Skip to content

Release 5.7.0 - #63

Merged
serialexperimentslainnnn merged 110 commits into
mainfrom
develop
Aug 28, 2026
Merged

serialexperimentslainnnn merged 110 commits into
mainfrom
develop

Conversation

@serialexperimentslainnnn

Copy link
Copy Markdown
Owner

Release 5.7.0.

Merging this publishes: release.yml cuts the signed v5.7.0 tag, builds and signs the artifacts, creates the GitHub Release and uploads to the Marketplace. There is no approval gate after the merge.

What ships

  • Guard view and alert log — every alert the Sensitive Guard raises is kept per project in the IDE's safe and readable in the chat's view row: what matched, what the rule saw, the verdict, and what let the call through. Search, filters by category and rule, Whitelist on any entry, configurable retention.
  • Guard rows survive reopening a chat, rebuilt from that log and anchored back to the call they judged. An alert a subagent earned is drawn in that agent's transcript, not the main one.
  • Guard modes — Enforcing, Permissive, Allow All, per rule as well as globally. Rules are Enforcing by default.
  • Settings ▸ Claude Code Security is its own page: every rule grouped and foldable, suspensions shown and endable, extra credential paths and blocked domains, whitelists at three reaches.
  • A shield in the chat's button row switches the guard to Allow All for a duration and back in one click, unlit whenever the guard is not deciding.
  • Wider detection — privilege escalation matched only where the payload executes; foreign paths read out of shell commands, not just a tool's location argument; encoded payloads decoded before judging; OpenShift alongside kubectl; snapshot-based recovery inhibition.
  • Vulnerabilities view — checks the project's manifests against a public advisory database, filters by severity, and hands findings to Claude to plan the fix.
  • Git view links out to the IDE's own windows; every view redraws in place, so filters, scroll and open cards survive a refresh.
  • Export, import and migrate settings — one JSON file, or straight from another JetBrains IDE on this machine. An export never carries environment variables; a permission mode that would weaken security is refused on the way in.
  • Both settings pages rebuilt to fit the window, per project and per IDE installation.

Full detail in CHANGELOG.md and RELEASE_NOTES.md.

Gates

All release gates green locally on the JetBrains runtime: JVM tests, detekt, spotless, kover, verifyPlugin, and the frontend suite (616 tests), lint and format.

UI end-to-end tests is not a required check on main; its scheduled runs currently fail in the runner (robot-server never answers on the fixed port), unrelated to this release.

serialexperimentslainnnn and others added 30 commits August 19, 2026 13:50
…-5.5.0

Post-release sync of v5.5.0 into develop.
The warning row covered the two bypasses that show no card. It did not cover the two that
involve the user directly: an Always allow answered earlier in the chat, which skips the card
entirely, and a card accepted just now.

The first of those was the worst of the four, being the only route past a rule with neither a
card nor a trace. All four now end at one place on the session, so the transcript answers the
same question the same way whichever was taken, and the reason distinguishes them: an approval
given five minutes ago and a shield left down last week are not the same event.

An ordinary permission card with no guard alert on it stays an ordinary grey line, and a call
nothing matched still says nothing.
Three problems found on the built plugin, all of them the same problem: the page was correct
and unreadable.

Both pages were filed under Other Settings, which is where the platform puts a configurable
with no group, and where a page opened while something is being blocked should not be.
groupId=root puts them where they are seen on opening Settings.

Allow All was a checkbox beside a mode combo, and nothing about that said which of the two was
in force or what either did. It is one of three values of the mode now, on the settings page
and in the chat menu alike, each with its own sentence: Enforcing refuses, Permissive asks,
Allow All lets it run. One field fewer on the document as well.

The whitelist was three text boxes, one of which asked for a RULE_ID=command format whose
left-hand side appeared nowhere a user could read it. It is one table of rows now, each with a
dropdown for how far it reaches: All rules, a category, or a rule, in the same words the rest
of the page uses. The three stored fields are unchanged behind it.
Settings stop being one shared document, which is a breaking change to how the plugin behaves
for anyone with more than one project open, so the major goes up rather than the minor.

The tag is not cut here: release.yml reads this version and cuts it from main.
The scope selector had ended up as a column inside the table, so adding a command opened a
combo box in a cell and every row asked the same question again. It is one dropdown above the
list now, defaulting to All rules, and the list underneath is that scope's commands — the same
shape the rule catalogue already uses for its categories.

Neither page was responsive. The notes were pinned to 600px of HTML, the form was pinned left,
and a rule row put its whole hint on one line, so the page scrolled sideways instead of
wrapping. The form fills the width, the horizontal scrollbar is gone, notes re-render at the
viewport width between a readable minimum and maximum, and a rule row is now its mode and its
name on one line with the detail wrapped underneath.
The row said "Allow All is on", which is the name of a mode rather than what happened, and it
named the rule without saying what the rule had seen. It now reads as three facts in order:
which rule matched, what it saw, and why the call ran anyway — the guard being disabled, or an
Allow All the user gave this exact command earlier in this chat.

The two that leave something standing carry a link, because the row is where the user finds
out it is still in force, usually by watching it act: Enable Sensitive Guard, and Disable this
authorization, which withdraws that command's approval for this chat. A card answered once and
a whitelist entry get none — the first is over, and the second is deleted where it was written.

