Coverage for node / src / stigmem_node / session_graph.py: 83%
64 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
1"""Per-session read/write graph controls for R-21 feedback-loop defense."""
3from __future__ import annotations
5import json
6import uuid
7from datetime import UTC, datetime
8from typing import Any
10from fastapi import HTTPException, status
12from .auth import Identity
14SESSION_HEADER = "Stigmem-Session"
15SUMMARIZE_WITH_PROVENANCE = "summarize_with_provenance"
18def normalize_session_id(session_id: str | None) -> str | None:
19 """Return a bounded session id, or None when the caller did not opt in."""
20 if session_id is None:
21 return None
22 normalized = session_id.strip()
23 if not normalized: 23 ↛ 24line 23 didn't jump to line 24 because the condition on line 23 was never true
24 return None
25 if len(normalized) > 128: 25 ↛ 26line 25 didn't jump to line 26 because the condition on line 25 was never true
26 raise HTTPException(
27 status_code=status.HTTP_400_BAD_REQUEST,
28 detail="session_id_too_long",
29 )
30 return normalized
33def _now_iso() -> str:
34 return datetime.now(UTC).isoformat()
37def record_read_scopes(
38 conn: Any,
39 *,
40 identity: Identity,
41 session_id: str | None,
42 scopes: set[str],
43) -> None:
44 """Record scopes read by a caller in a session."""
45 normalized = normalize_session_id(session_id)
46 if normalized is None:
47 return
48 now = _now_iso()
49 for scope in scopes:
50 conn.execute(
51 """INSERT OR IGNORE INTO session_scope_access
52 (id, session_id, entity_uri, tenant_id, scope, access_type, ts)
53 VALUES (?, ?, ?, ?, ?, 'read', ?)""",
54 (
55 str(uuid.uuid4()),
56 normalized,
57 identity.entity_uri,
58 identity.tenant_id,
59 scope,
60 now,
61 ),
62 )
65def record_write_scope(
66 conn: Any,
67 *,
68 identity: Identity,
69 session_id: str | None,
70 scope: str,
71) -> None:
72 """Record a scope written by a caller in a session."""
73 normalized = normalize_session_id(session_id)
74 if normalized is None:
75 return
76 conn.execute(
77 """INSERT OR IGNORE INTO session_scope_access
78 (id, session_id, entity_uri, tenant_id, scope, access_type, ts)
79 VALUES (?, ?, ?, ?, ?, 'write', ?)""",
80 (
81 str(uuid.uuid4()),
82 normalized,
83 identity.entity_uri,
84 identity.tenant_id,
85 scope,
86 _now_iso(),
87 ),
88 )
91def _read_scopes_for_session(conn: Any, *, identity: Identity, session_id: str) -> set[str]:
92 rows = conn.execute(
93 """SELECT scope FROM session_scope_access
94 WHERE session_id = ?
95 AND entity_uri = ?
96 AND tenant_id = ?
97 AND access_type = 'read'""",
98 (session_id, identity.entity_uri, identity.tenant_id),
99 ).fetchall()
100 return {row["scope"] for row in rows}
103def _provenance_scopes(
104 conn: Any, derived_from: list[dict[str, Any]], tenant_id: str
105) -> set[str]:
106 scopes: set[str] = set()
107 for entry in derived_from:
108 fact_id = entry.get("fact_id")
109 hash_val = entry.get("hash")
110 row = None
111 if fact_id: 111 ↛ 116line 111 didn't jump to line 116 because the condition on line 111 was always true
112 row = conn.execute(
113 "SELECT scope FROM facts WHERE id = ? AND tenant_id = ?",
114 (fact_id, tenant_id),
115 ).fetchone()
116 elif isinstance(hash_val, str) and hash_val.startswith("sha256:"):
117 alias = conn.execute(
118 "SELECT fact_id FROM fact_cid_aliases WHERE cid = ? AND tenant_id = ?",
119 (hash_val, tenant_id),
120 ).fetchone()
121 if alias is not None:
122 row = conn.execute(
123 "SELECT scope FROM facts WHERE id = ? AND tenant_id = ?",
124 (alias["fact_id"], tenant_id),
125 ).fetchone()
126 if row is not None: 126 ↛ 107line 126 didn't jump to line 107 because the condition on line 126 was always true
127 scopes.add(row["scope"])
128 return scopes
131def ensure_write_allowed(
132 conn: Any,
133 *,
134 identity: Identity,
135 session_id: str | None,
136 target_scope: str,
137 write_mode: str,
138 derived_from: list[dict[str, Any]],
139) -> None:
140 """Reject read-then-write same-scope loops unless provenance is carried forward."""
141 normalized = normalize_session_id(session_id)
142 if normalized is None:
143 return
145 read_scopes = _read_scopes_for_session(conn, identity=identity, session_id=normalized)
146 if target_scope not in read_scopes: 146 ↛ 147line 146 didn't jump to line 147 because the condition on line 146 was never true
147 return
149 if write_mode == SUMMARIZE_WITH_PROVENANCE and target_scope in _provenance_scopes(
150 conn, derived_from, identity.tenant_id
151 ):
152 return
154 raise HTTPException(
155 status_code=status.HTTP_403_FORBIDDEN,
156 detail={
157 "code": "feedback_loop_provenance_required",
158 "message": (
159 "writes into scopes read earlier in the same session require "
160 "write_mode='summarize_with_provenance' and source provenance"
161 ),
162 "session_id": normalized,
163 "scope": target_scope,
164 },
165 )
168def encode_derived_from(derived_from: list[dict[str, Any]]) -> str | None:
169 if not derived_from:
170 return None
171 return json.dumps(derived_from, sort_keys=True, separators=(",", ":"))