Skip to content

Commit 51829f7

Browse files
authored
feat(decisions): metadata field + grant-on-install consent backend (#1429)
Backend for the app permission consent flow (#56), per Jay's answers (forks 31-34): - Decisions gain an optional metadata JSON column (guarded ALTER migration mirroring board_audit; in _JSON_FIELDS; create() passthrough; DecisionIn field). - capabilities.py: CAPABILITY_DESCRIPTIONS (one human line per known cap) + describe_capability() handling network:<origin> and unknowns. - app_permissions.app_grant_decision_payload(): builds the multi_select consent card (options = requested caps, metadata.kind = app_grant). Not wired into install yet (live-verify session). - answer route: _apply_app_grant side-effect writes granted (selected) / denied (rest) per capability to the existing app_grants ledger, best-effort. Reuses the existing AppGrantsStore (app.state.app_grants, #1404/#1405). Tests: metadata round-trip, metadata echo, app_grant answer writes grants, payload builder, full capability-description coverage. 61 related tests pass.
1 parent 24622d2 commit 51829f7

7 files changed

Lines changed: 184 additions & 6 deletions

File tree

tests/test_app_capabilities.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
import pytest
33

44
from tinyagentos.userspace.capabilities import (
5+
CAPABILITY_DESCRIPTIONS,
56
FREE_CAPS,
67
GATED_CAPS,
78
KNOWN_CAPS,
9+
describe_capability,
810
is_known_capability,
911
)
1012
from tinyagentos.userspace.package import PackageError, parse_manifest
@@ -15,6 +17,20 @@ def test_known_caps_is_free_plus_gated_and_disjoint():
1517
assert FREE_CAPS.isdisjoint(GATED_CAPS)
1618

1719

20+
def test_every_known_cap_has_a_description():
21+
# The consent card renders one line per capability; a missing description
22+
# would show an empty/raw row, so coverage is enforced.
23+
for cap in KNOWN_CAPS:
24+
assert CAPABILITY_DESCRIPTIONS.get(cap), f"no description for {cap}"
25+
26+
27+
def test_describe_capability_handles_bare_network_and_unknown():
28+
assert describe_capability("app.net") == CAPABILITY_DESCRIPTIONS["app.net"]
29+
assert describe_capability("network:https://x.com") == "Connect to https://x.com"
30+
# Unknown tokens fall back to the raw string rather than an empty row.
31+
assert describe_capability("app.bogus") == "app.bogus"
32+
33+
1834
def test_broker_reexports_the_same_sets():
1935
# The broker must enforce the exact vocabulary the parser validates against.
2036
from tinyagentos.userspace import broker

tests/test_decision_store.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ async def test_invalid_type_rejected(store):
3333
await store.create("@a", "q", "bogus_type")
3434

3535

36+
@pytest.mark.asyncio
37+
async def test_metadata_round_trips(store):
38+
meta = {"kind": "app_grant", "app_id": "stream-chat", "capabilities": ["app.net"]}
39+
d = await store.create("@a", "grant?", "multi_select",
40+
options=[{"label": "Net", "value": "app.net"}], metadata=meta)
41+
assert d["metadata"] == meta
42+
got = await store.get(d["id"])
43+
assert got["metadata"] == meta
44+
# Omitted metadata defaults to an empty dict, not None.
45+
d2 = await store.create("@a", "q", "free_text")
46+
assert d2["metadata"] == {}
47+
48+
3649
@pytest.mark.asyncio
3750
async def test_list_filters(store):
3851
await store.create("@a", "q1", "approve_deny", project_id="p1", user_id="u1")

tests/test_routes_decisions.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,52 @@ async def test_duplicate_labels_get_distinct_values(client):
137137
assert resp.json()["answer"]["value"] == [one]
138138

139139

140+
@pytest.mark.asyncio
141+
async def test_metadata_echoed_on_create(client):
142+
resp = await client.post("/api/decisions", json={
143+
"from_agent": "@a", "question": "q", "type": "free_text",
144+
"metadata": {"kind": "app_grant", "app_id": "x", "capabilities": ["app.net"]},
145+
})
146+
assert resp.status_code == 200
147+
assert resp.json()["metadata"]["app_id"] == "x"
148+
149+
150+
@pytest.mark.asyncio
151+
async def test_app_grant_answer_writes_grants(client):
152+
# An app-grant consent Decision (metadata.kind == app_grant): answering the
153+
# multi_select with a subset writes granted for the picked caps and denied
154+
# for the rest to the app_grants ledger.
155+
app = client._transport.app
156+
resp = await client.post("/api/decisions", json={
157+
"from_agent": "@taos-app-install", "question": "stream-chat permissions",
158+
"type": "multi_select",
159+
"options": [{"label": "Net", "value": "app.net"},
160+
{"label": "Memory", "value": "app.memory"}],
161+
"metadata": {"kind": "app_grant", "app_id": "stream-chat",
162+
"capabilities": ["app.net", "app.memory"]},
163+
})
164+
d = resp.json()
165+
resp = await client.post(f"/api/decisions/{d['id']}/answer", json={"value": ["app.net"]})
166+
assert resp.status_code == 200
167+
user_id = d["user_id"]
168+
granted = await app.state.app_grants.granted_capabilities(user_id, "stream-chat")
169+
assert granted == {"app.net"}
170+
grants = {g["capability"]: g["decision"]
171+
for g in await app.state.app_grants.list_grants(user_id, "stream-chat")}
172+
assert grants == {"app.net": "granted", "app.memory": "denied"}
173+
174+
175+
@pytest.mark.asyncio
176+
async def test_app_grant_payload_builder():
177+
from tinyagentos.routes.app_permissions import app_grant_decision_payload
178+
payload = app_grant_decision_payload("stream-chat", ["app.net", "app.memory"])
179+
assert payload["type"] == "multi_select"
180+
assert payload["metadata"] == {"kind": "app_grant", "app_id": "stream-chat",
181+
"capabilities": ["app.net", "app.memory"]}
182+
assert [o["value"] for o in payload["options"]] == ["app.net", "app.memory"]
183+
assert payload["options"][0]["label"]
184+
185+
140186
@pytest.mark.asyncio
141187
async def test_answer_routes_back_to_bus_agent(client, monkeypatch):
142188
import tinyagentos.routes.decisions as dmod

tinyagentos/decisions/decision_store.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,15 @@
3636
deadline REAL,
3737
checkpoint_ref TEXT,
3838
parent_decision_id TEXT,
39-
timeline_id TEXT
39+
timeline_id TEXT,
40+
metadata TEXT NOT NULL DEFAULT '{}'
4041
);
4142
CREATE INDEX IF NOT EXISTS idx_decisions_status ON decisions(status, created_at DESC);
4243
CREATE INDEX IF NOT EXISTS idx_decisions_project ON decisions(project_id, status);
4344
CREATE INDEX IF NOT EXISTS idx_decisions_user ON decisions(user_id, status);
4445
"""
4546

46-
_JSON_FIELDS = ("options", "answer")
47+
_JSON_FIELDS = ("options", "answer", "metadata")
4748

4849

4950
def _row_to_decision(row, description) -> dict:
@@ -57,6 +58,22 @@ def _row_to_decision(row, description) -> dict:
5758
class DecisionStore(BaseStore):
5859
SCHEMA = DECISIONS_SCHEMA
5960

61+
async def _post_init(self) -> None:
62+
# `metadata` was added after the initial decisions ship. Guarded ALTER
63+
# so existing databases gain it without a destructive migration (SQLite
64+
# lacks ADD COLUMN IF NOT EXISTS before 3.37). Mirrors board_audit.py.
65+
cols = {
66+
row[1]
67+
for row in await (
68+
await self._db.execute("PRAGMA table_info(decisions)")
69+
).fetchall()
70+
}
71+
if "metadata" not in cols:
72+
await self._db.execute(
73+
"ALTER TABLE decisions ADD COLUMN metadata TEXT NOT NULL DEFAULT '{}'"
74+
)
75+
await self._db.commit()
76+
6077
async def create(
6178
self,
6279
from_agent: str,
@@ -72,6 +89,7 @@ async def create(
7289
parent_decision_id: str | None = None,
7390
checkpoint_ref: str | None = None,
7491
timeline_id: str | None = None,
92+
metadata: dict | None = None,
7593
) -> dict:
7694
if type not in DECISION_TYPES:
7795
raise ValueError(f"invalid decision type: {type!r}")
@@ -83,11 +101,12 @@ async def create(
83101
"""INSERT INTO decisions
84102
(id, from_agent, project_id, user_id, question, type, options, context,
85103
priority, status, created_at, deadline, parent_decision_id,
86-
checkpoint_ref, timeline_id)
87-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)""",
104+
checkpoint_ref, timeline_id, metadata)
105+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)""",
88106
(did, from_agent, project_id, user_id, question, type,
89107
json.dumps(options or []), context, priority, now, deadline,
90-
parent_decision_id, checkpoint_ref, timeline_id),
108+
parent_decision_id, checkpoint_ref, timeline_id,
109+
json.dumps(metadata or {})),
91110
)
92111
await self._db.commit()
93112
return await self.get(did)

