Release 5.7.0 - #63
Merged
Merged
Conversation
…-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.
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.
release: 5.7.0
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release 5.7.0.
Merging this publishes:
release.ymlcuts the signedv5.7.0tag, builds and signs the artifacts, creates the GitHub Release and uploads to the Marketplace. There is no approval gate after the merge.What ships
kubectl; snapshot-based recovery inhibition.Full detail in
CHANGELOG.mdandRELEASE_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 testsis not a required check onmain; its scheduled runs currently fail in the runner (robot-server never answers on the fixed port), unrelated to this release.