Skip to content

Save investigations to a file and flag changes on reopening - #49

Merged
JetSquirrel merged 11 commits into
mainfrom
feat/saved-investigations
Nov 3, 2026
Merged

JetSquirrel merged 11 commits into
mainfrom
feat/saved-investigations

Conversation

@JetSquirrel

@JetSquirrel JetSquirrel commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

Adds khonsu investigation save and reopen, so a line of inquiry can be kept and picked up later, with any change in the data flagged. The window doesn't save or reopen these files yet, so #34 stays open.

Merge order: #47, #48, then this. This branch is built on both, so its diff shows their work until they land. It targets main. Please merge these with merge commits, not squash or rebase, or each later branch stops containing the one before it (#55).

Updated for #55: nothing here needed changing, since a saved investigation doesn't use the rules. Tests now call khonsu plugin analyze aws exposure.

What it does

save writes a walk's starts and filters, a chosen path, a query and a dashboard to a versioned JSON file. It writes nothing to the graph, and it refuses to replace an existing file without --force.

reopen runs the walk again and reports what differs:

Field Says
graph_state unchanged, changed, or not_compared for a graph too large to hash
differences What changed, in words
path_same Whether the path still rests on the saved records
missing_seeds, target_missing Saved records that have gone. Nothing stands in for them
unsure Records found by an internal ID on a changed graph, which may now be something else

docs/investigations.md has the details.

Also fixes a sync bug

The first CI run on this PR failed and exposed a bug that was already in merge. A full sync removes nodes stamped before its own start, in whole seconds. Two syncs in the same second shared a stamp, so a node gone from the source stayed in the graph while its relationships were removed, with no warning. A real account is unlikely to sync that fast, but a saved-response or CSV source can.

Each sync's stamp is now later than any its source has written, so record_written_at can run a second or so ahead of the clock after quick repeat syncs. An older test slept to avoid the bug, and I removed those sleeps. That test is #[ignore]d by default, so the new test in tests/merge.rs is what guards this in CI.

Choices to review

  • Change detection is a whole-graph fingerprint. [v0.4] Source-consistent snapshots and graph diff #37's source snapshots don't exist yet, so this hashes every record, leaving out internal IDs, sync times and plugin versions. A no-op re-sync stays unchanged and a rebuilt graph matches. The trade-off is that an unrelated change also reads as changed, and each save or reopen reads the whole graph.
  • Large graphs aren't hashed. Above 200,000 nodes or relationships, only counts are compared. Equal counts give not_compared, never unchanged.
  • A saved query is returned, not run. Reopening shouldn't start something expensive on its own.
  • Files can hold personal details. The graph's path can include a user name, and queries are saved as typed. Source configuration and credentials are never written. The docs say to check before sharing a file.

Testing

  • cargo test --locked --workspace passes on Windows, and CI will cover macOS. The golden file now holds the fixture's fingerprint, so CI also checks that the hash matches across platforms.
  • End-to-end tests cover:
    • reopening on unchanged data, a no-op re-sync and a rebuilt graph
    • a changed value, a path that has gone, and a seed and target that have gone
    • a missing graph, and files with bad JSON, the wrong format, missing fields or a newer version
  • The HawDB snapshot test now also checks the fingerprint, so an upgrade can't quietly make every saved file read as changed.
  • Not tested: a graph over 200,000 records end to end. Only the comparison logic for that case has a test.

Issue status

For #34:

  • Done on the CLI: restoring the path and filters, flagging source changes, versioned files, and tests for invalid metadata and a missing graph.
  • Still to do: saving and reopening in the window, which "reopen after app restart" needs, along with restoring a dashboard or view state.

Refs #34

🤖 Generated with Claude Code

JetSquirrel and others added 7 commits November 2, 2026 15:25
The canvas could only add a node's neighbors, one hop and untyped, with no
sign when the fixed limit cut the result. An investigator following a
suspected chain needs direction, types and caps, and needs to know when
the answer is incomplete.

Graph::explore walks breadth-first with one parameterized query per step,
so caps and cancellation are enforced between queries, and says which cap
cut the result. khonsu explore offers it to agents and scripts, and the
inspector gets a panel with the hops to the selected node.

gpui-graph cannot highlight a set of nodes, so showing a path narrows the
canvas to it, and Back returns to earlier views.

Refs #32

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A relationship on the canvas said nothing about where it came from. An
investigator needs the source, the time and whether it was reported or
worked out, and needs missing values stated rather than filled in.

Each provenance field is explicitly known or unknown. khonsu evidence and
explore --to give stable IDs, and the inspector shows the same record with
buttons to copy it as JSON. Exports hold no source configuration, and
secret-looking names and values are masked and listed.