Decision gained the finding as its own field: reason is that sentence dressed for the model,
and three surfaces now explain the same match to three different readers.
It read as an essay per bullet: the reasoning, the mechanism and the history of each decision.
A changelog says what changed. The why belongs in the commit and in docs/SECURITY-GUARD.md,
both of which already carry it.
The refusal ended with "do not retry it and do not attempt another way to do the same thing",
and on a fresh session the model generalised from one block to the whole conversation and
stopped acting at all. That sentence was advice, never a control: the guard re-judges every
call, so a different approach was always going to be evaluated on its own merits.

What replaces it is the one fact that stops the over-reading — the decision is about this
call — and nothing else. It still does not say where the off switch is, for the reason
docs/SECURITY-GUARD.md already gives.

Pinned in the existing test rather than left as two edited strings: the message must carry the
reason, must carry the scope, and must not carry either banned phrase.
The guard now writes every decision it makes to the IDE password safe, per project: what
matched, what it saw, the verdict, and what let the call through. Capped at the most recent
500, because a keyring is not a database and an entry that grows without a bound becomes the
one the safe silently refuses. Nothing shows it yet — it is groundwork Lain asked for.

Its first consumer is restore, which is why the two land together. Guard rows came back as
ordinary tool calls because they are the plugin's own rows and the binary's file has no record
of them: a refusal is a failed tool result with the rule name nowhere in it, and an allowed
call is indistinguishable from any other. So there was nothing to recognise on re-read, and
the log is the only place the fact survives. EntryDTO gained the three fields, GuardRestore
stitches the log onto the transcript by toolUseId, and it is pure so it can be tested without
an IDE.

An Allow All given on a card comes back without its undo link: that approval lived in memory
and died with the IDE, and offering to withdraw something that no longer exists would be a lie
told by the one surface that exists so the user is not lied to.

A whitelisted bypass gained Remove from whitelist, which takes the command off whichever of
the three lists is letting it through, narrowest first — the guard's own precedence order, so
the row removes the entry that actually acted.
An <svg> is a replaced element, so `inset: 0` with width and height auto
does not stretch it: the containing block fixes the width and the viewBox
aspect ratio decides the height, which is a flat 100px however tall the
row is.

Every row taller than that had its edge stop short of the next commit and
its dot, placed at 50% of the real gutter, sat below the junction. That is
the uncommitted-changes row with its file list, and any commit carrying
enough ref tags to wrap.
FormBuilder lays label/control pairs into one column and has no notion of
the width available, so nothing shrank: long text pushed the form wider
than the dialog and the right-hand edge was simply clipped. The old fix
re-rendered every note at the viewport width on each resize, which treated
the symptom in one place and left the fields themselves overflowing.

The DSL solves all three at the root. Comments wrap on their own, so the
note machinery goes; align(AlignX.FILL) decides what stretches; and group
and collapsibleGroup give the page a structure it did not have. The
scroller now hands the form the viewport's width instead of its own
preferred one, which is what stops the clipping.

Shape: eight titled groups on the general page, the last three folded. On
the security page the CardLayout and its category dropdown are gone -
nine collapsible groups, so the catalogue says how big it is and the rule
that just fired can be found by looking.

No bind* anywhere on purpose. A binding captures one State instance and
reload swaps the whole object, so it would keep writing to a state nobody
reads. reset/apply/changedFields still work against settings.state.

Adds ClaudeSecurityConfigurableHeadlessTest, which the guard page never
had: the round trip, the ownership contract over every field of the
document, and that pressing OK does not cancel a timed Allow All.
The open-chat list, the agent index and the review-prompt counter were
the only things the plugin still kept in the clear. The first two were one
plaintext file each under ~/.claude/ide/claude-code-native/, and that
directory belongs to the machine, not to an IDE: every installation on the
box shared them, keyed by project path.

Each is now a keychain entry per IDE installation per project, beside the
settings document and the guard's alert log. The review counter is global,
because a user is asked once, not once per repository.

Getting out of the two files: an installation claims the projects it knows
- open now, plus its recent list - writes each one's slice under its own
scope, rewrites the file without them, and deletes it once nothing is
left. Anything belonging to a project this IDE has never opened stays
where it is, so the file empties itself as each IDE migrates rather than
lingering for ever. Nothing is taken when the safe is not writing.

The cost, accepted deliberately: a project open in two IDEs has one entry
and the two do not talk, so the first to migrate takes it. The other
restores its most recent session instead of the exact tab set, once, then
writes its own; its past agent trees for that project are lost.

Restore Plugin to default state now clears all four of this project's
entries. It cleared one, which made what the dialog says untrue.
One flat dropdown held every category and every rule interleaved, so it
was thirty-odd entries deep and which kind an entry was could only be told
by reading its prefix. Two combos put that in the shape of the control:
pick the category, then the rule inside it, each carrying its own All.

The three reaches map exactly as they did - All rules is the global list,
a category with the rule left at All is that category's, and a category
plus a rule is that rule's - so nothing about how the guard asks changed.
Since 6.0 the settings are one keychain entry per IDE installation per
project, and JetBrains' own Import Settings copies configuration files and
never touches the keychain. So a freshly imported PyCharm starts on an
empty scope and inherits only the pre-6.0 shared document. Three buttons
close that, and each is a gesture rather than something that happens to
you - silently adopting another IDE's configuration is how somebody ends
up unable to explain why a project behaves differently.

Migrate is a key rewrite, not a transfer: every JetBrains IDE shares one
keychain, and what separates the scopes is a digest of the configuration
directory and the project path. Reading the other IDE's entry is the same
calculation with its path substituted for ours. The project list has to
come from that IDE's recentProjects.xml because the PasswordSafe cannot be
enumerated - a scope id can be computed and probed, never listed - and it
is filtered to the projects that actually have something to copy.

