mirror of
https://github.com/samvallad33/vestige.git
synced 2026-04-25 00:36:22 +02:00
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:
parent
2da0a9a5c5
commit
83902b46dd
2 changed files with 111 additions and 3 deletions
|
|
@ -93,13 +93,21 @@
|
|||
// real errors (network down, dashboard disabled, 500s) and looked
|
||||
// identical to a first-run install. Split the two so debugging
|
||||
// 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 =
|
||||
(graphData?.nodeCount ?? 0) === 0 &&
|
||||
/not found|404|empty|no memor/i.test(msg);
|
||||
/not found|404|empty|no memor/i.test(rawMsg);
|
||||
error = isEmpty
|
||||
? 'No memories yet. Start using Vestige to populate your graph.'
|
||||
: `Failed to load graph: ${msg}`;
|
||||
: `Failed to load graph: ${safeMsg}`;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -517,6 +517,7 @@ async fn execute_check(
|
|||
let item = serde_json::json!({
|
||||
"id": intention.id,
|
||||
"description": intention.content,
|
||||
"status": intention.status,
|
||||
"priority": match intention.priority {
|
||||
1 => "low",
|
||||
3 => "high",
|
||||
|
|
@ -525,6 +526,7 @@ async fn execute_check(
|
|||
},
|
||||
"createdAt": intention.created_at.to_rfc3339(),
|
||||
"deadline": intention.deadline.map(|d| d.to_rfc3339()),
|
||||
"snoozedUntil": intention.snoozed_until.map(|d| d.to_rfc3339()),
|
||||
"isOverdue": is_overdue,
|
||||
});
|
||||
|
||||
|
|
@ -1439,4 +1441,102 @@ mod tests {
|
|||
assert!(schema_value["properties"]["filter_status"].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\""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue