rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
afe6172
·
1 Parent(s): d547d5a

fix(safety): KI-095 — 5 defensive guards against profile-field clearing

Browse files

Closes 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 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
- setattr(profile, q_obj.field, val)
 
 
 
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 is not None:
627
- setattr(sess.profile, field_name, v)
 
 
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
- prof_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
- prof_dict = {k: v for k, v in prof_dict.items() if k in valid_fields}
 
 
 
 
 
 
 
 
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),