Skip to content

Harden plugin installs, approvals and output, and say what isn't enforced - #53

Merged
JetSquirrel merged 21 commits into
mainfrom
feat/plugin-hardening
Nov 3, 2026
Merged

JetSquirrel merged 21 commits into
mainfrom
feat/plugin-hardening

Conversation

@JetSquirrel

@JetSquirrel JetSquirrel commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

Closes real gaps in how plugins are installed, approved and run, and makes the app honest about what it enforces. Plugins are still not sandboxed, and this doesn't change that. docs/plugin-security.md has the threat model, including what this doesn't protect against.

Merge order: #47, #48, #49, #50, #51, #52, then this. This branch is built on all six, 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).

The analysis entry point gets the same protections (#55)

Control On an analysis
Approval by folder hash Checked as each run starts. A changed plugin doesn't run, and isn't started just to ask what it offers
Output limits 16 MiB in a line, and 100,000 findings
Time limit 5 minutes, and Stop in the window ends it
Secrets It's sent none, so there's nothing of a source's to mask. Properties that look like secrets are masked first

Tests cover a tampered plugin, an endless line, output that isn't the protocol, and a plugin that never finishes, ended both by the limit and by cancelling. This also gives a real test of a plugin that hangs, which the describe timeout below still lacks. The 5 minutes and 100,000 are my choices, so tell me if you'd like others.

Gaps this closes

Gap Before Now
Plugin swapped after approval Ran with no prompt, if version and permissions matched Any changed file in its folder needs approval again
khonsu plugin uninstall .. Recursively deleted a folder outside the plugins directory Refused
entry of ../../x or an absolute path Ran it Refused when the manifest is read
Reinstall of a broken plugin Deleted the working one first Checked in a staging folder; the old one stays
Symbolic link in a plugin folder Followed, copying in whatever it pointed at Refused
Zip bomb Unpacked without limit Refused past 512 MiB or 10,000 entries, counting what actually unpacks
Endless line of output Held in memory The sync fails past 16 MiB in one line
describe or test output Unlimited, and describe could hang forever Cut at 16 MiB; describe stops after 30 seconds
Output that isn't valid UTF-8 Silently ended reading, showing as "no done" Reported as invalid output
Secrets in the error a sync returns My miss in #51: masked where stored, not in the returned error Masked there too, and in test results and previews
Approval dialog "Gets the permissions below" implied enforcement Says it isn't sandboxed and the list isn't enforced

The uninstall one was the most serious. I haven't seen it happen, but an agent or a mistyped command could have deleted real data.

Decisions for you

  1. Everyone re-approves third-party plugins once. Approvals from before this read as changed, because they hold no record of the folder's contents. There's no way to carry them over honestly. Built-in plugins are unaffected.
  2. Plugins can't write into their own folder any more without needing re-approval, except __pycache__ and .pyc. A plugin that keeps a cache there would need approving on every run. Let me know if you'd rather hash only the manifest and entry files. That's looser, but it wouldn't notice a changed helper script.
  3. .pyc files aren't checked, because Python writes them as it runs, even though they can hold code. It's a known hole, listed in the doc.

New

  • khonsu plugin approve ID approves a plugin as it is now. Without it, re-approving from the CLI would mean reinstalling from the original folder.
  • khonsu plugin list now gives approval (approved, not_approved or changed) and sandboxed: false.
  • sha2 is a new direct dependency of khonsu-core. It was already in the lockfile through HawDB's dependencies.

What this doesn't do

  • No sandbox. A plugin you approve can do anything you can. Its environment variables come with it.
  • Not a defense against an attacker with your account. The approval record sits beside the plugins, so anyone who can edit one can edit the other. This catches accidents and swapped files.
  • Programs on PATH, such as python3, aren't checked. The script is.
  • No limit on total records. A plugin can send valid records until you cancel.
  • Masking only covers declared secrets, and only what KhonsuGraph keeps or shows.

Testing

  • cargo test --locked --workspace passes on Windows, and CI will cover macOS.
  • End-to-end tests cover:
    • tampering with the program, a file beside it, and the manifest
    • entries and IDs that try to leave the folder
    • a bad reinstall
    • a 17 MiB line and invalid output, both with the graph intact afterwards
  • Unit tests cover archive caps, line limits and masking.
  • I checked the approval dialog in the real window on Windows, on a tampered plugin. I didn't run it on macOS or in Chinese.
  • Not tested:
    • The describe timeout. There's no cross-platform way to make a test plugin hang that doesn't also make the test take 30 seconds.
    • Symbolic links in folders and archives. Creating one in a test needs special rights on Windows.
  • Small gap: a sync refused for approval doesn't update the sidebar's last-sync line, so it can still say "Synced 11s ago". The error itself is shown.

Issue status

#39's criteria have tests, apart from the two gaps above. On "no new network access", this PR adds none, and there's nothing to test beyond that.

Refs #39

🤖 Generated with Claude Code

JetSquirrel and others added 11 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>
Saved investigations could only be used from the command line, and only
while the window didn't hold the graph.

Path Explorer can now save the current filters, walk and chosen path, and
File > Open investigation or khonsu open reopens one in a new tab, opening
its graph first if needed. The query goes in the editor without running.
Everything that differs from when it was saved shows as warnings on the
canvas, since a lost path mustn't wait for someone to select a node.

At startup the last graph is still opening, and a second open was dropped
without a word, so reopening waits for it.

Showing a finding's path now also sets the filters that find it, so saving
from there means the same thing.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A saved response that was missing read as an empty one, so a re-sync deleted
what it used to describe and nothing said so. An investigation could then
read an unfinished import as an all-clear.

A plugin can now finish with complete: false and say what it didn't
enumerate. The AWS plugin does so for a response that isn't saved. A full
sync removes records and moves the cursor only when it was complete.

Each source keeps its latest runs, with ID, times, status, plugin version
and gaps. Evidence, exposure findings and reopened investigations state the
coverage they rest on. A finding on an incomplete source can't be
not_indicated, since what was missed could be what would have met a
condition; what the source did report still stands.

Values of a source's secrets are masked in what its plugin says.

The shop fixture gains an explicitly empty route tables response, so its
answers stay as they were.

Refs #38

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An investigator couldn't ask what changed, because a graph only holds its
latest state.

khonsu snapshot save keeps a graph's records in a file by stable IDs, and
khonsu snapshot diff compares two. A record gone from the later snapshot
counts as removed only when its source's last sync was complete there.
Otherwise it's listed as not seen, since it may still exist.

A saved investigation pins the source runs it was evaluated against, and
names them when reopened on a graph that has changed. Evidence's
source_snapshot now gives the last complete run.

Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rced

A plugin could be swapped after approval and run without a prompt, as long
as it kept its version and permissions. Its entry could point outside its
folder, uninstall took any path, a bad reinstall deleted the working plugin,
and its output had no size limit. The approval dialog implied that declared
permissions were granted, when nothing enforces them.

Approval now covers a hash of the plugin's folder, so any changed file needs
approving again, with khonsu plugin approve or in the window. Entries and
IDs can't leave the plugin folder. Installs are checked in a staging folder,
and refuse symbolic links. Archives, output lines, describe and test all
have limits, and describe has a timeout.

Secrets are now also masked in the error a sync returns and in test results.
The dialog and CLI say plainly that plugins aren't sandboxed, and
docs/plugin-security.md sets out the threat model.

Approvals from before this read as changed, and need approving once more.

Refs #39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JetSquirrel and others added 10 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
Saving after "Show path on canvas" stored only the walk's filters, and reopening
walked again. That finds the fewest hops, which isn't always the finding's path:
a lateral finding's five hops came back as three, with nothing saying so.

A saved path can now hold the stable references a finding's path crossed the
process boundary as. Reopening looks those records up instead of walking, and if
one has gone or they no longer connect, path_same is false and the walk runs
instead. The window saves them for the finding path on the canvas, and
`khonsu investigation save --path-refs` takes a finding's path_refs. Older files
and walks are read and saved as before.

Refs #55, #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test deleted a relationship with a query that matched nothing, so it never
saw a changed graph. Change the saved responses and sync instead, as a real
account would change.

Refs #55

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oundary

Coverage used to be handled inside the exposure evaluator in core, which this
merge removes. It now goes both ways through the analysis:

- The request says which source wrote each record and how much of it the last
  sync covered, so the AWS plugin can apply its own policy: an incomplete source
  can't clear anything, and what it did report still stands.
- The host works out the incomplete sources behind each finding and marks the
  report incomplete itself, so this stays visible even for a plugin that ignores
  coverage. A source of the graph with nothing in it still counts.

Against the golden file from before the merge, every finding's ID, verdict,
unknowns and incomplete sources are unchanged, and so is the report's coverage.

Refs #55, #38

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	crates/khonsu-cli/src/lib.rs
#	crates/khonsu-core/src/investigation.rs
An analysis is a plugin run like any other, so it gets the same protections:

- Approval uses the folder hash, checked as each run starts, so a plugin changed
  after approval doesn't run and isn't started just to ask what it offers.
- A line of output is capped at 16 MiB, and findings at 100,000.
- An analysis is ended after 5 minutes, or by Stop in the window.

It's sent no source configuration or secrets, so there is nothing of a source's
to mask in what it says.

Tests cover a tampered plugin, an endless line, output that isn't the protocol,
and a plugin that never finishes, ended both by the limit and by cancelling.

Refs #55, #39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@JetSquirrel
JetSquirrel merged commit 5077929 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