Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8b92139
Add Path Explorer: bounded walks from chosen nodes
JetSquirrel Nov 2, 2026
be2b0fc
Add evidence records: what supports a node, relationship or path
JetSquirrel Nov 2, 2026
de5c394
Merge pull request #45 from JetSquirrel/feat/evidence-inspector
JetSquirrel Nov 2, 2026
24d022a
Add explainable AWS exposure rules
JetSquirrel Nov 2, 2026
c29ee42
Show exposure findings in the graph inspector
JetSquirrel Nov 2, 2026
331dab5
Save investigations to a file and flag changes on reopening
JetSquirrel Nov 2, 2026
7e486f6
Keep stale nodes from surviving a sync in the same second
JetSquirrel Nov 2, 2026
cdf9b25
Save and reopen investigations in the window
JetSquirrel Nov 2, 2026
e590912
Record how complete each sync was, and remove only after a complete one
JetSquirrel Nov 2, 2026
32c1c56
Add snapshots and a diff that only confirms what it can
JetSquirrel Nov 2, 2026
1137df9
Move the AWS exposure rules into the AWS plugin
JetSquirrel Nov 3, 2026
5264975
Merge feat/exposure-rules into feat/exposure-window
JetSquirrel Nov 3, 2026
a280b8e
Show plugin analyses in the inspector, in the plugin's own words
JetSquirrel Nov 3, 2026
3ec9bae
Merge branch 'feat/exposure-window' into feat/saved-investigations
JetSquirrel Nov 3, 2026
d898364
Merge feat/saved-investigations into feat/investigation-window
JetSquirrel Nov 3, 2026
8f61b3d
Keep a finding's exact path when saving it as an investigation
JetSquirrel Nov 3, 2026
3f7eea3
Test a removed record with a real re-sync
JetSquirrel Nov 3, 2026
db32115
Merge feat/investigation-window, and carry sync coverage across the b…
JetSquirrel Nov 3, 2026
c3fd5dd
Merge branch 'feat/sync-coverage' into feat/snapshots-diff
JetSquirrel Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use crate::ui::status_bar::StatusBarView;
use crate::ui::title_bar::TitleBarView;
use crate::ui::workspace::Workspace;
use crate::ui::{
ActivateTab, AddSource, CloseTab, NewGraph, NewQuery, NewSqlQuery, NextTab, OpenDashboard, OpenGraph, PreviousTab,
ActivateTab, AddSource, CloseTab, NewGraph, NewQuery, NewSqlQuery, NextTab, OpenDashboard, OpenGraph, OpenInvestigation, PreviousTab,
ToggleSidebar,
};

