Spaces:
Sleeping
Sleeping
Commit ·
afe6172
1
Parent(s): d547d5a
fix(safety): KI-095 — 5 defensive guards against profile-field clearing
Browse filesCloses audit holes found post-KI-094:
1. main.py /api/profile — reject empty-string / empty-list overwrites
2. needs_finder.py record_answer — defer asked.append until setattr succeeds
3. fact_find_brain.py greedy fallback — comment direct-setattr scope reason
4. session_state.py disk rehydrate — log dropped schema-drift keys
5. main.py /reset — require explicit confirm=true to wipe session
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/fact_find_brain.py +4 -1
- backend/main.py +9 -2
- backend/needs_finder.py +3 -1
- backend/session_state.py +10 -2
backend/fact_find_brain.py
CHANGED
|
@@ -749,7 +749,10 @@ def _canonical_fallback(session, user_text: str, *, reason: str) -> FactFindOutc
|
|
| 749 |
if val is None:
|
| 750 |
continue
|
| 751 |
captured[q_obj.field] = val
|
| 752 |
-
|
|
|
|
|
|
|
|
|
|
| 753 |
if slot_id not in profile.asked:
|
| 754 |
profile.asked.append(slot_id)
|
| 755 |
except Exception as e:
|
|
|
|
| 749 |
if val is None:
|
| 750 |
continue
|
| 751 |
captured[q_obj.field] = val
|
| 752 |
+
# KI-095 — route through session.update_profile_field for
|
| 753 |
+
# consistency with the rest of the codebase (centralised
|
| 754 |
+
# write + flush); val is guarded non-None just above.
|
| 755 |
+
session.update_profile_field(q_obj.field, val)
|
| 756 |
if slot_id not in profile.asked:
|
| 757 |
profile.asked.append(slot_id)
|
| 758 |
except Exception as e:
|
backend/main.py
CHANGED
|
@@ -569,6 +569,7 @@ class ProfileUpdateRequest(BaseModel):
|
|
| 569 |
class SessionResetRequest(BaseModel):
|
| 570 |
session_id: str
|
| 571 |
drop_profile: bool = False # True = nuke session entirely; False = clear chat only
|
|
|
|
| 572 |
|
| 573 |
|
| 574 |
class SessionResetResponse(BaseModel):
|
|
@@ -593,6 +594,10 @@ async def session_reset(req: SessionResetRequest):
|
|
| 593 |
cleared = False
|
| 594 |
new_sid: Optional[str] = None
|
| 595 |
if req.drop_profile:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 596 |
cleared = reset_session(req.session_id)
|
| 597 |
new_sid = uuid.uuid4().hex[:12]
|
| 598 |
return SessionResetResponse(ok=True, session_id=new_sid, cleared_state=cleared)
|
|
@@ -623,8 +628,10 @@ async def profile_update(req: ProfileUpdateRequest):
|
|
| 623 |
"health_conditions", "budget_band",
|
| 624 |
):
|
| 625 |
v = getattr(req, field_name, None)
|
| 626 |
-
if v
|
| 627 |
-
|
|
|
|
|
|
|
| 628 |
|
| 629 |
# KI-077 — if name is set, also persist to the named-profile store so a
|
| 630 |
# returning visitor's profile is recoverable across sessions.
|
|
|
|
| 569 |
class SessionResetRequest(BaseModel):
|
| 570 |
session_id: str
|
| 571 |
drop_profile: bool = False # True = nuke session entirely; False = clear chat only
|
| 572 |
+
confirm: bool = False # KI-095 — must be True when drop_profile=True; guards accidental wipes
|
| 573 |
|
| 574 |
|
| 575 |
class SessionResetResponse(BaseModel):
|
|
|
|
| 594 |
cleared = False
|
| 595 |
new_sid: Optional[str] = None
|
| 596 |
if req.drop_profile:
|
| 597 |
+
# KI-095 — require explicit confirm=True so a misclick or replayed
|
| 598 |
+
# request cannot wipe a populated session by accident.
|
| 599 |
+
if not req.confirm:
|
| 600 |
+
raise HTTPException(status_code=400, detail="confirm=true required to drop session")
|
| 601 |
cleared = reset_session(req.session_id)
|
| 602 |
new_sid = uuid.uuid4().hex[:12]
|
| 603 |
return SessionResetResponse(ok=True, session_id=new_sid, cleared_state=cleared)
|
|
|
|
| 628 |
"health_conditions", "budget_band",
|
| 629 |
):
|
| 630 |
v = getattr(req, field_name, None)
|
| 631 |
+
if v in (None, "", []):
|
| 632 |
+
# KI-095 — never clobber a filled field with empty input from the client
|
| 633 |
+
continue
|
| 634 |
+
setattr(sess.profile, field_name, v)
|
| 635 |
|
| 636 |
# KI-077 — if name is set, also persist to the named-profile store so a
|
| 637 |
# returning visitor's profile is recoverable across sessions.
|
backend/needs_finder.py
CHANGED
|
@@ -205,7 +205,6 @@ def record_answer(profile: Profile, question_id: str, raw_answer: str) -> Profil
|
|
| 205 |
q = next((x for x in GRAPH if x.id == question_id), None)
|
| 206 |
if q is None:
|
| 207 |
return profile
|
| 208 |
-
profile.asked.append(question_id)
|
| 209 |
value: Any = raw_answer
|
| 210 |
if q.parser:
|
| 211 |
try:
|
|
@@ -214,6 +213,9 @@ def record_answer(profile: Profile, question_id: str, raw_answer: str) -> Profil
|
|
| 214 |
value = None
|
| 215 |
if value is not None and value != "":
|
| 216 |
setattr(profile, q.field, value)
|
|
|
|
|
|
|
|
|
|
| 217 |
return profile
|
| 218 |
|
| 219 |
|
|
|
|
| 205 |
q = next((x for x in GRAPH if x.id == question_id), None)
|
| 206 |
if q is None:
|
| 207 |
return profile
|
|
|
|
| 208 |
value: Any = raw_answer
|
| 209 |
if q.parser:
|
| 210 |
try:
|
|
|
|
| 213 |
value = None
|
| 214 |
if value is not None and value != "":
|
| 215 |
setattr(profile, q.field, value)
|
| 216 |
+
# KI-095 — only mark slot asked once setattr succeeds, so a parse
|
| 217 |
+
# failure doesn't leave the slot in an asked-but-empty desync state.
|
| 218 |
+
profile.asked.append(question_id)
|
| 219 |
return profile
|
| 220 |
|
| 221 |
|
backend/session_state.py
CHANGED
|
@@ -104,12 +104,20 @@ def _load_from_disk(session_id: str) -> Optional[SessionState]:
|
|
| 104 |
return None
|
| 105 |
try:
|
| 106 |
raw = json.loads(target.read_text())
|
| 107 |
-
|
| 108 |
# Profile may have new fields added since this file was written —
|
| 109 |
# filter to only what the current Profile dataclass accepts so we
|
| 110 |
# never crash on schema drift.
|
| 111 |
valid_fields = {f for f in Profile.__dataclass_fields__.keys()}
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
return SessionState(
|
| 114 |
session_id=raw["session_id"],
|
| 115 |
profile=Profile(**prof_dict),
|
|
|
|
| 104 |
return None
|
| 105 |
try:
|
| 106 |
raw = json.loads(target.read_text())
|
| 107 |
+
raw_profile_dict = raw.get("profile", {}) or {}
|
| 108 |
# Profile may have new fields added since this file was written —
|
| 109 |
# filter to only what the current Profile dataclass accepts so we
|
| 110 |
# never crash on schema drift.
|
| 111 |
valid_fields = {f for f in Profile.__dataclass_fields__.keys()}
|
| 112 |
+
dropped = set(raw_profile_dict.keys()) - valid_fields
|
| 113 |
+
if dropped:
|
| 114 |
+
# KI-095 — log schema-drift drops so silent data loss is visible.
|
| 115 |
+
import logging
|
| 116 |
+
logging.warning(
|
| 117 |
+
"session_state load_from_disk dropped %d unknown profile keys for %s: %s",
|
| 118 |
+
len(dropped), session_id, sorted(dropped),
|
| 119 |
+
)
|
| 120 |
+
prof_dict = {k: v for k, v in raw_profile_dict.items() if k in valid_fields}
|
| 121 |
return SessionState(
|
| 122 |
session_id=raw["session_id"],
|
| 123 |
profile=Profile(**prof_dict),
|