tinyagentos/routes/app_permissions.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,38 @@
1919
from tinyagentos.auth_context import CurrentUser, current_user
2020
from tinyagentos.userspace.capabilities import (
2121
NET_PREFIX,
22+
describe_capability,
2223
is_known_capability,
2324
is_valid_network_grant,
2425
)
2526

2627
router = APIRouter()
2728

2829

30+
def app_grant_decision_payload(app_id: str, capabilities: list[str]) -> dict:
31+
"""Build the grant-on-install consent Decision for an app (#56, decision 6).
32+
33+
A multi_select card where each requested capability is an option the user
34+
grants or leaves unchecked; metadata.kind == "app_grant" routes the answer
35+
to the app_grants ledger (see _apply_app_grant in routes/decisions.py).
36+
Returned as kwargs for DecisionStore.create / POST /api/decisions. The
37+
install flow constructs and posts this; it is not wired into install yet."""
38+
return {
39+
"from_agent": "@taos-app-install",
40+
"question": f"{app_id} would like these permissions",
41+
"type": "multi_select",
42+
"priority": "blocking",
43+
"options": [
44+
{"label": describe_capability(c), "value": c} for c in capabilities
45+
],
46+
"metadata": {
47+
"kind": "app_grant",
48+
"app_id": app_id,
49+
"capabilities": list(capabilities),
50+
},
51+
}
52+
53+
2954
class GrantIn(BaseModel):
3055
capability: str
3156
decision: str = "granted" # "granted" or "denied"