Expand Down Expand Up @@ -136,8 +136,11 @@ impl KhonsuApp {
} else if crate::spec::tabs::is_spec(&path) {
self.workspace.update(cx, |ws, cx| ws.open_dashboard(path.clone(), window, cx));
opened.push(json!({"dashboard": path.display().to_string()}));
} else if path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case(khonsu_core::investigation::EXTENSION)) {
crate::ui::reopen_investigation(self.state.clone(), self.workspace.clone(), &path, window, cx);
opened.push(json!({"investigation": path.display().to_string()}));
} else {
return json!({"error": {"kind": "not_openable", "message": format!("{} is neither a graph nor a .dash file", path.display())}});
return json!({"error": {"kind": "not_openable", "message": format!("{} is not a graph, a .dash file or an investigation", path.display())}});
}
}
if let Some(text) = request.text {
Expand Down Expand Up @@ -193,6 +196,9 @@ impl Render for KhonsuApp {
.on_action(cx.listener(|this, _: &OpenDashboard, window, cx| {
this.workspace.update(cx, |ws, cx| ws.pick_dashboard_file(window, cx));
}))
.on_action(cx.listener(|this, _: &OpenInvestigation, window, cx| {
crate::ui::pick_investigation(this.state.clone(), this.workspace.clone(), window, cx);
}))
.on_action(cx.listener(|this, _: &NewGraph, window, cx| {
crate::ui::pick_graph(this.state.clone(), true, window, cx);
}))
Expand Down
38 changes: 38 additions & 0 deletions app/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@
//! blocks on the engine; UI code reaches it through `smol::unblock`.

use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::{LazyLock, Mutex, PoisonError};

use khonsu_core::explore::{Exploration, ExploreRequest};
use khonsu_core::investigation::{Capture, Investigation, Reopened};
use khonsu_core::plugin::PluginError;
use khonsu_core::plugin_analysis::{self, Offered, Report};
use khonsu_core::{AccessMode, Graph, GraphData, GraphSchema, Language, OpenError, QueryError, QueryOptions, QueryResult};
use serde_json::Map;

Expand Down Expand Up @@ -91,6 +96,39 @@ pub fn neighbors(id: i64) -> Result<GraphData, QueryError> {
with_graph(|g| g.neighbors(id, 200))
}

/// Holds the graph for the whole walk, as any call here does; `cancel` stops
/// it between queries.
pub fn explore(request: &ExploreRequest, cancel: &AtomicBool) -> Result<Exploration, QueryError> {
with_graph(|g| g.explore(request, cancel))
}

/// Records `capture` against the open graph, which holds it while it reads.
pub fn capture_investigation(capture: Capture) -> Result<Investigation, QueryError> {
let sources = khonsu_core::settings::load_sources();
with_graph(|g| {
let path = g.path().to_path_buf();
g.capture_investigation(&path, capture, &sources, &AtomicBool::new(false))
})
}

/// Runs a saved investigation again on the open graph.
pub fn reopen_investigation(saved: &Investigation) -> Result<Reopened, QueryError> {
let sources = khonsu_core::settings::load_sources();
with_graph(|g| g.reopen_investigation(saved, &sources, &AtomicBool::new(false)))
}

/// Runs an analysis a plugin offers. It holds the graph only while reading, not
/// while the plugin works. `Ok(None)` when `cancel` stopped it.
pub fn run_analysis(offered: &Offered, cancel: &AtomicBool) -> Result<Option<Report>, String> {
let sources = khonsu_core::settings::load_sources();
let input = with_graph(|g| g.read_analysis_input(&offered.info)).map_err(|error| error.message)?;
match plugin_analysis::run(offered, &input, &sources, cancel, |_| {}) {
Ok(report) => Ok(Some(report)),
Err(PluginError::Cancelled) => Ok(None),
Err(error) => Err(error.to_string()),
}
}

pub fn nodes_by_id(ids: &[i64]) -> Result<GraphData, QueryError> {
with_graph(|g| g.nodes_by_id(ids))
}
Expand Down
72 changes: 72 additions & 0 deletions app/src/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,77 @@ static STRINGS: &[(&str, &str, &str)] = &[
("canvas.compact_hint", "滚轮缩放,悬停查看名称", "Scroll to zoom; hover to see names"),
("canvas.truncated", "显示 {} / {} 个节点", "Showing {} of {} nodes"),
("canvas.limit", "画布最多显示 {} 个节点,没有展开", "The canvas holds at most {} nodes; not expanded"),
("evidence.title", "证据", "Evidence"),
("evidence.id", "证据 ID", "Evidence ID"),
("evidence.source", "数据源", "Source"),
("evidence.plugin", "插件", "Plugin"),
("evidence.written", "记录最后写入", "Record last written"),
("evidence.last_sync", "数据源最后成功同步", "Source last synced"),
("evidence.snapshot", "数据源快照", "Source snapshot"),
("evidence.coverage", "最近一次同步覆盖范围", "Last sync's coverage"),
("evidence.coverage.complete", "完整", "Complete"),
("evidence.coverage.partial", "部分", "Partial"),
("evidence.coverage.failed", "失败", "Failed"),
("evidence.coverage.cancelled", "已取消", "Cancelled"),
("evidence.assertion", "断言", "Assertion"),
("evidence.explicit", "数据源报告", "Reported by the source"),
("evidence.derived", "由其他记录推导", "Derived from other records"),
("evidence.unknown", "未提供", "Not supplied"),
("evidence.not_stable", "内部 ID,重建图后可能改变", "An internal ID, which a rebuild can change"),
("evidence.redacted", "已遮盖疑似机密:{}", "Masked as possible secrets: {}"),
("evidence.copy", "复制证据 JSON", "Copy evidence as JSON"),
("evidence.copy_path", "复制路径证据 JSON", "Copy path evidence as JSON"),
("investigation.open", "打开调查…", "Open investigation…"),
("investigation.save", "保存调查…", "Save investigation…"),
("investigation.saved", "已保存调查:{}", "Investigation saved: {}"),
("investigation.save_failed", "无法保存调查:{}", "The investigation could not be saved: {}"),
("investigation.open_failed", "无法打开调查:{}", "The investigation could not be opened: {}"),
("investigation.graph_missing", "这个调查的图不在 {}", "This investigation's graph is not at {}"),
("investigation.reopened", "已重新打开调查“{}”,保存于 {}", "Reopened investigation \"{}\", saved {}"),
("investigation.changed", "图在保存后发生了变化,结果可能与保存时不同", "The graph has changed since this was saved, so the answer may differ"),
("investigation.not_compared", "图太大,无法比较内容,只确认了记录数相同", "The graph is too large to compare, so only its record counts were checked"),
("investigation.missing_seeds", "保存的起点已不在图中:{}", "Saved starting points are no longer in the graph: {}"),
("investigation.target_missing", "保存的路径终点已不在图中", "The saved path's end is no longer in the graph"),
("investigation.path_differs", "路径不再基于保存时的记录", "The path no longer rests on the saved records"),
("investigation.unsure", "这些记录靠内部 ID 找到,图已变化,可能已是别的记录:{}", "Found by internal ID on a changed graph, so these may now be other records: {}"),
("analysis.check", "运行分析", "Run analysis"),
("analysis.recheck", "重新检查", "Check again"),
("analysis.stop", "停止", "Stop"),
("analysis.failed", "检查失败:{}", "The check failed: {}"),
("analysis.incomplete", "图太大未能读完,或有数据源最近一次同步不完整,所以缺少发现不说明任何问题。", "The graph was too large to read whole, or a source's last sync was not complete, so a missing finding means nothing."),
("analysis.none", "没有关于此节点的发现。如果刚同步过,请重新检查。", "No findings about this one. If you just synced, check again."),
("analysis.status.met", "满足", "Met"),
("analysis.status.not_met", "不满足", "Not met"),
("analysis.status.unknown", "未知", "Unknown"),
("analysis.assumptions", "未检查", "Not checked"),
("analysis.show_path", "在画布上显示路径", "Show path on canvas"),
("analysis.copy", "复制发现 JSON", "Copy finding as JSON"),
("explore.title", "路径探索", "Path Explorer"),
("explore.out", "沿箭头", "Outbound"),
("explore.in", "逆箭头", "Inbound"),
("explore.both", "双向", "Both"),
("explore.hops", "最多 {} 跳", "Up to {} hops"),
("explore.fewer_hops", "减少跳数", "Fewer hops"),
("explore.more_hops", "增加跳数", "More hops"),
("explore.types_all", "全部关系类型", "All relationship types"),
("explore.types_some", "关系类型:{} 种", "Relationship types: {}"),
("explore.add_start", "加为起点", "Add as a start"),
("explore.remove_start", "移出起点", "Remove from starts"),
("explore.starts", "起点:{} 个", "Starts: {}"),
("explore.run", "探索", "Explore"),
("explore.cancel", "取消", "Cancel"),
("explore.back", "返回上一步", "Back"),
("explore.show_path", "只看这条路径", "Show only this path"),
("explore.path", "到此节点的路径", "Path to this node"),
("explore.found", "节点:{},关系:{},深度:{}", "Nodes: {}, relationships: {}, depth: {}"),
("explore.none", "没有符合筛选条件的关系", "No relationships match these filters"),
("explore.limit.hops", "已到跳数上限,可能还有更远的节点", "Stopped at the hop limit; there is more beyond it"),
("explore.limit.nodes", "已到节点上限,结果不完整", "Stopped at the node limit; this result is incomplete"),
("explore.limit.edges", "已到关系上限,结果不完整", "Stopped at the relationship limit; this result is incomplete"),
("explore.cancelled", "已取消,结果不完整", "Cancelled; this result is incomplete"),
("explore.missing_start", "有起点已不在图中", "A start is no longer in the graph"),
("explore.failed", "探索失败:{}", "Exploring failed: {}"),
("explore.candidate", "路径来自配置数据,不代表实际可达", "A path in the data, not proof that it can be reached"),

// ── status bar ──────────────────────────────────────────────────────
("status_bar.opening", "正在打开…", "Opening…"),
Expand Down Expand Up @@ -378,6 +449,7 @@ static STRINGS: &[(&str, &str, &str)] = &[
("sources.read_only", "图以只读方式打开,不能同步", "The graph is open read-only; it can't be synced"),
("sources.last_ok", "{}同步,{} 个节点,{} 条关系", "Synced {}, {} nodes, {} relationships"),
("sources.last_failed", "{}同步失败:{}", "Sync failed {}: {}"),
("sources.last_partial", "{} 同步不完整,未删除任何记录:{}", "{} incomplete, nothing removed: {}"),
("sources.never", "从未同步", "Never synced"),
("sources.remove", "移除", "Remove"),
("sources.remove_title", "移除数据源 {}?", "Remove source {}?"),
Expand Down
2 changes: 2 additions & 0 deletions app/src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ pub fn agent_prompt() -> String {
`{command} schema --db <graph>`.\n\
- HawDB's Cypher has no labels(), and path variables only with shortestPath; \
return whole nodes and relationships to get a graph back.\n\
- To follow a suspected chain within caps: `{command} explore --db <graph> --from <node> --to <node>`; \
check `complete` before treating a missing path as an answer.\n\
- Validate a dashboard with `{command} check <file.dash> --db <graph>`; \
answer my comments on it with `{command} comments <file.dash>`.\n\
- Reads are the default. Answer from the values it returns, check `truncated`, \
Expand Down
Loading
Loading