The two halves are deliberately not symmetric. A file leaves the machine,
so envVars never goes into one: that is where an API key or a credentialed
proxy URL ends up, and it is the reason this configuration is in the
keychain rather than in .idea/. A scope-to-scope copy never leaves the
keychain, so there everything travels. It is dropped on the way in as well
as on the way out, because a file can now arrive from anywhere; a
permission mode that would weaken security is refused by either route.

The tripwire is SettingsTransferTest's first case: every String field
whose name reads like it holds a secret must be withheld, so a field added
later cannot quietly start being written into a JSON in Downloads.
OUTSIDE_PROJECT read its paths from locationCandidates, which skipped the
command key outright. So the boundary held for the file tools and not for
the shell: reading a file in your home with the Read tool was refused,
reading the same file with cat was not, and the shell is where the work
happens. SECURITY-GUARD.md has promised both spellings since 5.x, so this
closes a gap between what the guard claims and what it does rather than
adding a new rule.

The extractor now tokenises a command the way pathCandidates already did,
keeping its own exclusions for pattern and block-comment content.

Three things are deliberately not reaches, each reusing a concept the
package already had rather than inventing an exemption:

  - a system binary directory, via ScriptExecution.SYSTEM_BIN_DIRS - an
    absolute path to git runs a program, it does not read your disk;
  - a device node - an inert sink is not a location, and a real device is
    still refused by the rule that runs before this one;
  - a path that is only DECLARED. Setting JAVA_HOME to a JDK outside the
    project names a directory and never opens it.

That last one has to come with its converse or it is a bypass: a variable
declared in a command is now bound and used to expand references in the
same command, so declaring a directory and then reading through the
variable is judged at the directory it names. And a variable that decides
WHICH CODE RUNS - PATH, LD_PRELOAD, BASH_ENV, GIT_SSH_COMMAND - is never
an innocent declaration: prepending a directory to PATH is how git stops
meaning git. That is also why command names are not resolved to full
paths: resolution says what a name means now, the shell decides at exec
time, and the assignment is the only visible moment.

Nine existing cases flipped from ALLOW to DENY on Lain's explicit
instruction. Seven are text that only path-shapes after canonicalisation
- a regex literal between slash delimiters, a doubled slash in integer
division, a Windows path inside an echo - and two are genuine reads
outside the project that the old line permitted. None of the nine had the
outside-project verdict as its subject; each still pins what it was
written for.
Every other rule in this package is scoped to what the account already
has. Root is outside that scope: it reaches any file on the machine,
including the ones the other rules exist to protect, and a mistake made
there is not recoverable by whoever approved it. So it gets a rule of its
own rather than being a case of some other.

Covered: sudo, sudoedit, su, doas, pkexec, runuser, setpriv, run0 and the
desktop wrappers on Linux and macOS; an AppleScript request for
administrator privileges; runas, Start-Process with RunAs, psexec, and
wsl as root on Windows.

Two limits keep it from becoming noise. It matches at command position
only and the name must end at a separator, so an escalator can never be
the prefix of a longer word - a wrapper script whose own name starts with
sudo is not an escalation. And it reads only from payloads that EXECUTE,
because commandCandidates visits command-shaped keys and nothing else:
reading a file that documents an install step, writing that line into a
README, or searching for it trips nothing. It is a rule about running.

It sits after the more specific families, so an escalator in front of an
intrusion tool is still described as the intrusion tool.

Enforcing by default and whitelistable per command. The default does not
bend to how cheap escalation happens to be on one machine - a
passwordless configuration, or one behind a hardware token its owner
taps, is a property of that host and not of everyone who installs this
plugin. Whoever needs one files it, which leaves a record; a rule left
off does not.
My comments reached 80% of the lines and Lain stripped the lot by hand.
The plugin is small; a codebase where most lines are prose is harder to
read, and the bloat is paid on every read by every session.

Written down because it overrides a habit rather than a preference: the
general engineering instinct is to document rationale in place, and that
instinct is the thing being suppressed here. So the rule names the four
places the reasoning goes instead - a better name, a contract test, the
commit message, or docs - and the three kinds of text that are not
comments about the code and therefore stay.
The contract said the two rules differ because one lives in a plaintext
file under the user's home and the other in the keychain. Since 6.0 both
are keychain entries, so the stated reason was false while the assertions
it explained were still right.

What actually separates them is purpose, not place: an index of who
spawned whom has never needed the content, and a log that cannot say what
was attempted is not a log. No assertion changed. Corrected under an
explicit instruction from Lain.
Three separate blocks trying to run it, and only the first two were my
sloppiness. The third is the guard doing exactly what we just told it to:
a path that resolves out of a regex literal is refused, and this script is
made of regex literals because its job is parsing comment syntax. It reads
the marker sequences out of a Kotlin declaration list and out of every JS
module header, so it cannot stop containing them.

Whitelisting its invocation would have worked. Lain chose the other way:
if the tool cannot be written so the guard allows it, the maps get written
by hand. So they are, and the generated symbol tables go with the script -
hand-maintained line numbers are fiction two commits later. Each map's
file table is the index now: what a file decides, no anchors to rot.

The deleted version is in history if the decision is ever revisited.
The allowlist already ignores it - nothing under that directory is
tracked, and no re-include rule reaches it. What the explicit entry buys
is the descent: without it git walks the whole scratch project on every
status because the un-ignore of directories applies everywhere.