tinyagentos/routes/decisions.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import httpx
1313
from fastapi import APIRouter, Depends, Request
1414
from fastapi.responses import JSONResponse
15-
from pydantic import BaseModel, model_validator
15+
from pydantic import BaseModel, Field, model_validator
1616

1717
from tinyagentos.auth_context import CurrentUser, current_user
1818
from tinyagentos.decisions.decision_store import DECISION_TYPES, PRIORITIES
@@ -89,6 +89,7 @@ class DecisionIn(BaseModel):
8989
parent_decision_id: str | None = None
9090
checkpoint_ref: str | None = None
9191
timeline_id: str | None = None
92+
metadata: dict = Field(default_factory=dict)
9293

9394
@model_validator(mode="after")
9495
def _dedupe_option_values(self) -> "DecisionIn":
@@ -147,6 +148,7 @@ async def create_decision(body: DecisionIn, request: Request, user: CurrentUser
147148
parent_decision_id=parent_id,
148149
checkpoint_ref=body.checkpoint_ref,
149150
timeline_id=body.timeline_id,
151+
metadata=body.metadata,
150152
)
151153

152154
# Mark the parent superseded only after the replacement is persisted.
@@ -244,5 +246,33 @@ async def answer_decision(decision_id: str, body: AnswerIn, request: Request, us
244246
updated = await store.answer(decision_id, body.value, answered_by)
245247
if updated is None:
246248
return JSONResponse({"error": "already answered or not pending"}, status_code=409)
249+
await _apply_app_grant(request, updated, body.value)
247250
await _route_answer_to_agent(updated, body.value)
248251
return updated
252+
253+
254+
async def _apply_app_grant(request: Request, decision: dict, value) -> None:
255+
"""Side effect for an app-grant consent Decision: write the per-capability
256+
grant decisions to the app_grants ledger. The decision's metadata carries
257+
{kind: "app_grant", app_id, capabilities}; for the multi_select consent card
258+
the answer is the list of granted capability values, so the rest are denied.
259+
Best-effort: the answer is already persisted, so a grant-store hiccup must
260+
not fail the answer."""
261+
meta = decision.get("metadata") or {}
262+
if meta.get("kind") != "app_grant":
263+
return
264+
grants = getattr(request.app.state, "app_grants", None)
265+
app_id = meta.get("app_id")
266+
caps = meta.get("capabilities") or []
267+
if grants is None or not app_id:
268+
return
269+
user_id = decision.get("user_id") or ""
270+
granted = set(value if isinstance(value, list) else [value])
271+
try:
272+
for cap in caps:
273+
await grants.set_decision(
274+
user_id, app_id, cap,
275+
decision="granted" if cap in granted else "denied",
276+
)
277+
except Exception:
278+
pass

tinyagentos/userspace/capabilities.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,32 @@ def is_known_capability(perm: str) -> bool:
5656
if not isinstance(perm, str):
5757
return False
5858
return perm in KNOWN_CAPS or perm.startswith(NET_PREFIX)
59+
60+
61+
# One-line, human, non-technical description per capability, for the consent
62+
# card (mirrors the agent SCOPE_DESCRIPTIONS). Every entry in KNOWN_CAPS has
63+
# one; the test_capabilities suite enforces full coverage. Copy is a draft to
64+
# be reworded by product.
65+
CAPABILITY_DESCRIPTIONS: dict[str, str] = {
66+
"app.kv": "Store and read its own app data",
67+
"app.table": "Store and read its own structured data",
68+
"app.files": "Read and write files in its own app folder",
69+
"app.notify": "Send you notifications",
70+
"app.window": "Open and manage its own windows",
71+
"app.net": "Connect to the internet",
72+
"app.agent": "Ask your taOS agent for help",
73+
"app.llm": "Use a language model",
74+
"app.memory": "Read and write your memories",
75+
}
76+
77+
78+
def describe_capability(perm: str) -> str:
79+
"""A human one-liner for a capability, for the consent card.
80+
81+
Bare caps map through CAPABILITY_DESCRIPTIONS; a `network:<origin>` grant
82+
renders as "Connect to <origin>"; anything unrecognised falls back to the
83+
raw token so the card never shows an empty row.
84+
"""
85+
if isinstance(perm, str) and perm.startswith(NET_PREFIX):
86+
return f"Connect to {perm[len(NET_PREFIX):]}"
87+
return CAPABILITY_DESCRIPTIONS.get(perm, perm)

0 commit comments

Comments
 (0)