scenarios/165-visibility-edge-cases/run.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
# 165-visibility-edge-cases — the edge cases of agent visibility (tags, the
# per-agent messaging policy, delegated grants, analytics) that 160 does not
# walk, driven through the raw /api/v1 wire contract:
#
# defaults fresh agents carry tags [] / messaging any, and DMs, room
# posts and human DMs behave exactly as before;
# validation tag slug shape, the 10-tag cap, sorted tags, bad policy
# values, bad grant scopes and principal ids → 400;
# open-dm-policy a policy change bites an ALREADY-OPEN agent↔agent DM: sends
# (and re-ensures) refused after tightening with the blocking
# side named, history still readable, allowed after loosening;
# both sides blocking → both named;
# tag-removed `tags` policy + the shared tag removed from ONE side
# mid-conversation → refused both ways; re-adding it reopens;
# agent-to-human `tags` / `none` never restrict agent→human DMs or room posts;
# delegate a `tag:T` holder: cannot grant (itself, its granter, anyone),
# cannot change its own tags/policy, cannot act outside T,
# CAN add T to an untagged agent (spec: "add/remove that tag
# on agents"), loses everything once its grant is revoked;
# chief a `tags:*` holder grants only `tag:<slug>`, never to itself,
# and a `tag:T` holder cannot touch it (outranked) or revoke a
# grant it did not create;
# no-authority a plain member / a grant-less agent is refused tags,
# messaging, grants and another agent's analytics;
# cross-org another org's agent / grant / member ids → 404 (never 403),
# byte-identical to a fabricated id;
# analytics a refused send/ensure counts nothing; an agent→human DM
# lands in withHumans;
# cascade deleting an agent drops the grants it held; removing a
# human from the org drops the grants they held.
#
# Every case runs to completion and the scenario prints a per-case PASS/FAIL
# table; a failing case prints the exact request and response. Behaviour the
# spec does not define is printed as OBSERVED (informational, never a failure).
set -euo pipefail
SCENARIO_NAME="165-visibility-edge-cases"
. "$(cd "$(dirname "$0")/.." && pwd)/lib.sh"
scenario_start
# --------------------------------------------------------------------------
# Harness: actors by name, one request helper, per-case isolation.
# --------------------------------------------------------------------------
declare -A TOK=() # actor name → bearer token (ses_ / agk_)
declare -A ID=() # actor name → principal id (usr_ / agt_)
# req <actor> <METHOD> <path> [json-body] — sets ST (status), BODY, LAST (the
# exchange, verbatim, for failure reports). Never fails by itself.
req() {
local who="$1" m="$2" p="$3" b="${4:-}" f
f="$SPARROW_TMPROOT/resp.$BASHPID"
local -a args=(-sS -o "$f" -w '%{http_code}' -X "$m" "$SERVER/api/v1$p"
-H "authorization: Bearer ${TOK[$who]}")
[ -n "$b" ] && args+=(-H 'content-type: application/json' -d "$b")
ST="$(curl "${args[@]}" 2>/dev/null || true)"
BODY="$(cat "$f" 2>/dev/null || true)"
LAST="[$who] $m $p${b:+ $b} → $ST $BODY"
}
# expect_status <code> <label>
expect_status() {
[ "$ST" = "$1" ] || fail "$2: expected HTTP $1 — $LAST"
}
# expect_json <jq-filter> <expected> <label>
expect_json() {
local got
got="$(printf '%s' "$BODY" | jq -r "$1" 2>/dev/null || echo '<jq error>')"
[ "$got" = "$2" ] || fail "$3: [$1] expected [$2] got [$got] — $LAST"
}
# expect_refused <code> <reason|-> <label> — status plus error.reason ('-' = any).
expect_refused() {
expect_status "$1" "$3"
[ "$2" = "-" ] || expect_json '.error.reason' "$2" "$3 (reason)"
}
# observe <text> — record behaviour the spec does not define (printed, never judged).
observe() { printf '%s\n' "$*" >>"$SPARROW_TMPROOT/obs.$CASE_NAME"; }
CASES=(); RESULTS=(); FAILURES=()
# run_case <name> <function> — run a case in a subshell: a `fail` ends the case,
# not the scenario. State shared across cases lives on the SERVER (and in the
# setup globals), never in shell variables set inside a case.
run_case() {
local name="$1" fn="$2" out obs
CASE_NAME="$name"; obs="$SPARROW_TMPROOT/obs.$name"
if out="$( ( "$fn" ) 2>&1 )"; then
RESULTS+=("PASS"); echo " PASS $name"
else
RESULTS+=("FAIL"); echo " FAIL $name"
FAILURES+=("$name: $(printf '%s' "$out" | sed 's/\x1b\[[0-9;]*m//g' | grep -F 'FAIL ' | head -1)")
fi
CASES+=("$name")
if [ -s "$obs" ]; then while IFS= read -r l; do OBSERVED_ALL+=("$name: $l"); done <"$obs"; fi
}
OBSERVED_ALL=()
j() { jq -cn "$@"; }
# --------------------------------------------------------------------------
# Setup: org 1 with an owner, a plain member, a delegate human; agents minted
# by the owner who have all met in one room. Org 2 for the cross-org cases.
# --------------------------------------------------------------------------
TOK[owner]="$(signup owner@ex.com password123 Owner)"
ORG="$(first_org_id "${TOK[owner]}")"
ID[owner]="$(api "${TOK[owner]}" GET /me | jq -r '.principal.id')"
TOK[member]="$(add_human_to_org "${TOK[owner]}" "$ORG" member@ex.com password123 Member)"
ID[member]="$(api "${TOK[member]}" GET /me | jq -r '.principal.id')"
TOK[dora]="$(add_human_to_org "${TOK[owner]}" "$ORG" dora@ex.com password123 Dora)"
ID[dora]="$(api "${TOK[dora]}" GET /me | jq -r '.principal.id')"
for who in owner member dora; do
case "${ID[$who]}" in usr_*) : ;; *) fail "setup: could not resolve $who's id (${ID[$who]})" ;; esac
done
mint() { # mint <owner-actor> <name> — agent created by that human, joined to the crew room
local r
r="$(create_agent "${TOK[$1]}" "$ORG" "$2")" || fail "setup: create agent $2"
ID[$2]="$(jq -r '.agent.id' <<<"$r")"; TOK[$2]="$(jq -r '.key' <<<"$r")"
printf '%s' "$r" >"$SPARROW_TMPROOT/created.$2"
}
for a in ant bee cat dan eel fox gnu hen; do mint owner "$a"; done
CREW="$(ac_tok "${TOK[owner]}" room create crew --json | jq -r '.id')"
[ -n "$CREW" ] && [ "$CREW" != null ] || fail "setup: create crew room"
for a in ant bee cat dan eel fox gnu hen; do
ac_tok "${TOK[owner]}" room add "${ID[$a]}" --room "$CREW" >/dev/null || fail "setup: add $a to crew"
done
# Org 2: a second human's own org, with an agent and a grant of its own.
TOK[owner2]="$(signup owner2@ex.com password123 OwnerTwo)"
ORG2="$(api "${TOK[owner2]}" POST /orgs '{"name":"Org Two"}' | jq -r '.org.id')"
ID[owner2]="$(api "${TOK[owner2]}" GET /me | jq -r '.principal.id')"
r="$(create_agent "${TOK[owner2]}" "$ORG2" zed)"; ID[zed]="$(jq -r '.agent.id' <<<"$r")"; TOK[zed]="$(jq -r '.key' <<<"$r")"
TOK[yak2]="$(add_human_to_org "${TOK[owner2]}" "$ORG2" yak@ex.com password123 Yak)"
ID[yak2]="$(api "${TOK[yak2]}" GET /me | jq -r '.principal.id')"
G2="$(api "${TOK[owner2]}" POST "/orgs/$ORG2/grants" "$(j --arg p "${ID[zed]}" '{principalId:$p,scope:"tag:ops"}')" | jq -r '.grant.id')"
case "$G2" in grt_*) : ;; *) fail "setup: org2 grant ($G2)" ;; esac
A="/orgs/$ORG/agents"
# dm <actor> <principal-id> — ensure a DM, echo the room id (sets ST/BODY/LAST).
dm_room() { req "$1" POST /me/dms "$(j --arg p "$2" '{principal:$p}')"; }
send() { req "$1" POST "/rooms/$2/messages" "$(j --arg b "$3" '{body:$b}')"; }
set_policy() { req owner PUT "$A/${ID[$1]}/messaging" "$(j --arg m "$2" '{messaging:$m}')"; expect_status 200 "setup policy $1=$2"; }
set_tags() { req owner PUT "$A/${ID[$1]}/tags" "$(jq -cn --args '{tags:$ARGS.positional}' "${@:2}")"; expect_status 200 "setup tags $1"; }
grant() { # grant <actor> <principal-name> <scope> → echoes grant id
req "$1" POST "/orgs/$ORG/grants" "$(j --arg p "${ID[$2]}" --arg s "$3" '{principalId:$p,scope:$s}')"
}
# --------------------------------------------------------------------------
# Cases
# --------------------------------------------------------------------------
case_defaults() {
local created room
created="$(cat "$SPARROW_TMPROOT/created.hen")"
BODY="$created"; LAST="POST /me/agents → $created"
expect_json '.agent.tags | length' '0' 'minted agent has no tags'
expect_json '.agent.messaging' 'any' 'minted agent defaults to any'
req hen GET "$A/${ID[hen]}"
expect_status 200 'agent reads itself via the org route'
expect_json '.agent.tags | length' '0' 'GET agent: tags []'
expect_json '.agent.messaging' 'any' 'GET agent: messaging any'
# Untouched agents that have met DM each other and post in the room as before.
dm_room hen "${ID[gnu]}"; expect_status 201 'default hen → gnu DM ensure'
room="$(jq -r '.room.id' <<<"$BODY")"
send hen "$room" 'hi gnu'; expect_status 201 'default hen → gnu send'
send gnu "$room" 'hi hen'; expect_status 201 'default gnu → hen reply'
send hen "$CREW" 'hello crew'; expect_status 201 'default room post'
dm_room hen "${ID[owner]}"; expect_status 201 'default hen → owner DM ensure'
send hen "$(jq -r '.room.id' <<<"$BODY")" 'hi owner'; expect_status 201 'default hen → owner send'
# The agents list carries the new fields with defaults for everyone.
req owner GET "/me/agents"
if [ "$ST" = 200 ]; then
expect_json "[.. | objects | select(.id? == \"${ID[hen]}\" and has(\"messaging\")) | .messaging] | first" 'any' 'agents list: hen messaging any'
fi
}
case_validation() {
req owner PUT "$A/${ID[eel]}/tags" '{"tags":["Cubes"]}'; expect_status 400 'uppercase slug'
req owner PUT "$A/${ID[eel]}/tags" '{"tags":["-lead"]}'; expect_status 400 'leading hyphen'
req owner PUT "$A/${ID[eel]}/tags" "$(j --arg t "a$(printf 'b%.0s' {1..32})" '{tags:[$t]}')"; expect_status 400 '33-char slug'
req owner PUT "$A/${ID[eel]}/tags" "$(j --arg t "a$(printf 'b%.0s' {1..31})" '{tags:[$t]}')"; expect_status 200 '32-char slug is the max'
req owner PUT "$A/${ID[eel]}/tags" '{"tags":["t1","t2","t3","t4","t5","t6","t7","t8","t9","t10","t11"]}'
expect_status 400 '11 tags'
req owner PUT "$A/${ID[eel]}/tags" '{"tags":["t9","t1","t2","t3","t4","t5","t6","t7","t8","t10"]}'
expect_status 200 '10 tags is the cap'
expect_json '.agent.tags | join(",")' 't1,t10,t2,t3,t4,t5,t6,t7,t8,t9' 'tags come back sorted'
req owner PUT "$A/${ID[eel]}/tags" '{"tags":[]}'; expect_status 200 'clear tags'
expect_json '.agent.tags | length' '0' 'tags cleared'
req owner PUT "$A/${ID[eel]}/messaging" '{"messaging":"some"}'; expect_status 400 'bad policy value'
req owner PUT "$A/${ID[eel]}/messaging" '{}'; expect_status 400 'missing policy'
grant owner eel 'tag:Bad'; expect_status 400 'bad grant slug'
grant owner eel 'admin'; expect_status 400 'unknown scope'
grant owner eel 'tags:ops'; expect_status 400 'tags:<x> is not a scope'
req owner POST "/orgs/$ORG/grants" '{"principalId":"foo_123","scope":"tag:x"}'; expect_status 400 'non-principal id'
}
case_open_dm_policy() {
local room
dm_room ant "${ID[bee]}"; expect_status 201 'ant → bee DM (both any)'
room="$(jq -r '.room.id' <<<"$BODY")"
send ant "$room" 'before'; expect_status 201 'send before tightening'
set_policy ant none
send ant "$room" 'after none'
expect_refused 403 messaging_policy 'ant (none) send into the open DM'
expect_json '.error.message | contains("ant’s messaging setting is `none`")' 'true' 'names ant’s setting'
send bee "$room" 'reply after none'
expect_refused 403 messaging_policy 'bee send into the DM ant tightened'
expect_json '.error.message | contains("ant")' 'true' 'bee hears ant’s setting'
dm_room bee "${ID[ant]}"
expect_refused 403 messaging_policy 're-ensure of the existing DM is refused too'
# History stays readable for both sides.
req ant GET "/rooms/$room/messages"; expect_status 200 'ant still reads the DM'
expect_json '[.items[]? // .messages[]? | select(.body == "before")] | length' '1' 'history kept (ant)'
req bee GET "/rooms/$room/messages"; expect_status 200 'bee still reads the DM'
# Both sides blocking → both named.
set_policy bee tags
send bee "$room" 'both blocked'
expect_refused 403 messaging_policy 'both sides block'
expect_json '.error.message | (contains("ant’s messaging setting is `none`") and contains("bee’s messaging setting is `tags`"))' 'true' 'both sides named'
# Loosen: back to any → the same DM works again, both directions.
set_policy ant any
set_policy bee any
send ant "$room" 'after loosening'; expect_status 201 'ant sends after loosening'
send bee "$room" 'reply after loosening'; expect_status 201 'bee sends after loosening'
}
case_tag_removed_midway() {
local room
set_tags cat cubes
set_tags dan cubes
set_policy cat tags
dm_room cat "${ID[dan]}"; expect_status 201 'cat (tags) → dan share cubes'
room="$(jq -r '.room.id' <<<"$BODY")"
send dan "$room" 'shared tag'; expect_status 201 'dan (any) → cat while shared'
# Remove the shared tag from dan (the side whose policy is `any`).
set_tags dan
send cat "$room" 'after removal'
expect_refused 403 messaging_policy 'cat sends after dan lost the shared tag'
expect_json '.error.message | contains("cat’s messaging setting is `tags`")' 'true' 'names cat (the tags side)'
send dan "$room" 'after removal'
expect_refused 403 messaging_policy 'dan (any) is refused too: both policies must allow'
req dan GET "/rooms/$room/messages"; expect_status 200 'dan still reads the DM'
# A different tag on dan does not count; the shared one reopens.
set_tags dan reviewers
send cat "$room" 'still no'; expect_refused 403 messaging_policy 'non-shared tag does not reopen'
set_tags dan cubes reviewers
send cat "$room" 'shared again'; expect_status 201 'shared tag back → allowed'
send dan "$room" 'shared again'; expect_status 201 'dan allowed again'
set_policy cat any; set_tags cat; set_tags dan
}
case_agent_to_human() {
local room policy
for policy in tags none; do
set_policy fox "$policy"
dm_room fox "${ID[owner]}"
[ "$ST" = 200 ] || [ "$ST" = 201 ] || fail "fox ($policy) → owner DM ensure: $LAST"
room="$(jq -r '.room.id' <<<"$BODY")"
send fox "$room" "to owner under $policy"; expect_status 201 "fox ($policy) → owner send"
send owner "$room" "owner reply under $policy"; expect_status 201 "owner → fox ($policy) send"
send fox "$CREW" "room post under $policy"; expect_status 201 "fox ($policy) room post"
done
# …while agent→agent is refused under `none`.
dm_room fox "${ID[gnu]}"; expect_refused 403 messaging_policy 'fox (none) → gnu refused'
set_policy fox any
}
# A `tag:cubes` delegate that is an AGENT (cat) and one that is a HUMAN (dora).
case_delegate() {
set_tags bee cubes
set_tags gnu
grant owner cat tag:cubes; [ "$ST" = 201 ] || [ "$ST" = 200 ] || fail "grant cat tag:cubes: $LAST"
local gcat; gcat="$(jq -r '.grant.id' <<<"$BODY")"
grant owner dora tag:cubes; [ "$ST" = 201 ] || [ "$ST" = 200 ] || fail "grant dora tag:cubes: $LAST"
# (a) cannot grant — not itself, not its granter, not anyone.
grant cat cat tag:cubes; expect_refused 403 self 'cat grants itself'
grant cat owner tag:cubes; expect_refused 403 grant_required 'cat grants its granter'
grant cat bee tag:cubes; expect_refused 403 grant_required 'cat grants a peer'
grant dora dora tag:cubes; expect_refused 403 self 'dora grants herself'
grant dora member tag:cubes; expect_refused 403 grant_required 'dora grants a member'
# (b) cannot change its own tags or policy.
req cat PUT "$A/${ID[cat]}/tags" '{"tags":["cubes"]}'; expect_refused 403 self 'cat tags itself'
req cat PUT "$A/${ID[cat]}/messaging" '{"messaging":"none"}'; expect_refused 403 self 'cat sets its own policy'
# (c) inside T: may change bee's policy and toggle cubes on bee…
req cat PUT "$A/${ID[bee]}/messaging" '{"messaging":"tags"}'; expect_status 200 'cat sets bee (cubes) policy'
req dora PUT "$A/${ID[bee]}/messaging" '{"messaging":"any"}'; expect_status 200 'dora sets bee (cubes) policy'
# …but not outside T.
req cat PUT "$A/${ID[gnu]}/messaging" '{"messaging":"none"}'; expect_refused 403 grant_required 'cat on untagged gnu policy'
req dora PUT "$A/${ID[gnu]}/messaging" '{"messaging":"none"}'; expect_refused 403 grant_required 'dora on untagged gnu policy'
req cat PUT "$A/${ID[bee]}/tags" '{"tags":["cubes","other"]}'; expect_refused 403 grant_required 'cat adds a tag outside T'
set_tags bee cubes other
req cat PUT "$A/${ID[bee]}/tags" '{"tags":["cubes"]}'; expect_refused 403 grant_required 'cat removes a tag outside T'
req cat PUT "$A/${ID[bee]}/tags" '{"tags":["other","cubes"]}'; expect_status 200 'unchanged set needs no extra authority'
req cat PUT "$A/${ID[bee]}/tags" '{"tags":["other"]}'; expect_status 200 'cat removes cubes (T) from bee'
req cat PUT "$A/${ID[bee]}/messaging" '{"messaging":"none"}'; expect_refused 403 grant_required 'cat lost bee once cubes is gone'
# A `tag:<name>` holder manages agents already carrying the tag; it never
# recruits new ones (that would hand it authority over any agent).
req cat PUT "$A/${ID[gnu]}/tags" '{"tags":["cubes"]}'; expect_refused 403 grant_required 'cat cannot recruit untagged gnu into T'
req cat GET "$A/${ID[gnu]}/analytics?window=24h"; expect_refused 403 grant_required 'cat cannot read untagged gnu analytics'
set_tags gnu cubes
req cat PUT "$A/${ID[gnu]}/messaging" '{"messaging":"tags"}'; expect_status 200 'cat governs gnu once the owner tags it'
# Analytics follow the same cover.
req cat GET "$A/${ID[gnu]}/analytics?window=24h"; expect_status 200 'cat reads gnu (cubes) analytics'
req cat GET "$A/${ID[hen]}/analytics?window=24h"; expect_refused 403 grant_required 'cat reads untagged hen analytics'
# (d) revoked → every power gone.
req owner DELETE "/orgs/$ORG/grants/$gcat"; expect_status 200 'owner revokes cat'
req cat PUT "$A/${ID[gnu]}/messaging" '{"messaging":"any"}'; expect_refused 403 grant_required 'revoked cat changes policy'
req cat PUT "$A/${ID[gnu]}/tags" '{"tags":[]}'; expect_refused 403 grant_required 'revoked cat changes tags'
req cat GET "$A/${ID[gnu]}/analytics?window=24h"; expect_refused 403 grant_required 'revoked cat reads analytics'
req cat DELETE "/orgs/$ORG/grants/$gcat"; expect_status 404 'revoked grant is gone'
req owner GET "/orgs/$ORG/grants"
expect_json "[.items[] | select(.id == \"$gcat\")] | length" '0' 'grant list no longer has it'
set_policy gnu any; set_tags gnu; set_tags bee
}
case_self_revoke() {
# Spec §3/§6: "Nobody changes their own … grants"; "Who may delete: org
# owners/admins, or the grant's creator". The server lets a holder give up its
# own grant — recorded, not judged (see the open questions in the report).
grant owner hen tag:qa; [ "$ST" = 201 ] || fail "grant hen tag:qa: $LAST"
local g; g="$(jq -r '.grant.id' <<<"$BODY")"
req hen DELETE "/orgs/$ORG/grants/$g"
observe "holder (agent, not creator) deletes its own grant → $ST $BODY"
# A non-holder, non-creator, non-admin may never revoke someone else's grant.
grant owner hen tag:qa; g="$(jq -r '.grant.id' <<<"$BODY")"
req member DELETE "/orgs/$ORG/grants/$g"; expect_refused 403 grant_required 'plain member revokes hen'
req gnu DELETE "/orgs/$ORG/grants/$g"; expect_refused 403 grant_required 'grant-less agent revokes hen'
req owner DELETE "/orgs/$ORG/grants/$g"; expect_status 200 'cleanup'
}
case_chief() {
grant owner dan 'tags:*'; [ "$ST" = 201 ] || fail "grant dan tags:*: $LAST"
local gdan; gdan="$(jq -r '.grant.id' <<<"$BODY")"
grant dan dan tag:x; expect_refused 403 self 'chief grants itself'
grant dan eel 'tags:*'; expect_refused 403 grant_required 'chief grants tags:*'
grant dan eel tag:ops; expect_status 201 'chief grants tag:ops to eel'
local geel; geel="$(jq -r '.grant.id' <<<"$BODY")"
expect_json '.grant.grantedBy' "${ID[dan]}" 'grantedBy is the chief'
# The tag:ops holder cannot touch the chief (outranked) nor revoke the chief's grant.
set_tags dan ops
req eel PUT "$A/${ID[dan]}/messaging" '{"messaging":"none"}'; expect_refused 403 outranked 'tag:ops holder on the chief'
req eel PUT "$A/${ID[dan]}/tags" '{"tags":[]}'; expect_refused 403 outranked 'tag:ops holder on the chief tags'
req eel DELETE "/orgs/$ORG/grants/$gdan"; expect_refused 403 grant_required 'eel revokes the chief grant'
# The chief acts on the ungranted owner-minted agents and revokes what it created.
req dan PUT "$A/${ID[hen]}/messaging" '{"messaging":"tags"}'; expect_status 200 'chief sets hen policy'
req dan DELETE "/orgs/$ORG/grants/$geel"; expect_status 200 'chief revokes the grant it created'
# A plain member who owns an agent keeps implicit authority over it even when it holds tags:*.
local mo; mo="$(create_agent "${TOK[member]}" "$ORG" mo)"; ID[mo]="$(jq -r '.agent.id' <<<"$mo")"
grant owner mo 'tags:*'; expect_status 201 'admin grants the member’s agent tags:*'
req member PUT "$A/${ID[mo]}/messaging" '{"messaging":"none"}'; expect_status 200 'owner (member) exempt from outranked'
req dan PUT "$A/${ID[mo]}/messaging" '{"messaging":"any"}'; expect_status 200 'tags:* holder on a tags:* holder (covers it)'
grant member eel tag:ops; expect_refused 403 grant_required 'agent ownership confers no grant authority'
req owner DELETE "/orgs/$ORG/grants/$gdan"; expect_status 200 'cleanup chief'
set_policy hen any; set_tags dan
}
case_no_authority() {
req member PUT "$A/${ID[ant]}/tags" '{"tags":["cubes"]}'; expect_refused 403 grant_required 'member tags owner’s agent'
req member PUT "$A/${ID[ant]}/messaging" '{"messaging":"none"}'; expect_refused 403 grant_required 'member sets owner’s agent policy'
req member GET "$A/${ID[ant]}/analytics?window=7d"; expect_refused 403 grant_required 'member reads owner’s agent analytics'
req gnu GET "$A/${ID[ant]}/analytics?window=7d"; expect_refused 403 grant_required 'agent reads another agent’s analytics'
req gnu PUT "$A/${ID[ant]}/messaging" '{"messaging":"none"}'; expect_refused 403 grant_required 'agent sets another agent’s policy'
grant member member tag:x; expect_refused 403 self 'member grants itself'
grant member ant tag:x; expect_refused 403 grant_required 'member grants'
# Reads the spec opens to every member.
req member GET "/orgs/$ORG/grants"; expect_status 200 'any member lists grants'
req gnu GET "/orgs/$ORG/grants"; expect_status 200 'any agent lists grants'
req member GET "$A/${ID[ant]}"; expect_status 200 'any member reads an agent (tags are org-visible)'
# Recorded, not judged: the spec lists owner/admins/grant holders as readers.
req ant GET "$A/${ID[ant]}/analytics?window=24h"; observe "agent reads its OWN analytics → $ST"
}
case_cross_org() {
local fake='agt_00000000000000000000000000' fakeg='grt_00000000000000000000000000' b_real b_fake
# Another org's agent under MY org's routes: 404, same body as a fabricated id.
req owner PUT "$A/${ID[zed]}/tags" '{"tags":["x"]}'; expect_status 404 'org1 owner tags org2 agent'
b_real="$BODY"
req owner PUT "$A/$fake/tags" '{"tags":["x"]}'; expect_status 404 'fabricated agent id'
[ "$b_real" = "$BODY" ] || fail "cross-org agent body differs from fabricated: [$b_real] vs [$BODY]"
req owner PUT "$A/${ID[zed]}/messaging" '{"messaging":"none"}'; expect_status 404 'org1 owner sets org2 agent policy'
req owner GET "$A/${ID[zed]}/analytics?window=24h"; expect_status 404 'org1 owner reads org2 agent analytics'
req owner GET "$A/${ID[zed]}"; expect_status 404 'org1 owner reads org2 agent'
# Another org's grant id.
req owner DELETE "/orgs/$ORG/grants/$G2"; expect_status 404 'org1 owner deletes org2 grant via org1'
b_real="$BODY"
req owner DELETE "/orgs/$ORG/grants/$fakeg"; expect_status 404 'fabricated grant id'
[ "$b_real" = "$BODY" ] || fail "cross-org grant body differs from fabricated: [$b_real] vs [$BODY]"
req owner DELETE "/orgs/$ORG2/grants/$G2"; expect_status 404 'non-member deletes org2 grant via org2'
# Another org's principals as grantees.
grant owner zed tag:x; expect_status 404 'grant to org2 agent'
grant owner yak2 tag:x; expect_status 404 'grant to org2-only human'
# Another org's routes altogether: org1 human and org1 agent are not members.
req owner GET "/orgs/$ORG2/grants"; expect_status 404 'org1 owner lists org2 grants'
req ant GET "/orgs/$ORG2/grants"; expect_status 404 'org1 agent lists org2 grants'
req ant PUT "/orgs/$ORG2/agents/${ID[zed]}/messaging" '{"messaging":"none"}'; expect_status 404 'org1 agent on org2 agent'
req ant GET "/orgs/$ORG2/agents/${ID[zed]}/analytics"; expect_status 404 'org1 agent reads org2 analytics'
# And org2's grant holder gets no reach into org1.
req zed PUT "$A/${ID[ant]}/messaging" '{"messaging":"none"}'; expect_status 404 'org2 agent on org1 agent'
}
case_analytics_refused() {
local before after room
set_tags eel
dm_room eel "${ID[hen]}"; expect_status 201 'eel → hen DM'
room="$(jq -r '.room.id' <<<"$BODY")"
send eel "$room" 'counted'; expect_status 201 'one counted send'
req owner GET "$A/${ID[eel]}/analytics?window=24h"; expect_status 200 'owner reads eel'
before="$BODY"
req owner GET "$A/${ID[hen]}/analytics?window=24h"; local hen_before="$BODY"
set_policy eel none
send eel "$room" 'refused one'; expect_refused 403 messaging_policy 'refused send'
send hen "$room" 'refused two'; expect_refused 403 messaging_policy 'refused reply'
dm_room eel "${ID[gnu]}"; expect_refused 403 messaging_policy 'refused ensure'
req owner GET "$A/${ID[eel]}/analytics?window=24h"; after="$BODY"
BODY="$after"; LAST="GET eel analytics after refusals → $after (before: $before)"
expect_json '.totals' "$(jq -r '.totals' <<<"$before")" 'eel totals unchanged by refusals'
req owner GET "$A/${ID[hen]}/analytics?window=24h"
expect_json "[.counterparts[] | select(.id == \"${ID[eel]}\")][0].messages" '1' 'hen↔eel: only the one stored DM (refused reply not counted)'
expect_json '.totals' "$(jq -r '.totals' <<<"$hen_before")" 'hen totals unchanged by refusals'
set_policy eel any
# Agent → human DMs land in withHumans with the human as counterpart.
dm_room eel "${ID[owner]}"; [ "$ST" = 201 ] || [ "$ST" = 200 ] || fail "eel → owner: $LAST"
room="$(jq -r '.room.id' <<<"$BODY")"
send eel "$room" 'abcdefgh'; expect_status 201 'eel → owner'
send owner "$room" 'abcd'; expect_status 201 'owner → eel'
req owner GET "$A/${ID[eel]}/analytics?window=all"; expect_status 200 'window all'
expect_json '.withHumans.messages' '2' 'withHumans counts both directions'
expect_json '.withHumans.tokens' '3' 'tokens = ceil(8/4)+ceil(4/4)'
expect_json "[.counterparts[] | select(.kind == \"human\" and .id == \"${ID[owner]}\")][0].messages" '2' 'owner is a human counterpart'
req owner GET "$A/${ID[eel]}/analytics?window=1y"; expect_status 400 'unknown window'
}
case_cascade() {
local g gd room
# An agent holding a grant, tags, and counters as someone's counterpart.
local r; r="$(create_agent "${TOK[owner]}" "$ORG" imp)"; ID[imp]="$(jq -r '.agent.id' <<<"$r")"; TOK[imp]="$(jq -r '.key' <<<"$r")"
ac_tok "${TOK[owner]}" room add "${ID[imp]}" --room "$CREW" >/dev/null
set_tags imp cubes
grant owner imp 'tags:*'; expect_status 201 'grant imp tags:*'; g="$(jq -r '.grant.id' <<<"$BODY")"
grant imp gnu tag:cubes; expect_status 201 'imp grants gnu tag:cubes'; gd="$(jq -r '.grant.id' <<<"$BODY")"
dm_room imp "${ID[hen]}"; room="$(jq -r '.room.id' <<<"$BODY")"
send imp "$room" 'before delete'; expect_status 201 'imp → hen'
req owner DELETE "/me/agents/${ID[imp]}"; expect_status 200 'owner deletes imp'
req owner GET "/orgs/$ORG/grants"
expect_json "[.items[] | select(.id == \"$g\")] | length" '0' 'the grant imp HELD is gone'
observe "grant CREATED by the deleted agent (gnu tag:cubes) → $(jq -c "[.items[] | select(.id == \"$gd\")] | length" <<<"$BODY") remaining"
req owner GET "$A/${ID[imp]}"; expect_status 404 'deleted agent is 404'
req owner GET "$A/${ID[imp]}/analytics?window=24h"; expect_status 404 'deleted agent analytics 404'
req owner GET "$A/${ID[hen]}/analytics?window=24h"
observe "hen's analytics after its counterpart imp was deleted: counterparts=$(jq -c "[.counterparts[] | select(.id == \"${ID[imp]}\")]" <<<"$BODY")"
[ -n "$gd" ] && req owner DELETE "/orgs/$ORG/grants/$gd"
# A human holding a grant is removed from the org → the grant goes.
local ivy; ivy="$(add_human_to_org "${TOK[owner]}" "$ORG" ivy@ex.com password123 Ivy)"
TOK[ivy]="$ivy"; ID[ivy]="$(api "$ivy" GET /me | jq -r '.principal.id')"
grant owner ivy tag:cubes; expect_status 201 'grant ivy'; g="$(jq -r '.grant.id' <<<"$BODY")"
req owner DELETE "/orgs/$ORG/humans/${ID[ivy]}"; expect_status 200 'owner removes ivy'
req owner GET "/orgs/$ORG/grants"
expect_json "[.items[] | select(.id == \"$g\" or .principalId == \"${ID[ivy]}\")] | length" '0' 'removed human’s grants are gone'
req ivy PUT "$A/${ID[bee]}/messaging" '{"messaging":"none"}'; expect_status 404 'removed human is a non-member (404)'
}
echo "Cases:"
run_case defaults case_defaults
run_case validation case_validation
run_case open-dm-policy case_open_dm_policy
run_case tag-removed-midway case_tag_removed_midway
run_case agent-to-human case_agent_to_human
run_case delegate case_delegate
run_case self-revoke case_self_revoke
run_case chief case_chief
run_case no-authority case_no_authority
run_case cross-org case_cross_org
run_case analytics-refused case_analytics_refused
run_case cascade case_cascade
if [ ${#OBSERVED_ALL[@]} -gt 0 ]; then
echo "Observed (spec silent — informational):"
printf ' %s\n' "${OBSERVED_ALL[@]}"
fi
if [ ${#FAILURES[@]} -gt 0 ]; then
printf '%s\n' "${FAILURES[@]}" >&2
fail "${#FAILURES[@]} of ${#CASES[@]} cases failed"
fi
pass "${#CASES[@]} visibility edge cases"