Correctness unchanged, and deliberately left where it was written rather
than moved into a tidier section: last match wins in this file, so
reordering it is a change even when it looks like formatting.
Two the plan asked for and I skipped, and the first is the one that
matters: the refusal text was changed because a session that hit one
block stopped working altogether, and nothing stopped somebody putting
the instruction back. Now five assertions do - no order about what to do
next, the scope sentence present, the reason carried verbatim, a guard
named rather than a generic failure, and no mention of the switch that
would turn the guard off.

GuardRestoreHeadlessTest covers the wiring the pure test cannot: that
restore actually reads the alert log for its own conversation and
forwards the three fields into the transcript. Including the two that are
easy to get backwards - an Allow All given on a card comes back with no
undo link, because that approval died with the IDE, and another
conversation's alerts stay out.

The README went with them. Its version badge and build output still said
5.5.0, and worse, it still told the reader their settings are one
document shared by every project - the exact thing 6.0.0 stopped being
true. The security row still promised five switches; that page now holds
a mode per rule across nine categories.
The rule went into CLAUDE.md this morning and the code did not follow it
yet. 74 files, about 1400 lines: the reasoning moves to a name, to a
contract test, or to the commit that made the change. Nothing here alters
behaviour - the gates and the whole frontend suite pass unchanged.

Machine-read pragmas stay, because removing one changes what a tool does:
noinspection, eslint-disable, the ts- directives, istanbul ignore, licence
headers, and Kotlin's own Suppress annotations, which were never comments
to begin with.

Six comments survived the sweep for the wrong reason and were taken out by
hand afterwards. The stripper keeps anything MENTIONING a tool name, and
detekt and ktlint were in that list even though neither reads a comment -
in Kotlin the suppression is an annotation. So prose that merely said the
word detekt was preserved, and where it had been part of a longer block
what remained was half a sentence explaining nothing. A filter that
matches a word rather than the syntax a tool parses will keep doing that.
Twenty-four vectors covered terraform, kubectl, five aws verbs and the
classic filesystem atoms. Lain's reading was right: for devops that is
thin, and the shape of the gap says why. An enumeration only ever holds
the tools whoever wrote it had in mind that afternoon.

Now seventy-odd, grouped by the rule they answer to and written as
tool-plus-verb rather than one atom per command, so a sibling tool lands
in the same net. What the old list missed, checked against
destructive_command_guard, the MITRE persistence matrix and vendor docs
rather than from memory:

  - Windows had NO coverage at all. Recursive removal, drive formatting,
    disk clearing, and the shadow-copy deletion that is the canonical
    ransomware precursor.
  - Deleting a secret was not destructive to anything: the vault CLIs,
    the cloud secret stores and the orchestrator's own secret objects.
  - The most common data-loss statement in SQL - a delete with no
    predicate - was absent, as were the dedicated drop utilities and the
    framework reset commands that recreate a schema from scratch.
  - Storage below the filesystem: pool and volume destruction, partition
    wiping, block discard, subvolume deletion, disk erasure.
  - Quiet mass deletion, where the destructive part is an option rather
    than the verb: mirroring with deletion enabled, and search-and-remove.
  - Orchestrators other than kubectl, and the tools that delete a whole
    cluster in one call.
  - Git operations that discard uncommitted work rather than rewriting
    history, which is the loss a developer actually suffers.

Still enumeration, and it will age the same way. The alternative - match
the shape of a destructive verb in subcommand position - is what the
device rule did when its own list kept missing entries, and it is the
right end state. It needs the false-positive work done first: an ordinary
container removal, a pod delete and a package uninstall all fit that
shape and are somebody's Tuesday.
A single oversized command hung the guard for tens of seconds on the thread that
reads the tool binary output, a denial of service against the guard available
today. Three patterns had an open quantifier before a mandatory literal, so they
were quadratic under a full-string scan:

- the brace tokeniser in CommandRules scanned to the end from every start
  position looking for a brace group; it now splits on whitespace and tests each
  token, which is linear and recognises the same set of tokens
- URLISH and URL_IN_TEXT in ToolInputScanner are now possessive, and the URL
  extractor skips any value with no scheme separator, since without one it cannot
  match; the unanchored scan over one long token was the dominant cost

