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
4 changes: 2 additions & 2 deletions docs/SPEC-v0.4.md
Original file line number Diff line number Diff line change
Expand Up @@ -981,8 +981,8 @@ Its first line, before any table:

> This is a **reading** of somebody else's taxonomy against the guarantees CTRLRun tests. It
> is not a compliance claim, a conformance claim, a certification, or a statement that CTRLRun
> covers the OWASP Top 10 for Agentic Applications. Four of the ten entries are not addressed
> by CTRLRun at all, and they are listed by name below.
> covers the OWASP Top 10 for Agentic Applications. Three of the ten entries are not
> addressed by CTRLRun at all, and they are listed by name below.

`ROADMAP.md`'s standards rule is the reason this document can exist at all: *integrate first,
map second, never claim compliance.* Every row maps a `G` to an entry, and every `G` is
Expand Down
12 changes: 6 additions & 6 deletions docs/docs/OWASP-AGENTIC-TOP10.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ description: "A reading of somebody else's taxonomy against the guarantees CTRLR

This is a **reading** of somebody else's taxonomy against the guarantees CTRLRun tests. It is
not a compliance claim, a conformance claim, a certification, or a statement that CTRLRun
covers the OWASP Top 10 for Agentic Applications. Four of the ten entries are not addressed by
CTRLRun at all, and they are listed by name below.
covers the OWASP Top 10 for Agentic Applications. Three of the ten entries are not addressed
by CTRLRun at all, and they are listed by name below.

Every row maps a guarantee to an entry, and every guarantee is backed by a passing acceptance
test — so each row points at code and at a test. A row whose test disappears is a row that
Expand Down Expand Up @@ -62,12 +62,12 @@ mechanism, not the entry.

| Guarantee | Invariant | Entries | How |
|---|---|---|---|
| **G1** mutated approval refused | An approval is bound to one `action_hash`; presenting it for any other action is refused, and the approval is not consumed. | `ASI09:2026`, `ASI01:2026` (partly) | The approval a human granted is bound to the exact canonical form of the action they were shown, so an action that changed after the approval — by a hijacked goal or by anything else — has no approval to present. |
| **G1** mutated approval refused | An approval is bound to one `action_hash`; presenting it for any other action is refused, and the approval is not consumed. | `ASI09:2026`, `ASI01:2026` (partly), `ASI06:2026` (partly) | The approval a human granted is bound to the exact canonical form of the action they were shown, so an action that changed after the approval — by a hijacked goal or by anything else — has no approval to present. |
| **G2** replayed approval refused | An approval is single-use; the second presentation is refused and does not execute. | `ASI09:2026` | The approval record is consumed in the same transaction that admits it, so one human decision authorizes exactly one execution and a loop cannot spend it twice. |
| **G3** duplicate effect refused | A second attempt on an effect key whose record is `COMMITTED` is refused, and the remote is not called. | `ASI08:2026`, `ASI02:2026` | Effects are identified by a key derived from the action's own arguments, and a committed key is refused rather than retried — so a retry loop cannot turn one intended effect into several. |
| **G4** one winner under concurrency | Reservation is atomic across processes, not merely across threads. | `ASI08:2026` | The reservation is taken inside a `BEGIN IMMEDIATE` against a unique constraint on the effect key, so two agents that picked up the same task produce one effect and one refusal. |
| **G5** ambiguous blocks a blind retry | An executor that raises anything other than `NotExecuted` leaves the effect `AMBIGUOUS`, and the retry is refused rather than executed. | `ASI08:2026` | A lost response is recorded as an *unknown* outcome rather than a failure, and an unknown outcome is a state only a human or a reconciliation hook may leave — so the failure does not cascade into a second execution of something that may already have happened. |
| **G6** unknown action refused | Unknown action → DENY. There is no default-allow. | `ASI02:2026`, `ASI01:2026` (partly) | The policy is the list of what an agent may do; anything not written in it is refused, so a tool an agent was talked into reaching for is refused whether or not the reasoning that reached for it was sound. |
| **G6** unknown action refused | Unknown action → DENY. There is no default-allow. | `ASI02:2026`, `ASI01:2026` (partly), `ASI06:2026` (partly) | The policy is the list of what an agent may do; anything not written in it is refused, so a tool an agent was talked into reaching for is refused whether or not the reasoning that reached for it was sound. |
| **G7** no principal refused | An action proposed with no principal is refused, and no receipt and no events are written. | `ASI03:2026` | Every action carries a principal or it does not run, so there is no path on which an action executes with nobody attributable to it. |
| **G8** expired authority refused | A grant is authority only until its `expires_at`; after that the action it covered is denied, by name. | `ASI03:2026`, `ASI10:2026` | Authority is evaluated on every action against the clock, not at the start of a session, so an agent still running after its grant lapsed is denied on its next proposal. |
| **G9** delegation cannot escalate | A delegated grant is valid only if it is provably a subset of its parent on every dimension — and a child that **drops** a dimension its parent constrains is rejected rather than treated as unconstrained. | `ASI03:2026`, `ASI10:2026` | Containment is checked at creation and again on every evaluation by walking the chain to its root, and omission is never inheritance — so an agent handed authority cannot mint itself more of it, and a revocation anywhere in the chain cuts everything beneath it. |
Expand All @@ -84,14 +84,14 @@ The half that makes the table above credible. One honest sentence each; nothing
|---|---|---|
| `ASI04:2026` | Agentic Supply Chain Vulnerabilities | Out of scope. CTRLRun never inspects a package, a model, a tool registry or an MCP server's provenance; it decides actions, and a poisoned dependency reaches it as an ordinary caller. |
| `ASI05:2026` | Unexpected Code Execution | Out of scope. Nothing here sandboxes an interpreter or constrains what a process may run. CTRLRun sits between an agent and one remote effect, not between an agent and its own runtime. |
| `ASI06:2026` | Memory & Context Poisoning | Out of scope, and deliberately so: CTRLRun never reads a model's memory, its context or its prompt. It sees a proposed action and its arguments, which is the point at which a poisoned context has already become a concrete request. |
| `ASI07:2026` | Insecure Inter-Agent Communication | Not yet. Authority does not propagate across agent hops in this release — a grant is evaluated where the action is proposed, and there is no A2A model. `docs/docs/ROADMAP.md` puts that in v0.8; until then, an agent handing work to another agent is outside what these guarantees say anything about. |