A sync does not rewrite an unchanged relationship, so its timestamp is
named record_written_at, separate from the source's last successful sync.

Refs #33

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add evidence records: what supports a node, relationship or path
An open security group rule or a walkable path isn't an actionable finding.
An investigator needs to know why a resource was flagged, which fields say
so, and what was never checked.

khonsu exposure evaluates three documented rules. Each condition is met,
not met or unknown and names the fields it read. The verdict follows from
those alone: potential_exposure, insufficient_data or not_indicated. There
is no score, and no verdict says reachable.

The AWS plugin now reads route tables and IPv6 addresses, because a public
address and an open rule mean little without a route. A record that should
exist and doesn't gives unknown, not a clear result, since a missing saved
response reads as empty (#38).

A finding carries its path as a derived evidence record with its rule ID,
plus the arguments to reopen it in khonsu explore.

The new cases live in their own fixture, so the shop fixture's answers and
the HawDB snapshot stay as they were.

Refs #35, #33, #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rules could only be read as JSON, so someone investigating in the window
had to leave it to learn why an instance was flagged.

Selecting an instance now offers a check. Each finding shows its verdict,
rule, conditions with their AWS fields, candidate path and what wasn't
checked. Showing a path narrows the canvas to it as a walk, so Back, Path
Explorer and each record's evidence carry on working from there.

Refs #35

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A line of inquiry was lost when the window closed, and nothing would have
said whether the data underneath it had moved on.

khonsu investigation save writes a walk's starts and filters, a chosen
path, a query and a dashboard to a versioned file of its own, by stable IDs
and with no source configuration. The graph isn't touched.

khonsu investigation reopen runs the walk again and compares a fingerprint
of the graph that leaves out internal IDs, sync times and plugin versions,
so a re-sync that changes nothing stays unchanged and a rebuilt graph
matches. It names records that have gone and doesn't substitute others.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A full sync removes the nodes stamped before its own start, in whole
seconds. Two syncs in one second shared a stamp, so a node gone from the
source stayed in the graph while its relationships were removed, with
nothing to say so.

Each sync's stamp is now later than any its source has written. A stamp can
run a second or so ahead of the clock after quick repeat syncs.

An older test slept to avoid this, and no longer needs to.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JetSquirrel and others added 4 commits November 3, 2026 20:50
KhonsuGraph is a general graph workbench, but its core held AWS semantics:
EC2, security group, load balancer and route table rules, and a general
`khonsu exposure` command for them. Every new provider would have added more.

Plugins can now offer analyses, an optional part of the protocol. The host
reads a bounded part of the graph and sends it with stable references to each
record. The plugin answers with findings that point at those records. The host
checks every reference, looks up field values itself, and rebuilds each path
from the graph, so a plugin can't misquote a record or claim a connection that
isn't there. A bad answer is refused whole, since dropping a finding would read
as that resource having none. Nothing in the host names a provider's labels,
rules or verdicts.

The three rules, their field mappings, assumptions and verdict policy are now in
plugins/aws/src/exposure.rs, run with `khonsu plugin analyze aws exposure`.
Finding IDs, rule IDs, verdicts, order and condition text are unchanged. In the
output, `aws_field` is now `source_field`, `instances` is now `subjects`, and a
relationship's end reads as the field `@to`.

Three false readings found in review are fixed, with regression tests:

- Missing balancer group data read as "the target's rules don't allow it",
  clearing the finding. It's now unknown.
- A TCP_UDP target group was treated as TCP only, so a UDP rule didn't count.
- The target's security group membership wasn't in the rule's evidence.

An example analyzer with nothing to do with AWS shows the interface, and tests
how the host handles an answer that misbehaves.

Refs #55, #35

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The inspector called the exposure evaluator in core, matched AWS rule constants
to find its titles, and only appeared on nodes labelled Instance.

It now asks plugins what they offer and shows each analysis on nodes with a
label it declares. Titles, verdicts, rules and condition text all come from the
plugin, including their Chinese, and a verdict's tone sets how it is shown. The
graph is held only while its records are read, so a plugin that takes a while
doesn't block the window, and Stop ends one. A plugin not approved as it is now
isn't started just to ask what it offers.

"Show path on canvas" draws what the host rebuilt from the plugin's references,
which also replaces the fields that were skipped in JSON. A test checks that the
command and the library the window calls give the same findings, and another
that the window's code names no provider's labels or rules.

Refs #55, #35

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	app/tests/investigation.rs
#	crates/khonsu-cli/src/lib.rs
#	crates/khonsu-core/src/lib.rs
#	docs/investigation-gates.md
@JetSquirrel
JetSquirrel merged commit 3cddabb into main Nov 3, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant