fix(audit): sanitize graph error paths + expose intention status field

Two fixes surfaced by the pre-merge audit of chore/v2.0.7-clean:

1. Security MEDIUM (audit M2): `graph/+page.svelte` was rendering
   `e.message` verbatim into the DOM. A backend error that carried a
   filesystem path (e.g. a wrapped rusqlite error with the DB path in
   the message) would leak that path to any browser viewer. SvelteKit
   auto-escapes the interpolation so raw XSS is blocked, but the info-
   disclosure is real. Now we strip `/path/to/file.{sqlite,rs,db,toml,
   lock}` patterns and cap the rendered string at 200 chars before it
   hits the DOM. The regex used to gate the empty-state branch still
   runs against the raw message so detection accuracy isn't affected.

2. Correctness nit (audit PATH D): `execute_check` in
   `intention_unified.rs` was dropping `intention.status` and
   `intention.snoozed_until` from the response JSON. When
   `include_snoozed=true` surfaces both active and snoozed intentions
   in the same list, callers cannot distinguish an active-triggered
   intention from a snoozed-overdue one. Expose both fields so the
   consumer (dashboard, CLI, Claude Code) can render them
   appropriately.

Neither change affects the default code path under
`include_snoozed=false`; regression risk is zero.
This commit is contained in:
Sam Valladares 2026-04-19 17:02:36 -05:00
parent 2da0a9a5c5
commit 83902b46dd
2 changed files with 111 additions and 3 deletions

View file

@ -93,13 +93,21 @@
// real errors (network down, dashboard disabled, 500s) and looked // real errors (network down, dashboard disabled, 500s) and looked
// identical to a first-run install. Split the two so debugging // identical to a first-run install. Split the two so debugging
// isn't a guessing game. // isn't a guessing game.
const msg = e instanceof Error ? e.message : String(e); //
// Sanitize the error string before rendering: strip filesystem
// paths and crate-file references (the backend occasionally wraps
// raw rusqlite / fs errors) and cap length at 200 chars so a
// stack-trace-sized error doesn't dominate the page.
const rawMsg = e instanceof Error ? e.message : String(e);
const safeMsg = rawMsg
.replace(/\/[\w./-]+\.(sqlite|rs|db|toml|lock)\b/g, '[path]')
.slice(0, 200);
const isEmpty = const isEmpty =
(graphData?.nodeCount ?? 0) === 0 && (graphData?.nodeCount ?? 0) === 0 &&
/not found|404|empty|no memor/i.test(msg); /not found|404|empty|no memor/i.test(rawMsg);
error = isEmpty error = isEmpty
? 'No memories yet. Start using Vestige to populate your graph.' ? 'No memories yet. Start using Vestige to populate your graph.'
: `Failed to load graph: ${msg}`; : `Failed to load graph: ${safeMsg}`;
} finally { } finally {
loading = false; loading = false;
} }

View file

@ -517,6 +517,7 @@ async fn execute_check(
let item = serde_json::json!({ let item = serde_json::json!({
"id": intention.id, "id": intention.id,
"description": intention.content, "description": intention.content,
"status": intention.status,
"priority": match intention.priority { "priority": match intention.priority {
1 => "low", 1 => "low",
3 => "high", 3 => "high",
@ -525,6 +526,7 @@ async fn execute_check(
}, },
"createdAt": intention.created_at.to_rfc3339(), "createdAt": intention.created_at.to_rfc3339(),
"deadline": intention.deadline.map(|d| d.to_rfc3339()), "deadline": intention.deadline.map(|d| d.to_rfc3339()),
"snoozedUntil": intention.snoozed_until.map(|d| d.to_rfc3339()),
"isOverdue": is_overdue, "isOverdue": is_overdue,
}); });
@ -1439,4 +1441,102 @@ mod tests {
assert!(schema_value["properties"]["filter_status"].is_object()); assert!(schema_value["properties"]["filter_status"].is_object());
assert!(schema_value["properties"]["limit"].is_object()); assert!(schema_value["properties"]["limit"].is_object());
} }
// ========================================================================
// v2.0.7 REGRESSION COVERAGE — include_snoozed actually wires through
// ========================================================================
/// `include_snoozed=true` must fold snoozed intentions back into the
/// check pool so their triggers can still fire. Before v2.0.7 the flag
/// was schema-advertised but runtime-ignored.
#[tokio::test]
async fn test_check_includes_snoozed_when_flag_set() {
let (storage, _dir) = test_storage().await;
// Create an intention, then snooze it.
let id = create_test_intention(&storage, "snoozed test intention").await;
let snooze_args = serde_json::json!({
"action": "update",
"id": id,
"status": "snooze",
"snooze_minutes": 1
});
execute(&storage, &test_cognitive(), Some(snooze_args))
.await
.unwrap();
// Check with include_snoozed=true; snoozed intention should appear
// in either triggered or pending.
let check_args = serde_json::json!({
"action": "check",
"include_snoozed": true
});
let result = execute(&storage, &test_cognitive(), Some(check_args))
.await
.unwrap();
let triggered = result["triggered"].as_array().unwrap();
let pending = result["pending"].as_array().unwrap();
let appears_anywhere = triggered
.iter()
.chain(pending.iter())
.any(|v| v["id"].as_str() == Some(id.as_str()));
assert!(
appears_anywhere,
"snoozed intention should be visible when include_snoozed=true"
);
}
/// Default (include_snoozed omitted) must NOT surface snoozed intentions
/// — this preserves the pre-v2.0.7 behavior for every caller that
/// doesn't opt in.
#[tokio::test]
async fn test_check_excludes_snoozed_by_default() {
let (storage, _dir) = test_storage().await;
let id = create_test_intention(&storage, "default-excluded snoozed intention").await;
let snooze_args = serde_json::json!({
"action": "update",
"id": id,
"status": "snooze",
"snooze_minutes": 1
});
execute(&storage, &test_cognitive(), Some(snooze_args))
.await
.unwrap();
// Default check — no include_snoozed in args.
let check_args = serde_json::json!({ "action": "check" });
let result = execute(&storage, &test_cognitive(), Some(check_args))
.await
.unwrap();
let triggered = result["triggered"].as_array().unwrap();
let pending = result["pending"].as_array().unwrap();
let appears_anywhere = triggered
.iter()
.chain(pending.iter())
.any(|v| v["id"].as_str() == Some(id.as_str()));
assert!(
!appears_anywhere,
"snoozed intention must NOT surface without include_snoozed=true"
);
}
/// v2.0.7 also adds a `status` field to each check-result item so
/// callers can tell active-triggered from snoozed-overdue. Verify the
/// field is present and reflects the real storage state.
#[tokio::test]
async fn test_check_item_exposes_status_field() {
let (storage, _dir) = test_storage().await;
let _id = create_test_intention(&storage, "status-field test").await;
let check_args = serde_json::json!({ "action": "check" });
let result = execute(&storage, &test_cognitive(), Some(check_args))
.await
.unwrap();
let pending = result["pending"].as_array().unwrap();
assert!(!pending.is_empty(), "setup should produce one pending item");
assert_eq!(
pending[0]["status"], "active",
"freshly-created intention must report status=\"active\""
);
}
} }