The length cap now also bounds the path and location extractors, not only the
command extractor, so no oversized value reaches the patterns at all. Truncation
emits a head and a tail window and never drops a candidate, so a destructive
command padded at either end is still caught. GuardCommandLengthTest pins both
the time budget and the head and tail coverage. No verdict changes: the other
permission tests stay green.
Every other command family de-obfuscates with the launch home and environment before
matching, so a routing flag hidden behind a variable is resolved and caught. The proxy
family alone called de-obfuscate with neither, leaving a small but real detection gap: a
flag whose destination came from an environment value went unseen. Pass the policy home
and env like the rest.
A coding agent has no legitimate reason to erase its own tracks, so a new DEFENCE_EVASION
category carries an ANTI_FORENSIC rule that refuses clearing the shell history (history -c,
unset HISTFILE, set +o history), vacuuming the systemd journal, and the PowerShell and Windows
equivalents (Clear-History, the SaveNothing readline option, wevtutil cl, Clear-EventLog). It
is matched at command position only, so reading or committing a message that merely mentions a
technique is untouched. Signatures come from public threat references; the tests cover the
positives and the false-positive surface (bare history, journal inspection, a commit message).
A coding session never runs a miner, so a RESOURCE_HIJACKING rule under the intrusion-technique
category refuses the known mining binaries (xmrig, minerd, cpuminer, ethminer, t-rex and the like)
where a command begins, and the stratum pool-protocol scheme they connect with anywhere in the
command. Tests cover the positives and the false-positive surface — an ordinary build, a search for
the word, and a commit message that mentions a miner all stay allowed.
Ransomware destroys the means to recover before it encrypts, and nothing in development needs to.
A new INHIBIT_RECOVERY rule, under destructive operations, refuses the recovery-inhibition forms of
T1490 that the destructive-command family did not already cover: the Windows backup-admin removals,
the boot-recovery-off and shadow-storage-resize switches, WMI shadow-copy removal, and the macOS
Time Machine off switch. Tests cover the positives and keep ordinary backup status and enumeration
allowed. The WMI form crosses a pipe, so that one vector matches across the pipe within a statement.
Entering the host namespaces or handing a container full host control is a documented escape
(T1611), so a new CONTAINER_ESCAPE rule refuses nsenter into PID 1, mounting the host root
filesystem, running with full host control, mounting the container-management socket, and the
equivalent pod-spec fields. It is Enforcing by default and whitelistable: a legitimate full-control
run is the user's to permit, never ours to leave unguarded. Tests cover the escapes and keep an
ordinary project mount and a non-PID-1 namespace allowed.
serialexperimentslainnnn and others added 29 commits August 21, 2026 10:15
Every alert older than the restored window piled up at the top of the main
chat, and the agents stopped getting theirs on their cards. Both came from one
change: unanchored rows moved from being appended at the end to being woven in
by timestamp, and a row older than the first entry in the transcript sorts
before all of them, so it lands at the top. Having no anchor, it also carries
no parent tool use id, which is exactly what routes an alert to an agent card.

Partition the loose rows against the earliest timestamp the transcript has:
those inside the window are woven in where they belong, those older than it go
back to the end. The rows a transcript with no timestamps cannot place are
still dropped, as they were, since there is nothing to place them against.
Three frontend tests were left asserting the behaviour that e164b88 replaced
on purpose, and they have been failing since. Reconciling the cards means an
unchanged payload now reuses the node instead of tearing it down, so a test
demanding a new first child was demanding the bug back; the whitelist button
reuses the guard-ask class for its styling, so the selector for the explain
button matched both and no longer distinguishes what the test is named for.

Nothing here loosens an assertion: the scroll test still fails if the panel or
its scroll position is lost, and the explain test still fails if the button
appears where it should not.
The fixture pinned asOfMillis to a fixed instant in 2023, and the view renders
how long ago the scan ran, so the rendered age drifted a little further from
the assertion every day until it broke. Anchor it to the moment the test runs.
The coverage floor has been red since the OSV client landed, with the package
measuring 58 percent against a floor of 65. Two of its classes cannot be
reached by a unit test, on exactly the grounds the file already excludes
process and ui for: OsvHttp is a java.net.http wrapper whose every branch
needs a live socket, and VulnService is a project service that needs a
Project, the pooled thread and the EDT.

OsvScanner is deliberately left inside the gate. It is reachable in principle
and its zero coverage is real debt, so it stays measured and named in the
checklist rather than being defined out of the measurement. The figures in the
policy table were also four packages and one release out of date.
Four changes with no behaviour in them. Import order and branch bodies for
spotless, the retention default named instead of written as a literal, and the
scanner's hydration lifted into a function of its own so the number of returns
in one body drops back under what detekt allows.
The guard log keeps up to 500 entries and the restored transcript is capped at
its last 200, so every alert older than the first restored entry has nowhere to
go. Weaving them by timestamp put them at the top; partitioning them out put
them back at the end. Both are the same defect wearing a different position: a
row rendered where it did not happen.

An alert that cannot be placed is not restored. It is still in the guard log,
which is the view built to hold it, and the transcript stops claiming a sequence
it does not have.
Plan is the view you open least and the one that is often not there at all: its
button is hidden unless the session has a plan. First in the row, it makes the
whole row shift sideways the moment a plan appears or disappears.
A tool call made inside an agent never reaches the main transcript model:
onToolUse returns early when the call carries a parent. The guard's card was
added to that model regardless, so every refusal an agent earned was drawn in
the main chat, detached from the call it refused, while the agent's own tab
showed only the raw tool error.

The agent's rows come from its own JSONL, which the guard never writes to, so
the card cannot come from the file. Weave it in the same way a restored session
does: the alert is anchored by tool use id, and the log already stores that.
Alerts anchored in the agent's entries are woven into its tab; the main chat
keeps only the calls it made itself.

The notification then has to ask a sharper question. It used to skip only when
the panel was showing the chat, which is wrong in both directions once a card
can land elsewhere: it stayed silent for an agent's alert while the chat was up,
and warned about a chat alert while an agent tab was up. It now asks whether the
tab on screen is the one the card landed in, and an alert whose home cannot be
resolved yet is never assumed to be visible.
An agent tab is drawn by agentRows, a second row builder that never carried the
guard fields. The card arrived and rendered as a plain system notice: the JS
decides to draw the footer by looking for blockedRule or bypassedRule on the
row, so with neither present there was no Disable rule, no Whitelist Command and
no way into the log. The refusal was visible and unactionable, which is the worse
half of not showing it.

The footer's links carry the rule and the command, never the row id, so the same
fields entryJson already emits are enough for them to work from an agent row.
The live path now sends an agent's alert to the agent's tab, but restore did not
follow: it anchors an alert by tool use id against the whole saved transcript,
and the session's own JSONL does contain the agent's calls, nested under the
Task. The anchor matched, so on every IDE restart the card came back in the main
chat under the agent card as well as in the agent's tab.