And the two entries where the mapping above is **partial**, with the part that is not covered
And the three entries where the mapping above is **partial**, with the part that is not covered
stated here rather than left implied:

| Entry | Title | Covered | Not covered |
|---|---|---|---|
| `ASI06:2026` | Memory & Context Poisoning | G6 and G1 constrain what an agent acting on a poisoned context can *do*: the action must still be named in the policy, so a belief an attacker planted cannot reach a tool the agent was never entitled to use, and an approval granted for one action cannot be spent on another. This is the same downstream constraint that makes `ASI01` partial, and it is here for the same reason. | CTRLRun never reads a model's memory, its context or its prompt, so it neither detects nor prevents the poisoning. And the shape poisoning most often takes is the one the kernel has least to say about: **corrupted arguments to an action the agent is entitled to take** — the right operation against the wrong record. Policy conditions, resource patterns and v0.6 data scope bite on part of that; nothing bites on an identifier an attacker chose. |
| `ASI01:2026` | Agent Goal Hijack | G1 and G6 constrain what a hijacked agent can *do*: it still meets the policy, and it still cannot present an approval granted for a different action. | CTRLRun does not detect or prevent the hijack. It never sees the prompt, the plan or the reasoning, so an agent whose goal was replaced proposes actions exactly as a healthy one would — and every action inside its policy and its grants will run. |
| `ASI09:2026` | Human-Agent Trust Exploitation | G1 and G2 close the shape where an approval a human gave for one action is spent on another, or spent twice. | CTRLRun does not authenticate the *approver*, does not model separation of duties, and has no opinion on whether the human was misled into approving. A human persuaded to approve the right action for the wrong reason gets a valid approval, and the receipt records it as one. |

Expand Down
33 changes: 27 additions & 6 deletions tests/test_owasp_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ def _sections() -> tuple[str, str]:
return text[:split], text[split:]


def _guarantee_table() -> str:
"""Just the `Guarantee -> entries mitigated` table.

Narrower than `_sections()[0]` on purpose. That half opens with the enumeration of all ten
entries, so every code is in it and a membership test against it says nothing. What says
something is whether a code is cited by a guarantee row.
"""
text = _document()
start = text.index("## Guarantee")
return text[start : text.index("## Not covered by CTRLRun")]


# --- T121: the mapping is complete in both directions --------------------------------------


Expand Down Expand Up @@ -103,23 +115,32 @@ def test_T121_every_entry_in_the_cited_edition_appears_in_one_half_or_the_other(
assert code in mapping or code in not_covered, code


def test_T121_the_four_uncovered_entries_are_listed_by_name():
"""§6.1's disclaimer says four of the ten are not addressed at all, and this is the test
that keeps that sentence true rather than merely written."""
def test_T121_the_three_uncovered_entries_are_listed_by_name():
"""§6.1's disclaimer says three of the ten are not addressed at all, and this is the test
that keeps that sentence true rather than merely written.

`code in not_covered` on its own does not say it: a *partly* addressed entry appears in
that half too, by the design the next test asserts, so the membership check cannot tell
the two apart and the sentence was carrying this test alone. What separates them is that a
fully uncovered entry is cited by **no guarantee row** — which is a test against the
guarantee table and not against the half containing it, since that half opens by
enumerating all ten.
"""
_, not_covered = _sections()

fully_uncovered = {"ASI04:2026", "ASI05:2026", "ASI06:2026", "ASI07:2026"}
fully_uncovered = {"ASI04:2026", "ASI05:2026", "ASI07:2026"}
for code in fully_uncovered:
assert code in not_covered, code
assert ENTRIES[code] in not_covered, code
assert "Four of the ten entries are not addressed by CTRLRun at all" in _flat()
assert code not in _guarantee_table(), code
assert "Three of the ten entries are not addressed by CTRLRun at all" in _flat()


def test_T121_a_partly_addressed_entry_appears_in_both_halves():
"""§6.2 item 4 — the honest place for a hedge is next to the thing it qualifies."""
mapping, not_covered = _sections()

for code in ("ASI01:2026", "ASI09:2026"):
for code in ("ASI01:2026", "ASI06:2026", "ASI09:2026"):
assert code in mapping, code
assert code in not_covered, code
assert "Not covered" in not_covered
Expand Down