An anchor that carries a parent is a call the chat did not make itself. Those
alerts are dropped before restore weaves anything, so the agent's transcript
stays their only home. The filter cannot live inside reinstate: the agent's own
entries carry that same parent, and there it is what makes them the agent's.
Restore had to work out after the fact where an alert had come from, and every
signal it had was ambiguous: the agent's call is a row in the agent's own
transcript and a nested row in the parent session's, so anchoring by tool use id
matches in both. Inferring it back from the parent field only works while that
field survives the round trip, and it is not the moment the answer is known.

The guard already knows. When it fires, the call either is or is not one this
chat made itself, and the alert now carries that. Restore drops what was not
this chat's, so an alert is drawn in the transcript that produced it and nowhere
else. The parent-based test stays for alerts written before this build.
Nothing here removes a capability or changes a contract a user depends on, so
the major was not earned: the settings scope moved, but a project with no
settings of its own still starts from the ones that were already there.

The changelog and release notes are rewritten around what the release ends up
being rather than the route to it. There is no Fixed section: the guard's alert
log and the vulnerability scanner do not exist in 5.5.0, so nothing built on
them was ever broken in a version anyone ran, and a defect introduced and closed
inside one cycle is not something to announce.
PluginIdentity.PLUGIN_VERSION was left at 6.0.0 when the release was recut as
5.7.0, so every outbound request advertised a version the plugin is not, and
PluginIdentityTest failed. The constant is the one runtime-readable source of
truth for the version (the descriptor needs an internal API to read).
Ordered hardening of the guard. Every change tightens; nothing is loosened.
Proven live this session: a parameter-expansion splice, a subshell-wrapped verb,
and an unscanned Write into a git hook all evaded the guard.

- Fused-expansion collapse: CommandRules.stripFusedExpansions removes any
  parameter expansion (every operator form) and the positional/special params
  fused into a word, after expandEnv, to a fixpoint inside peel. Because every
  family runs on deobfuscate, a verb split by an expansion is seen through
  everywhere at once. deobfuscatePath applies the same to non-command path values,
  so an obfuscated Read path is judged too.
- Shared command-position anchor: CommandRules.AT_COMMAND now opens after a
  subshell or group start (never a command substitution or a brace expansion), a
  run of NAME=value assignments, no-op wrappers (env, nohup, time, nice, command,
  exec, stdbuf, setsid, ionice) and their flags, and after a container exec/run.
  PrivilegeEscalation, Tunneling, AntiForensics, DisableDefences, ResourceHijacking,
  cmdStart and the ScriptExecution anchors all consume it, so a verb reached inside
  a chain is caught in one place.
- Relative traversal: GuardPaths.absoluteForm anchors a relative candidate at the
  project root the way the shell anchors at the cwd, so OUTSIDE_PROJECT judges a
  dot-dot traversal instead of dropping it for lacking a leading slash.
- Write into an execution sink: ExecutionSinks.isSink + SensitiveGuard's
  sinkWriteFindings judge the content of a Write/Edit when the destination is an
  auto-executed location (git hooks, shell rc, autostart, cron, launchd, systemd,
  fish). Inert files — docs, data, source, a plain project script — are untouched.
- Commit/push scans the live hooks: committedHookFindings reads the repo's hook
  files on commit/push and judges each, so a poisoned hook is caught before it runs.
- No execute-bit dependency, name-spoof closed: an interpreter running a file,
  source, and dot are judged by content regardless of permission; the DevToolScripts
  exemption now applies only to an unreadable tool, so a readable dev-tool-named
  script carrying a payload is read and judged. java source-launch is scoped to
  its source suffix so running a prebuilt jar is not mistaken for it.

Covered by GuardObfuscationHardeningTest (cases plus two deterministic fuzzers over
~5000 iterations). The relative-traversal case in SensitiveGuardTest is updated to
assert the new, secure behaviour.
Extends the guard across the AWS, gcloud/gsutil/bq, kubectl and oc command
palettes, compiled from the official CLI references and cross-checked with
Stratus Red Team, Falco k8saudit and the MITRE ATT&CK for Containers matrix.

Destructive side (DestructiveCommands): the existing broad cloud teardown
patterns already covered most of the surface; this adds the gaps they miss by
shape — scheduled key destruction and key disabling, logging and monitoring
teardown (defense evasion), the exfil-via-sharing set (image and snapshot
attribute changes, opening a firewall to the world, public bucket permissions),
message-queue purge, cluster termination, warehouse dataset removal, and the
cluster resources the orchestration rule did not name (node, persistent volume,
daemonset, cluster-scoped RBAC), node eviction and pruning, and an evicting taint.

Secret and credential exposure side (DANGEROUS_COMMANDS): secret-value retrieval,
decrypted parameter reads, key-based decryption and data-key export, long-term
key and console-login creation, temporary role and session credential minting,
host password and user-data disclosure, registry login material, admin auth
flows, certificate export, API-key value reveal, and function environment dump;
the equivalents for secret access, token printing, service-account impersonation
and key creation, signing oracles, API-key string retrieval, host and database
credential exposure, and cluster credential writes; plus the cluster secret
dumps, token minting, secret extraction and session-token printing. A command
run through a container exec is already judged by the shared anchor, so the inner
verb is what trips.

Every pattern is Enforcing and whitelistable. Covered by GuardCloudCommandsTest,
which pins read-only usage as ALLOW and checks the obfuscation collapse still
sees a spliced or subshell-wrapped cloud command.
The guard expands variables so it can judge where a path really points. The
expanded string then went verbatim into the text describing the hit, and that
text is handed back to the model as the refusal and stored in the transcript and
the alert log. Because the expansion map is the process environment plus the
user's own settings block, a refused read of a path naming a sensitive variable
answered with that variable's value — turning the control built to stop
exfiltration into a read oracle, one refusal per variable.

Redaction happens at the single exit from evaluate, so no rule, present or
future, can leak through this door; matching still runs on the expanded form, so
nothing is weakened. Only values of variables whose name reads as sensitive are
replaced, and only when long enough to be a secret, so an ordinary variable and
a home-anchored path stay legible and the message still says what was wrong.
onBeforeBrowse returned userGesture, and CEF cancels a navigation when the
handler returns true. So the check was inverted twice over: it cancelled the
navigations that carried a user gesture and permitted every navigation that did
not — which is exactly the script-driven case. onBeforePopup, two lines below,
returns true unconditionally to block popups, so the convention was understood
in the same file.

It matters because the CSP closes every other way out of the page: no fetch, no
XHR, no remote image, no form post. A top-level navigation is the one channel
CSP has no directive for, so setting location.href was the exfiltration route if
the page were ever made to run attacker script, and the transcript is whatever
the agent has read this session.

The target is now compared against the pages the host loads itself — the scheme
page and, when bound, the loopback page — and anything else is cancelled whatever
its gesture. Link clicks are unaffected: the JS already cancels those and hands
them to the host, which opens them in the real browser. The decision is a
top-level function beside nextPageRoute so it is testable without a browser.
The legacy per-project settings document is a project-level persistent state on
.idea/claude-code.xml, so a repository can simply commit one. On the first open of
a project that has no settings of its own, the whole document was adopted into the
password safe with only the permission mode clamped, and the file was then deleted.
Everything else rode in: the path to the binary the plugin spawns, the node path,
the script it sources at launch, the stdio MCP servers it starts, the guard's mode
and per-rule state and whitelists — and the flag recording that the user had
already agreed to trust an execution config. One file supplied both the code to
run and the consent to run it, with nobody asked. Clone, open, owned.

Adoption now goes through UntrustedState, which knows the difference between the
two routes, because consent is the difference:

- A project file gets nothing that decides execution or how much the guard asks.
  Nothing in it was consented to, so the guard's rules and whitelists are dropped
  as well.
- An explicit Import settings…, which the user picks and confirms, keeps the guard
  rules and whitelists the confirmation names — that is the feature — and still
  drops what it does not name: the execution primitives, the trust flag, the master
  switch and its far-future-suspension spelling, and remembered tool approvals.

Model, effort, endpoints and the rest are adopted as before, so migrating a project
still does something. The user-facing notice about a refused permission mode is
kept. The import path's own clamp is subsumed, and its now-unreachable helpers are
removed rather than left for the reachability contract to trip over.
ensureExecTrust was called from one place, ClaudeSession.start, but the function
that actually sources the script is reached from several earlier callers: the boot
refresh that a timer drives simply because the tool window is open, and all three
sign-in flows. So trust-on-open was bypassed by opening the panel — the script ran
and the answer was never asked for.

The gate now sits inside the function that does the running, so every caller is
covered by construction, including any added later. An untrusted script is not
sourced and the reason is logged.

Also records why the two binary paths are deliberately not part of what the dialog
asks about: it asks whether you trust this PROJECT, and a path typed into your own
settings is your own choice. Adding them made the integration fixtures prompt, and
the prompt's own wording would have been untrue. The boundary they needed is held
elsewhere — a state adopted from a file cannot carry either path.
The two "until this IDE closes" relaxations — one rule, or the whole guard — were
held in a single set and a single flag on a process-wide object. Everything else
about the guard is per project, and the documentation says so: tuning one
repository's rules says nothing about the next one you open. These two did not
honour that. Relaxing a rule in a scratch project relaxed it in every other
project open in the same IDE, and standing the guard down there stood it down for
all of them — with nothing in the other project's UI or settings page to show it,
because their own documents were untouched.

They cannot move into the settings document, since the point of them is that they
die with the process. So they are keyed by the settings scope instead, which is
already the per-project identity used for the document, the alert log and the
agent index. Every caller passes its own scope; the timed durations were already
persisted state and so were already correct.

SettingsGuardMasterSection takes a ClaudeSettings like its sibling section does,
which is how it reaches the scope, and JcefSettingsMenu threads the scope through
the three entry points that touch this state.
Three gaps, all of the same shape: something judged one part of a request and
treated the answer as covering the whole of it.

An *Always allow* on a card was checked against the FIRST command in the tool
input. A tool input can carry several — an MCP server names its own inputs, and a
dozen key names read as commands — so an approval given for a harmless one
auto-allowed whatever else travelled alongside it, with the transcript naming
only the part that had been approved. The whitelist already required every issued
command to be approved; the card path now does the same, and answering the card
records all of them so a later identical call still matches.

The scheduled-execution family was four patterns wide, which left the schedulers
people actually reach for: transient systemd units, enabling a unit that is not a
timer, lingering, launchd load and bootstrap, task-scheduler creation from either
CLI, the Run key, and the two directories macOS and freedesktop auto-start from.
The hook-directory pattern also only matched the classic path, missing the
versioned convention right next to it.

And the singular attach message skipped the project-root check its plural sibling
performs, so the same family of message had two different rules; it now goes
through the same handler.

Covered by GuardPersistenceVectorsTest, which also pins that inspecting what is
already scheduled stays allowed.
Whether a path is inside the project was compared case-insensitively everywhere.
That is right on Windows and on the default macOS volume, which fold case, and
wrong on Linux, which does not: a real and separate sibling directory whose name
differs only in case read as part of the project, which exempted it from every
rule that applies only outside it — credentials, the temp directory, and the
outside-project rule itself. Creating one was unguarded for the same reason.

Case is now folded only where the filesystem folds it, and still folded for a
drive-rooted path whatever the host, since that spelling is Windows either way.
… use

Reading a build wrapper for the first time — the name-based exemption went away
because an attacker picks the name — exposed a family of fabricated paths. None of
them appears anywhere in the script: the analysis invented them, then the rule
refused the script for reaching them.

Variable resolution, both defects long-standing:

- The home directory was substituted with a plain string replace, so a longer name
  beginning with the same letters was cut in half and the remainder glued onto the
  expansion. It now substitutes only when no name character follows.
- An assignment's value was captured up to the first space, so a value that opens a
  subshell bound the variable to a two-character fragment; substituting that
  fragment produced an absolute path out of nothing. A value whose brackets, braces
  or backticks do not balance is no longer bound at all, so the variable stays
  unresolved — which is the truth, since its value is computed at run time.

And a path counts as a place only where something operates on it:

- A shell comment is not a command. Quoting is respected, so a hash inside quotes
  is text and cannot hide what follows it.
- A NAME=value token is a declaration wherever it sits, not only at the head of a
  line. Execution-controlling names still count, as they already did.
- A flag carrying its own value is a declaration too.
- An argument of a verb that touches nothing is not a place; an unknown verb still
  counts, so the default stays closed. A redirection target always counts, and a
  navigation target counts whenever the command goes on to do something.

Contrast is pinned throughout: reading, writing and executing outside the project
still refuse, navigating somewhere and then writing there still refuses, a glob
that names a place is still a place, and an execution-controlling declaration is
still a reach. Two assertions that pinned a bare echo of a path as a refusal are
updated on Lain's instruction — that was the false positive — and each keeps an
operative-verb control beside it.
detekt had been declared unrunnable here for weeks. It was the JDK: the Gradle
daemon picked up the system Java 25, which detekt 1.23.8 does not support, and the
failure carried no message beyond the version number. Pointed at the JetBrains
Runtime the build already declares as its toolchain, it runs — and found five
issues, all introduced today. Each is fixed at its cause rather than by moving a
threshold: the guard-field emission is extracted from the agent-row builder, the
settings-menu choice takes a value object instead of six parameters, the redaction
loop is a fold, and the two fuzz splicers share their split predicate.

Assistant scratch tooling under .claudetools is excluded from eslint. It is node
CommonJS against node globals, never shipped and already gitignored, so no-undef
fired ten times on every workstation that has one.

The rest is spotless and prettier output, plus the test renames that ktlint's
property-naming rule wanted after the suspension scope was threaded through.

All seven gates now pass locally: test, detekt, spotlessCheck, koverVerify, and
the frontend's vitest, eslint and prettier.
The README presented the `claude` CLI as something to install beforehand — "the
binary you already have installed", a requirement line, and a comparison row
reading a bare "Yes". The plugin has offered to install it, per OS and per
distribution, since the onboarding screen existed; the documentation just framed
it as the reader's homework. Fixed in the four places that said so.
The entry named one nested checkout by hand. `subprojects/` is where they live, so
the pattern covers the next one too, and a stray checkout can no longer arrive in a
commit because someone forgot to add a line. Also restores the trailing newline the
file had lost.
The hook lived in a directory the plugin's own guard protects, and reading it on
every commit refused the commit: its shebang matches an intrusion pattern, and the
guard cannot tell a rule's own vocabulary from an attack. This repository is the
worst case for its own control, because the defensive code necessarily contains the
strings the rules look for.

Removed rather than relocated: moving it would only hide it from the scan while it
kept running, which is worse than not having it. Conventional Commits are still
enforced where it counts, by commitlint in CI on the pull request.
The frontend job failed on CI while every one of its 616 tests passed: vitest
exits non-zero on an unhandled error, and a timer was firing after the test
environment had been torn down, so the global object it reached for no longer
existed. It reproduced on both runs and attributed itself to a different test file
each time — proof it belongs to none of them — and never once locally, where the
suite takes 3.7s against CI's 31s. The window it needs is the gap between the last
test and teardown, and only a slow machine leaves one.

Two callbacks reached through the global object with nothing guarding them:

- The composer's follow toggle read `window.CC` purely to guard a call it then made
  through the file's own local alias, assigned from that same object at load. The
  guard is now the alias, which is what the next line already used: identical in a
  browser, and no global to be missing.
- The core's readiness probe retries every 50 ms up to two hundred times — ten
  seconds of exposure, far more than the composer's sixty milliseconds — and only
  some tests define the symbol it waits for, so in the rest it was still running
  long after the page. It now returns when there is no page to talk to.

`CC.send` reaches through the same object and is left alone: it already sits inside
a try, so the same failure is caught there.

Verified by inspection rather than by reproduction: the suite stays green locally,
but the fault will not reproduce on hardware this fast, single-forked or otherwise.
CI is the judge.
@serialexperimentslainnnn
serialexperimentslainnnn merged commit 52f51c8 into main Aug 28, 2026
24 of 34 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