fix: read the copilot approvals default, and config files with a bom - #293
Conversation
A user who sets chat.defaultConfiguration to {"approvals": "allowAll"} runs
every tool call without being asked, and we reported nothing at all for them —
or permission_mode default when another key happened to be present. On the
permissions page they looked exactly like a locked-down user.
The key is registered (approvals: manual | assisted | allowAll, default manual)
and VS Code reads it straight into each new session's auto-approve state, so
allowAll now maps to bypassPermissions. The name it carried before the rename,
chat.agentSessions.defaultConfiguration, is read the same way for installs that
still hold it.
Two things deliberately left alone: assisted still prompts, so calling it a
bypass would overstate the posture, and mode: autopilot picks the chat mode
without touching approvals.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
✅ Security consensus: no issues found. (reviewers: claude, semgrep, gitleaks)
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head f3a85196 · 2026-09-05T18:10Z
A settings.json written by PowerShell's Set-Content -Encoding UTF8, or by an older Notepad, starts with a BOM. json.loads rejects it outright, so the parse returned None and the user vanished from the permissions page — the same silent blindness as an unread key, with a different trigger. The file is not a broken artifact: the editors read it happily and preserve the BOM through their own edits, so the config stays live and stays invisible to us until someone rewrites the file by hand. The strip goes in the shared JSONC entry point, so every reader that routes through it is covered at once — Copilot settings and MCP on all three platforms, Augment, and the Copilot CLI — rather than fixing the one file that surfaced it. A BOM character inside a string literal is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bugbot needs on-demand usage enabledBugbot uses usage-based billing for this team and requires on-demand usage to be enabled. A team admin can enable on-demand usage in the Cursor dashboard. |
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
✅ Security consensus: no issues found. (reviewers: claude, semgrep, gitleaks)
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head 7f2befa9 · 2026-09-06T02:00Z
Test battery — Stage 1 + Stage 2 on
|
| Reader reached by the change | Exercised how |
|---|---|
| Copilot settings (macOS / Linux / Windows) | real CLI on both runners |
| Copilot MCP (macOS / Linux / Windows) | real CLI on both runners |
| Augment settings + MCP, Copilot CLI settings + MCP | full local suite (960 selected) |
End to end on both runners
A settings.json and an mcp.json planted per user, one pair written with a BOM the way an MDM script writes them (Set-Content -Encoding UTF8), one pair without — so the fix and the no-regression case run side by side through the real CLI reporting to a local capture stub.
| user | file encoding | settings → | mcp → |
|---|---|---|---|
devBOM |
BOM | bypassPermissions |
bomserver |
devPlain |
plain UTF-8 | bypassPermissions |
plainserver |
Identical on sentinel-agent-runner as root and poc-win-runner as NT AUTHORITY\SYSTEM. The devBOM settings file carries only chat.defaultConfiguration: {"approvals": "allowAll"}, so it was invisible twice over before this PR — unreadable file and unread key.
Proven to fail without the fix
Not just locally — the same planted files, read by staging's code on the runner:
STAGING mcp.json parse : FAILS -> JSONDecodeError Unexpected UTF-8 BOM (decode using utf-8-sig)
STAGING settings record: None
Locally, six of the eight new guards fail on staging with the real symptoms (_build_record returning None, and the same JSONDecodeError); the other two are guardrails proving assisted and mode: autopilot are not overstated as bypasses.
Suites
| Leg | Result |
|---|---|
| macOS local | 63 passed |
| Linux runner, as root | 62 passed, 1 skipped |
| Windows runner, as SYSTEM | 56 passed, 7 skipped |
| Every suite routing through the shared helper | 960 passed; the only reds are test_copilot_cli_discovery, which fails the identical 7 on staging on this machine (a real ~/.copilot is present) |
| CI | three macOS legs green, Windows legs and Cursor still running; Greptile passed |
One thing worth recording
A first Windows run reported no permissions for either user while MCP worked fine. That was the fixture, not the code: I had planted only the github.copilot extension, so the canonical Chat row never existed for those users and permissions had nothing to attach to. Re-run with the real extension pair and both users reported correctly. Worth knowing that on a multi-user host the canonical row is chosen once per scan, so a user whose install produces a different row name gets no permissions block — pre-existing behaviour from #285, unchanged here.
Hygiene
Both runners torn down — synthetic users deleted, planted files and stubs removed, residue checks clean.
|
@greptile-apps review Re-review request: I overwrote the PR description with a REST Please re-post the review so the confidence score is on record here. |
The BOM strip sits in the shared JSONC entry point, so it changes behaviour for six extractor families. Only Copilot settings had a test for it, which is the one family that happened to surface the bug. Adds coverage for the other paths a reader would otherwise have to take on trust: VS Code MCP, Augment settings and Copilot CLI settings each parsing a BOM'd file, and an identity assertion that the Copilot CLI re-export really is the same function rather than a copy that could drift. Also pins three things the diff could plausibly break but does not: - a settings file written before this change yields a byte-identical record, so the two added keys introduce no drift; - a UTF-16 BOM still fails cleanly instead of being half-stripped into something that parses as the wrong document; - the packaging entry in setup/, which is a second argparse wrapping main() and the front door MDM actually runs, still fails open when unconfigured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
✅ Security consensus: no issues found. (reviewers: Claude lead review, Semgrep, Gitleaks)
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head fdb890f4 · 2026-09-08T20:40Z
| GW = Path("/Users/aakashvelusamy/github/ai-gateway-data") | ||
|
|
||
| def test_record_satisfies_process_permissions_validation(self): | ||
| if not self.GW.exists(): | ||
| self.skipTest("ai-gateway-data not on this machine") | ||
| rec = _Ex()._build_record( | ||
| {"chat.defaultConfiguration": {"approvals": "allowAll"}, | ||
| "chat.agentSessions.defaultConfiguration": {"approvals": "manual"}}, | ||
| Path("/Users/u/settings.json"), "user") | ||
| self.assertIn(rec["settings_source"], ("user", "project", "managed")) | ||
| self.assertTrue(rec["settings_path"]) | ||
| self.assertEqual(rec["permission_mode"], "bypassPermissions") | ||
| # permission_mode is a CharField(max_length=50) in the backend model | ||
| self.assertLessEqual(len(rec["permission_mode"]), 50) | ||
| self.assertIsInstance(rec["raw_settings"], dict) | ||
|
|
||
|
|
||
| class N6_SecondParser(unittest.TestCase): | ||
| """setup/ ships a PyInstaller entry with its OWN argparse that wraps main(). | ||
| It is the MDM/LaunchDaemon front door — a different parser for the same run.""" | ||
|
|
||
| ENTRY = Path("/Users/aakashvelusamy/github/setup/packaging/unbound_discovery_entry.py") |
There was a problem hiding this comment.
These cross-repository tests use author-specific paths under /Users/aakashvelusamy/github/.... The normal test workflow checks out only this repository, and the tests skip when those paths are absent. As a result, CI does not enforce the claimed packaging integration, so a regression in the LaunchDaemon entry point could still pass. Use CI-provisioned fixtures or run these checks in a pipeline that checks out both repositories.
Knowledge Base Used: Tool registry and contracts
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
It hardcodes absolute paths to two other repos on one machine, so it skips silently everywhere else. The BOM behaviour it covered is already guarded by the copilot suite in this repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
✅ Security consensus: no issues found. (reviewers: claude-opus-5, semgrep, gitleaks)
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head 39bbe6a4 · 2026-09-09T15:08Z
Comment-disposition audit — head
|
| Finding | Disposition |
|---|---|
P2 — External tests always skip (tests/test_nm_pr293_battery.py) |
Fixed in 39bbe6a. The file is deleted. It reached /Users/aakashvelusamy/github/{ai-gateway-data,setup} by absolute path, so on CI it hit skipTest and asserted nothing while showing green. The BOM behaviour it covered is guarded by TestByteOrderMark in tests/test_copilot_vscode_permissions.py, which runs everywhere. |
Confidence Score: 5/5 — "no actionable correctness, security, or repository-rule violations remain."
Consensus / security review
✅ No issues found on 39bbe6a (reviewers: claude-opus-5, semgrep, gitleaks). Clean on every prior head as well.
Cursor (Bugbot + Security Agent)
Nothing open on this head.
Blast radius checked for this round
_strip_jsonc_comments is the widest surface here — 17 call sites across 6 extractor families. The change is a leading-BOM strip, so a file without a BOM is byte-for-byte unaffected and a file with one becomes parseable where it previously raised. No existing record changes shape; records only appear where none could before.
* fix(windows): support targeted MCP scans * fix(windows): preserve targeted scan inputs and status * fix: read the copilot approvals default, and config files with a bom (#293) * fix: read the copilot session approvals default A user who sets chat.defaultConfiguration to {"approvals": "allowAll"} runs every tool call without being asked, and we reported nothing at all for them — or permission_mode default when another key happened to be present. On the permissions page they looked exactly like a locked-down user. The key is registered (approvals: manual | assisted | allowAll, default manual) and VS Code reads it straight into each new session's auto-approve state, so allowAll now maps to bypassPermissions. The name it carried before the rename, chat.agentSessions.defaultConfiguration, is read the same way for installs that still hold it. Two things deliberately left alone: assisted still prompts, so calling it a bypass would overstate the posture, and mode: autopilot picks the chat mode without touching approvals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: read json config files that carry a byte order mark A settings.json written by PowerShell's Set-Content -Encoding UTF8, or by an older Notepad, starts with a BOM. json.loads rejects it outright, so the parse returned None and the user vanished from the permissions page — the same silent blindness as an unread key, with a different trigger. The file is not a broken artifact: the editors read it happily and preserve the BOM through their own edits, so the config stays live and stays invisible to us until someone rewrites the file by hand. The strip goes in the shared JSONC entry point, so every reader that routes through it is covered at once — Copilot settings and MCP on all three platforms, Augment, and the Copilot CLI — rather than fixing the one file that surfaced it. A BOM character inside a string literal is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: cover the jsonc families and callers this change reaches The BOM strip sits in the shared JSONC entry point, so it changes behaviour for six extractor families. Only Copilot settings had a test for it, which is the one family that happened to surface the bug. Adds coverage for the other paths a reader would otherwise have to take on trust: VS Code MCP, Augment settings and Copilot CLI settings each parsing a BOM'd file, and an identity assertion that the Copilot CLI re-export really is the same function rather than a copy that could drift. Also pins three things the diff could plausibly break but does not: - a settings file written before this change yields a byte-identical record, so the two added keys introduce no drift; - a UTF-16 BOM still fails cleanly instead of being half-stripped into something that parses as the wrong document; - the packaging entry in setup/, which is a second argparse wrapping main() and the front door MDM actually runs, still fails open when unconfigured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: drop the scratch battery file from the branch It hardcodes absolute paths to two other repos on one machine, so it skips silently everywhere else. The BOM behaviour it covered is already guarded by the copilot suite in this repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * Clear the API key from the installer's environment after the scan The mcp-scan path exported UNBOUND_API_KEY and never restored it, so it outlived the scan when install.ps1 runs in a reused session and clobbered a key the caller had already set. Restore the previous value in the existing finally block. * feat: report the copilot posture vs code actually applies (#305) * fix: read the copilot session approvals default A user who sets chat.defaultConfiguration to {"approvals": "allowAll"} runs every tool call without being asked, and we reported nothing at all for them — or permission_mode default when another key happened to be present. On the permissions page they looked exactly like a locked-down user. The key is registered (approvals: manual | assisted | allowAll, default manual) and VS Code reads it straight into each new session's auto-approve state, so allowAll now maps to bypassPermissions. The name it carried before the rename, chat.agentSessions.defaultConfiguration, is read the same way for installs that still hold it. Two things deliberately left alone: assisted still prompts, so calling it a bypass would overstate the posture, and mode: autopilot picks the chat mode without touching approvals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: read json config files that carry a byte order mark A settings.json written by PowerShell's Set-Content -Encoding UTF8, or by an older Notepad, starts with a BOM. json.loads rejects it outright, so the parse returned None and the user vanished from the permissions page — the same silent blindness as an unread key, with a different trigger. The file is not a broken artifact: the editors read it happily and preserve the BOM through their own edits, so the config stays live and stays invisible to us until someone rewrites the file by hand. The strip goes in the shared JSONC entry point, so every reader that routes through it is covered at once — Copilot settings and MCP on all three platforms, Augment, and the Copilot CLI — rather than fixing the one file that surfaced it. A BOM character inside a string literal is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: read the copilot settings vs code actually applies Five mapping defects, each checked against the VS Code 1.136 registry and the public docs rather than taken from the previous key list. - Sandbox state was read from the wrong key on Windows. VS Code reads a Windows-only key there and ignores the generic one, so sandboxing was invisible on every Windows machine, including users who had turned it on. The spelling that shipped before the id was corrected is read too. - An absent sandbox key was reported as unknown. The registered default is "off", so absent means disabled; the backend only counts an explicit False, so the common case never surfaced at all. - The pre-rename chat.tools.autoApprove was treated as a global auto-approve. VS Code never migrated its value and no longer reads the key, so a leftover true grants nothing and reporting a bypass for it is a false positive. The key stays in the extracted set, so a stale value is still visible. - chat.tools.terminal.blockDetectedFileWrites and chat.agent.sandbox.allowNetwork are now captured; both govern real exposure and neither was being read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: report the posture copilot applies when nothing is configured Only 22% of devices running Copilot Chat reported any permission posture. The rest came back blank, which on the page is indistinguishable from a device that was never scanned — and it was the majority of the fleet. The cause was that a record was only built when settings.json held one of the extracted keys. A stock VS Code holds none of them, so nothing was emitted. But Copilot ships permissive: edits are auto-applied across most paths and terminal auto-approval is on, so those users have a real posture and it is not a locked-down one. A user with a Copilot config directory and no relevant keys now reports that shipped posture instead of nothing. Two boundaries are kept deliberately: - A settings file we could not read leaves the posture unknown rather than claiming the defaults, so a refused or malformed file is never silently downgraded into a clean bill of health. - No VS Code at all still reports nothing, so "never scanned" stays legible. The built-in terminal rules are not synthesised into allow_rules. They belong to the tool rather than the user, and reporting them as chosen risk is what makes a default install look configured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: log the copilot config-dir probe failure instead of swallowing it A failed stat would drop the defaults record with no trace of why, which is the one case where absence and a scan error look identical from the outside. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an untouched copilot install asks for everything, so report default The default posture was mapped to acceptEdits on the reading that Copilot ships permissive. It does not, and the reading was wrong on both halves. chat.tools.edits.autoApprove does default to {"**/*": true}, but the workbench never consumes it — the id appears once, in its own registration, and is forwarded to the agent host. It does not auto-apply in-editor chat edits. chat.tools.terminal.enableAutoApprove does default to true, but it is not the gate. The gate also requires a stored warningAccepted flag that starts false, so nothing is auto-approved until the user accepts that dialog once. A fresh install therefore asks for every tool call, which is what the picker shows and what the docs state. The baseline record still matters — it separates "scanned, nothing configured" from "never scanned" — but it must not claim an elevation the user does not have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: pin the reports this change alters for existing users This PR changes what already-scanned users report, so the tests worth having are the ones that state the direction and size of each move rather than that the code still runs. - An absent sandbox key now reads as off instead of unknown. The backend only counts an explicit false, so this newly raises Sandbox Disabled on every Copilot record. Pinned as exactly +1 against the real deriver, so a change that compounds it shows up here. - Dropping the dead legacy key lowers a score by 5, not by the bypass weight of 4. Losing the bypass also loses "No deny rules present", which only applies when the config grants capability to protect. The extra point is that dependency, and pinning it keeps the two from drifting apart silently. - The default-posture record is asserted at both boundaries: a file we could not read stays unknown, and no VS Code at all still reports nothing. - Its settings_path names a file that does not exist on disk, which is the case the backend has to tolerate; the per-user filter must still keep it for its owner and drop it for anyone else. _sandbox_enabled also moved from a staticmethod to an instance method, so one test calls it unbound through the class. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a settings file that is present but skipped is unknown, not default Enumeration keeps regular files only, so a FIFO or a directory left at settings.json was dropped before anything was read — and because only a failed parse set the unreadable flag, the user fell through to the default posture and reported a clean row. That is the boundary this feature is supposed to hold. Anyone who wanted a real bypass to go unreported could get one by leaving a pipe at that path, which is worse than the coverage gap the default posture exists to close. A present-but-unenumerated candidate now counts as unreadable, so the posture comes back unknown. A readable profile alongside it still reports, so one bad path does not blind the rest of the user's config. The FIFO test is restored to asserting no record. It had been changed to accept one on the reasoning that VS Code cannot read a pipe either, so the user really is on defaults — but that argues from what the editor does, not from what we know, and we do not know. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: distinguish an absent settings file from one we cannot see _skipped_a_present_file treated every lstat failure as "genuinely absent", so a directory we lack permission to read would have looked the same as an empty one. Only a missing file means absent now; any other error means we cannot tell, and the posture stays unknown. The outcome was already correct, because the profiles glob raises on an unreadable parent and short-circuits first. This makes the guarantee explicit rather than incidental, so reordering the candidate list cannot reopen it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: drop the scratch battery file from the branch It hardcodes an absolute path to a second repo on one machine, so it skips silently everywhere else. The behaviour it covered is already guarded by the copilot suite in this repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an unlistable profiles dir leaves the posture unknown Path.glob swallows a listing error and yields nothing, so an execute-only profiles/ looked like a user with no profiles and was reported as a clean default posture — while VS Code still loads a bypass from it by known path. Probing with scandir surfaces the case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a profile we could not inspect must not read as a clean posture Two ways a live bypass could hide behind a confident low-risk row: a profile directory the scan cannot list is now carried through to the verdict rather than being forgotten once any record exists, and a stray plain file under profiles/ no longer counts as an inspection failure — that had let one touch drop a user off the page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a file our own policy refused is unknown, not mild Containment and the read cap are our rules, not the editor's — VS Code applies a 3 MB or symlink-escaping settings.json regardless, so refusing to read one leaves the posture unknown even when another file produced a record. Malformed JSON stays the milder case, since nothing can apply it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: our parser failing is not proof the file is junk Only a syntax error means the editor cannot apply a settings file either. Any other parser failure — a recursion limit, a mangling by the strip passes — is a file we did not manage to read, so it now leaves the posture unknown instead of letting a milder record win. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a settings file we cannot parse leaves the posture unknown Failing to parse never proved the editor would fail too — it recovers what it can from a broken settings file, and a byte order mark alone used to defeat this parser while VS Code read on. Every parse failure now fails closed, which also removes the split that tried to tell the harmless kind apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: trim comments to the rule, not the argument Six rounds of review left several comments retelling how a decision was reached. A maintainer needs the rule as it stands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: extract the copilot settings that take a guard away (#309) * feat: extract the copilot settings that take a guard away We read the settings that say auto-approve is on. We were not reading the ones that say the safety rails are gone, and several of those ship permissive, so their absence from a settings file never meant the protection was in place. VS Code registers 158 chat settings; most are cosmetic and stay excluded. These change what the agent may do: - ignoreDefaultAutoApproveRules discards VS Code's own deny rules, the net that blocks find -delete and sed -e. - autoApproveWorkspaceNpmScripts ships true, so a script defined by a cloned repository can run without a prompt. - Three sandbox escape hatches ship true: running commands outside the sandbox, auto-approving inside it, and retrying a blocked command with network on. - The plugin and extension-tool surface decides whose code the agent can run at all. - maxRequests, autoReply and the autopilot toggle bound how far it runs unattended. - Anonymous access, approved organisations and session sync decide who may use it and what leaves the machine. chat.editing.autoAcceptDelay is mapped rather than only captured: a non-zero delay applies edits on a timer with no prompt, which is acceptEdits by any other name. Zero is the shipped default and still prompts. chat.agentHost.otel.headers is deliberately left out. It carries request headers that routinely hold credentials, and it says nothing about the agent's posture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: extract the copilot extension's own security settings VS Code registers 158 chat settings. The Copilot extension contributes 191 more of its own, and we were reading none of them. Its model and prompt experiment flags are noise and stay excluded; these move data or grant capability. - codeSearchExternalIngest ships true: workspace code is sent out for indexing. enableCodeSearch, the ADO endpoint override and the OTEL endpoint decide where it goes, and an endpoint is something an attacker would rather set than steal. - The GitHub MCP server has its own enable, lockdown, readonly and toolset governance, none of which we could see. - installExtensionSkill lets the agent install extensions, which is a code-execution surface rather than a preference. - backgroundAgent and cloudAgent ship true and decide where the agent runs; cli.sandbox decides whether it is contained when it runs there. - The execution and search subagents have their own enable flags and tool-call limits, and organization agents and instructions are org-managed inputs to every session. - currentEditorContext ships true, so the open editor's contents travel with the request. A test pins the exclusion as well as the inclusion: the prompt and model flags must stay out, or the record dilutes into noise and stops being readable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the settings from every profile, not just the winning one Rules were already unioned across a user's profiles, but raw_settings came from whichever profile ranked riskiest. A guard switched off in another profile was therefore dropped from the report — which defeats extracting those keys at all, since ignoreDefaultAutoApproveRules in a second profile is still in force there. Settings are now unioned as well, with the winning profile taking precedence where both set the same key, so the reported posture and the path it is attributed to stay consistent. Found by the test battery for this PR, and independently by review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the setting, drop the secret inside it Two of the newly captured settings are posture signal whose value can carry a credential, which makes collecting them verbatim a way to ship secrets to the backend rather than a way to see risk. A terminal profile carries an env map that routinely holds API keys. The names are the signal — they show what the agent's shell is given — so those are kept and the values are not. The OTEL and code-search endpoint overrides are URLs, and a URL can hold credentials in its userinfo or query. The destination is the finding, so scheme, host, port and path stay; userinfo and query go. This is the rule already applied to chat.agentHost.otel.headers, which was excluded outright for the same reason. It should have been applied to these at the same time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: redact an endpoint whether or not it has a scheme The redaction only ran when the value contained "://", so collector.internal:4318/v1?api-key=… went to the backend untouched — a scheme-less endpoint is exactly what someone writes in this setting, and it carries a credential just as readily as a fully qualified URL. The authority is now parsed either way, so userinfo and the query go in both shapes and the host still survives, which is the part worth reporting. A value whose port will not parse is replaced outright rather than passed through, so the error path cannot become the leak. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: fold the credential-redaction guards into the copilot suite The scratch battery file is dropped; the guards that cover shipped behaviour move into the real suite so the redaction cannot regress unnoticed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: strip credentials from plugin marketplace settings A private marketplace is a git remote, so it carries a token the same way the endpoints already handled do, and a strict entry can carry auth headers. Plain refs keep their form so the evidence stays readable. Also brackets an IPv6 host, which was ambiguous once a port was appended. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep a marketplace ref the url parser cannot read An scp-style remote (git@host:owner/repo.git) failed to parse and was being discarded whole, losing the identity of a configured code source. The userinfo and query are now cut textually on that path, and nested values inside an entry get the same treatment as top-level ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: redact what carries a credential, not what merely looks like one Three ways the previous pass got the boundary wrong. An npm scope and a wildcard pattern were being rewritten as URLs, corrupting the identity of a configured marketplace. A token carried in an endpoint's path survived, so only its first segment is kept now. And an unencoded question mark inside userinfo cut the value before the credential rather than after it. Marketplace shapes are told apart by their setting: two hold remotes directly, the strict one holds entry objects where only a named field is a remote. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: redact a marketplace credential whatever field it arrives in The entry schema is open, so keying redaction off the single name "url" left a remote under any other name shipping its token. Identity fields are now the named exception and everything else is checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a marketplace path is identity, not a url A local plugin directory is named by path, so a question mark in one is part of the name rather than a query string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an identity field must not smuggle a credential Exempting a field by name kept its shape intact but also exempted it at every depth, so a remote written under one shipped its token. Those fields now keep punctuation that belongs to them while still losing userinfo, and auth headers are reduced whatever shape they arrive in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: redact a terminal env whatever shape it arrives in settings.json is user-authored and never schema-checked before we read it, so env can arrive as a list and the profile itself as something other than an object. Auth headers already handled that; env now does too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: vishnu <vishnuvinod072@gmail.com> Co-authored-by: Aakash Velusamy <aakashvpsgtech@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Vishnu <79318686+zeus-12@users.noreply.github.com>
* fix: decode space-encoded discovered project paths at the dispatcher VS Code / Copilot-family tools report a project folder with spaces as a percent-encoded path (e.g. C:\Users\...\FHA%20Asset%20Optimizer), which landed on ai_tool_project.project_path with the escape. Normalise at process_single_tool — the one chokepoint every tool routes through — so it is fixed for all tools (Copilot CLI/Chat, JetBrains, Cursor, ...) regardless of which produced the path. Decodes only %20 (spaces), so a path with a literal % or an encoded slash is left intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Windows installer: download the repository without Git (#260) * Windows installer: fall back to an archive download when Git is missing install.sh has downloaded the repository as a tarball whenever git is absent or non-functional since Feb 2026 (978c1a0). install.ps1 never got the same fallback: without Git it prints "Git is not installed." and exits, so on Windows fleets deployed through MDM the four hook installs succeed and the discovery step fails on every daily run. Get-Repository now tries git first and, when git is missing or the clone fails, downloads the branch archive from GitHub with Invoke-WebRequest and expands it with Expand-Archive (both built into Windows PowerShell 5+). Git is no longer a prerequisite for the discovery scan on Windows, which matches macOS and Linux. Also makes the git path check exit codes instead of relying on try/catch, which never fires for a failing native command. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: exercise install.ps1's archive fallback on the Windows CI runner Runs the installer's download functions in Windows PowerShell with git stripped from PATH and asserts the repository still lands, via the GitHub archive. Skips off Windows and when github.com is unreachable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: keep the PATH helper check portable across pathsep values * Windows installer: report why the archive download failed --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit e603604) * fix: normalize every reported path, not just top-level project entries Project paths reach analytics from more than the top-level project entry: the per-rule/per-skill/per-MCP project_path and nested path keys can each carry a percent-encoded folder name (VS Code / Copilot-family). Walk the whole result at the process_single_tool chokepoint and decode %20 on every path/project_path, so no encoded name escapes from any source. Still %20-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Revert "Merge pull request #259 from websentry-ai/fix/decode-project-path-at-dispatcher" This reverts commit 8878568, reversing changes made to d0540db. * Release-10-09-26-v2 (#317) * fix(windows): support targeted MCP scans * fix(windows): preserve targeted scan inputs and status * fix: read the copilot approvals default, and config files with a bom (#293) * fix: read the copilot session approvals default A user who sets chat.defaultConfiguration to {"approvals": "allowAll"} runs every tool call without being asked, and we reported nothing at all for them — or permission_mode default when another key happened to be present. On the permissions page they looked exactly like a locked-down user. The key is registered (approvals: manual | assisted | allowAll, default manual) and VS Code reads it straight into each new session's auto-approve state, so allowAll now maps to bypassPermissions. The name it carried before the rename, chat.agentSessions.defaultConfiguration, is read the same way for installs that still hold it. Two things deliberately left alone: assisted still prompts, so calling it a bypass would overstate the posture, and mode: autopilot picks the chat mode without touching approvals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: read json config files that carry a byte order mark A settings.json written by PowerShell's Set-Content -Encoding UTF8, or by an older Notepad, starts with a BOM. json.loads rejects it outright, so the parse returned None and the user vanished from the permissions page — the same silent blindness as an unread key, with a different trigger. The file is not a broken artifact: the editors read it happily and preserve the BOM through their own edits, so the config stays live and stays invisible to us until someone rewrites the file by hand. The strip goes in the shared JSONC entry point, so every reader that routes through it is covered at once — Copilot settings and MCP on all three platforms, Augment, and the Copilot CLI — rather than fixing the one file that surfaced it. A BOM character inside a string literal is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: cover the jsonc families and callers this change reaches The BOM strip sits in the shared JSONC entry point, so it changes behaviour for six extractor families. Only Copilot settings had a test for it, which is the one family that happened to surface the bug. Adds coverage for the other paths a reader would otherwise have to take on trust: VS Code MCP, Augment settings and Copilot CLI settings each parsing a BOM'd file, and an identity assertion that the Copilot CLI re-export really is the same function rather than a copy that could drift. Also pins three things the diff could plausibly break but does not: - a settings file written before this change yields a byte-identical record, so the two added keys introduce no drift; - a UTF-16 BOM still fails cleanly instead of being half-stripped into something that parses as the wrong document; - the packaging entry in setup/, which is a second argparse wrapping main() and the front door MDM actually runs, still fails open when unconfigured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: drop the scratch battery file from the branch It hardcodes absolute paths to two other repos on one machine, so it skips silently everywhere else. The BOM behaviour it covered is already guarded by the copilot suite in this repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * Clear the API key from the installer's environment after the scan The mcp-scan path exported UNBOUND_API_KEY and never restored it, so it outlived the scan when install.ps1 runs in a reused session and clobbered a key the caller had already set. Restore the previous value in the existing finally block. * feat: report the copilot posture vs code actually applies (#305) * fix: read the copilot session approvals default A user who sets chat.defaultConfiguration to {"approvals": "allowAll"} runs every tool call without being asked, and we reported nothing at all for them — or permission_mode default when another key happened to be present. On the permissions page they looked exactly like a locked-down user. The key is registered (approvals: manual | assisted | allowAll, default manual) and VS Code reads it straight into each new session's auto-approve state, so allowAll now maps to bypassPermissions. The name it carried before the rename, chat.agentSessions.defaultConfiguration, is read the same way for installs that still hold it. Two things deliberately left alone: assisted still prompts, so calling it a bypass would overstate the posture, and mode: autopilot picks the chat mode without touching approvals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: read json config files that carry a byte order mark A settings.json written by PowerShell's Set-Content -Encoding UTF8, or by an older Notepad, starts with a BOM. json.loads rejects it outright, so the parse returned None and the user vanished from the permissions page — the same silent blindness as an unread key, with a different trigger. The file is not a broken artifact: the editors read it happily and preserve the BOM through their own edits, so the config stays live and stays invisible to us until someone rewrites the file by hand. The strip goes in the shared JSONC entry point, so every reader that routes through it is covered at once — Copilot settings and MCP on all three platforms, Augment, and the Copilot CLI — rather than fixing the one file that surfaced it. A BOM character inside a string literal is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: read the copilot settings vs code actually applies Five mapping defects, each checked against the VS Code 1.136 registry and the public docs rather than taken from the previous key list. - Sandbox state was read from the wrong key on Windows. VS Code reads a Windows-only key there and ignores the generic one, so sandboxing was invisible on every Windows machine, including users who had turned it on. The spelling that shipped before the id was corrected is read too. - An absent sandbox key was reported as unknown. The registered default is "off", so absent means disabled; the backend only counts an explicit False, so the common case never surfaced at all. - The pre-rename chat.tools.autoApprove was treated as a global auto-approve. VS Code never migrated its value and no longer reads the key, so a leftover true grants nothing and reporting a bypass for it is a false positive. The key stays in the extracted set, so a stale value is still visible. - chat.tools.terminal.blockDetectedFileWrites and chat.agent.sandbox.allowNetwork are now captured; both govern real exposure and neither was being read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: report the posture copilot applies when nothing is configured Only 22% of devices running Copilot Chat reported any permission posture. The rest came back blank, which on the page is indistinguishable from a device that was never scanned — and it was the majority of the fleet. The cause was that a record was only built when settings.json held one of the extracted keys. A stock VS Code holds none of them, so nothing was emitted. But Copilot ships permissive: edits are auto-applied across most paths and terminal auto-approval is on, so those users have a real posture and it is not a locked-down one. A user with a Copilot config directory and no relevant keys now reports that shipped posture instead of nothing. Two boundaries are kept deliberately: - A settings file we could not read leaves the posture unknown rather than claiming the defaults, so a refused or malformed file is never silently downgraded into a clean bill of health. - No VS Code at all still reports nothing, so "never scanned" stays legible. The built-in terminal rules are not synthesised into allow_rules. They belong to the tool rather than the user, and reporting them as chosen risk is what makes a default install look configured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: log the copilot config-dir probe failure instead of swallowing it A failed stat would drop the defaults record with no trace of why, which is the one case where absence and a scan error look identical from the outside. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an untouched copilot install asks for everything, so report default The default posture was mapped to acceptEdits on the reading that Copilot ships permissive. It does not, and the reading was wrong on both halves. chat.tools.edits.autoApprove does default to {"**/*": true}, but the workbench never consumes it — the id appears once, in its own registration, and is forwarded to the agent host. It does not auto-apply in-editor chat edits. chat.tools.terminal.enableAutoApprove does default to true, but it is not the gate. The gate also requires a stored warningAccepted flag that starts false, so nothing is auto-approved until the user accepts that dialog once. A fresh install therefore asks for every tool call, which is what the picker shows and what the docs state. The baseline record still matters — it separates "scanned, nothing configured" from "never scanned" — but it must not claim an elevation the user does not have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: pin the reports this change alters for existing users This PR changes what already-scanned users report, so the tests worth having are the ones that state the direction and size of each move rather than that the code still runs. - An absent sandbox key now reads as off instead of unknown. The backend only counts an explicit false, so this newly raises Sandbox Disabled on every Copilot record. Pinned as exactly +1 against the real deriver, so a change that compounds it shows up here. - Dropping the dead legacy key lowers a score by 5, not by the bypass weight of 4. Losing the bypass also loses "No deny rules present", which only applies when the config grants capability to protect. The extra point is that dependency, and pinning it keeps the two from drifting apart silently. - The default-posture record is asserted at both boundaries: a file we could not read stays unknown, and no VS Code at all still reports nothing. - Its settings_path names a file that does not exist on disk, which is the case the backend has to tolerate; the per-user filter must still keep it for its owner and drop it for anyone else. _sandbox_enabled also moved from a staticmethod to an instance method, so one test calls it unbound through the class. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a settings file that is present but skipped is unknown, not default Enumeration keeps regular files only, so a FIFO or a directory left at settings.json was dropped before anything was read — and because only a failed parse set the unreadable flag, the user fell through to the default posture and reported a clean row. That is the boundary this feature is supposed to hold. Anyone who wanted a real bypass to go unreported could get one by leaving a pipe at that path, which is worse than the coverage gap the default posture exists to close. A present-but-unenumerated candidate now counts as unreadable, so the posture comes back unknown. A readable profile alongside it still reports, so one bad path does not blind the rest of the user's config. The FIFO test is restored to asserting no record. It had been changed to accept one on the reasoning that VS Code cannot read a pipe either, so the user really is on defaults — but that argues from what the editor does, not from what we know, and we do not know. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: distinguish an absent settings file from one we cannot see _skipped_a_present_file treated every lstat failure as "genuinely absent", so a directory we lack permission to read would have looked the same as an empty one. Only a missing file means absent now; any other error means we cannot tell, and the posture stays unknown. The outcome was already correct, because the profiles glob raises on an unreadable parent and short-circuits first. This makes the guarantee explicit rather than incidental, so reordering the candidate list cannot reopen it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: drop the scratch battery file from the branch It hardcodes an absolute path to a second repo on one machine, so it skips silently everywhere else. The behaviour it covered is already guarded by the copilot suite in this repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an unlistable profiles dir leaves the posture unknown Path.glob swallows a listing error and yields nothing, so an execute-only profiles/ looked like a user with no profiles and was reported as a clean default posture — while VS Code still loads a bypass from it by known path. Probing with scandir surfaces the case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a profile we could not inspect must not read as a clean posture Two ways a live bypass could hide behind a confident low-risk row: a profile directory the scan cannot list is now carried through to the verdict rather than being forgotten once any record exists, and a stray plain file under profiles/ no longer counts as an inspection failure — that had let one touch drop a user off the page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a file our own policy refused is unknown, not mild Containment and the read cap are our rules, not the editor's — VS Code applies a 3 MB or symlink-escaping settings.json regardless, so refusing to read one leaves the posture unknown even when another file produced a record. Malformed JSON stays the milder case, since nothing can apply it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: our parser failing is not proof the file is junk Only a syntax error means the editor cannot apply a settings file either. Any other parser failure — a recursion limit, a mangling by the strip passes — is a file we did not manage to read, so it now leaves the posture unknown instead of letting a milder record win. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a settings file we cannot parse leaves the posture unknown Failing to parse never proved the editor would fail too — it recovers what it can from a broken settings file, and a byte order mark alone used to defeat this parser while VS Code read on. Every parse failure now fails closed, which also removes the split that tried to tell the harmless kind apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: trim comments to the rule, not the argument Six rounds of review left several comments retelling how a decision was reached. A maintainer needs the rule as it stands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: extract the copilot settings that take a guard away (#309) * feat: extract the copilot settings that take a guard away We read the settings that say auto-approve is on. We were not reading the ones that say the safety rails are gone, and several of those ship permissive, so their absence from a settings file never meant the protection was in place. VS Code registers 158 chat settings; most are cosmetic and stay excluded. These change what the agent may do: - ignoreDefaultAutoApproveRules discards VS Code's own deny rules, the net that blocks find -delete and sed -e. - autoApproveWorkspaceNpmScripts ships true, so a script defined by a cloned repository can run without a prompt. - Three sandbox escape hatches ship true: running commands outside the sandbox, auto-approving inside it, and retrying a blocked command with network on. - The plugin and extension-tool surface decides whose code the agent can run at all. - maxRequests, autoReply and the autopilot toggle bound how far it runs unattended. - Anonymous access, approved organisations and session sync decide who may use it and what leaves the machine. chat.editing.autoAcceptDelay is mapped rather than only captured: a non-zero delay applies edits on a timer with no prompt, which is acceptEdits by any other name. Zero is the shipped default and still prompts. chat.agentHost.otel.headers is deliberately left out. It carries request headers that routinely hold credentials, and it says nothing about the agent's posture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: extract the copilot extension's own security settings VS Code registers 158 chat settings. The Copilot extension contributes 191 more of its own, and we were reading none of them. Its model and prompt experiment flags are noise and stay excluded; these move data or grant capability. - codeSearchExternalIngest ships true: workspace code is sent out for indexing. enableCodeSearch, the ADO endpoint override and the OTEL endpoint decide where it goes, and an endpoint is something an attacker would rather set than steal. - The GitHub MCP server has its own enable, lockdown, readonly and toolset governance, none of which we could see. - installExtensionSkill lets the agent install extensions, which is a code-execution surface rather than a preference. - backgroundAgent and cloudAgent ship true and decide where the agent runs; cli.sandbox decides whether it is contained when it runs there. - The execution and search subagents have their own enable flags and tool-call limits, and organization agents and instructions are org-managed inputs to every session. - currentEditorContext ships true, so the open editor's contents travel with the request. A test pins the exclusion as well as the inclusion: the prompt and model flags must stay out, or the record dilutes into noise and stops being readable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the settings from every profile, not just the winning one Rules were already unioned across a user's profiles, but raw_settings came from whichever profile ranked riskiest. A guard switched off in another profile was therefore dropped from the report — which defeats extracting those keys at all, since ignoreDefaultAutoApproveRules in a second profile is still in force there. Settings are now unioned as well, with the winning profile taking precedence where both set the same key, so the reported posture and the path it is attributed to stay consistent. Found by the test battery for this PR, and independently by review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the setting, drop the secret inside it Two of the newly captured settings are posture signal whose value can carry a credential, which makes collecting them verbatim a way to ship secrets to the backend rather than a way to see risk. A terminal profile carries an env map that routinely holds API keys. The names are the signal — they show what the agent's shell is given — so those are kept and the values are not. The OTEL and code-search endpoint overrides are URLs, and a URL can hold credentials in its userinfo or query. The destination is the finding, so scheme, host, port and path stay; userinfo and query go. This is the rule already applied to chat.agentHost.otel.headers, which was excluded outright for the same reason. It should have been applied to these at the same time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: redact an endpoint whether or not it has a scheme The redaction only ran when the value contained "://", so collector.internal:4318/v1?api-key=… went to the backend untouched — a scheme-less endpoint is exactly what someone writes in this setting, and it carries a credential just as readily as a fully qualified URL. The authority is now parsed either way, so userinfo and the query go in both shapes and the host still survives, which is the part worth reporting. A value whose port will not parse is replaced outright rather than passed through, so the error path cannot become the leak. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: fold the credential-redaction guards into the copilot suite The scratch battery file is dropped; the guards that cover shipped behaviour move into the real suite so the redaction cannot regress unnoticed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: strip credentials from plugin marketplace settings A private marketplace is a git remote, so it carries a token the same way the endpoints already handled do, and a strict entry can carry auth headers. Plain refs keep their form so the evidence stays readable. Also brackets an IPv6 host, which was ambiguous once a port was appended. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep a marketplace ref the url parser cannot read An scp-style remote (git@host:owner/repo.git) failed to parse and was being discarded whole, losing the identity of a configured code source. The userinfo and query are now cut textually on that path, and nested values inside an entry get the same treatment as top-level ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: redact what carries a credential, not what merely looks like one Three ways the previous pass got the boundary wrong. An npm scope and a wildcard pattern were being rewritten as URLs, corrupting the identity of a configured marketplace. A token carried in an endpoint's path survived, so only its first segment is kept now. And an unencoded question mark inside userinfo cut the value before the credential rather than after it. Marketplace shapes are told apart by their setting: two hold remotes directly, the strict one holds entry objects where only a named field is a remote. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: redact a marketplace credential whatever field it arrives in The entry schema is open, so keying redaction off the single name "url" left a remote under any other name shipping its token. Identity fields are now the named exception and everything else is checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a marketplace path is identity, not a url A local plugin directory is named by path, so a question mark in one is part of the name rather than a query string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an identity field must not smuggle a credential Exempting a field by name kept its shape intact but also exempted it at every depth, so a remote written under one shipped its token. Those fields now keep punctuation that belongs to them while still losing userinfo, and auth headers are reduced whatever shape they arrive in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: redact a terminal env whatever shape it arrives in settings.json is user-authored and never schema-checked before we read it, so env can arrive as a list and the profile itself as something other than an object. Auth headers already handled that; env now does too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: vishnu <vishnuvinod072@gmail.com> Co-authored-by: Aakash Velusamy <aakashvpsgtech@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Vishnu <79318686+zeus-12@users.noreply.github.com> --------- Co-authored-by: NandaPranesh <106886030+anonpran@users.noreply.github.com> Co-authored-by: Aakash Velusamy <aakashvpsgtech@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Vignesh Subbiah <51325334+vigneshsubbiah16@users.noreply.github.com> Co-authored-by: rajaramsrinivas <raj@unboundsecurity.ai> Co-authored-by: pugazhendhi-m <132246623+pugazhendhi-m@users.noreply.github.com> Co-authored-by: Vishnu <79318686+zeus-12@users.noreply.github.com> Co-authored-by: gowshik450526511 <66863804+gowshik450526511@users.noreply.github.com> Co-authored-by: vishnu <vishnuvinod072@gmail.com> Co-authored-by: audit <audit@local>
…ndex (#378) * fix: decode space-encoded discovered project paths at the dispatcher VS Code / Copilot-family tools report a project folder with spaces as a percent-encoded path (e.g. C:\Users\...\FHA%20Asset%20Optimizer), which landed on ai_tool_project.project_path with the escape. Normalise at process_single_tool — the one chokepoint every tool routes through — so it is fixed for all tools (Copilot CLI/Chat, JetBrains, Cursor, ...) regardless of which produced the path. Decodes only %20 (spaces), so a path with a literal % or an encoded slash is left intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Windows installer: download the repository without Git (#260) * Windows installer: fall back to an archive download when Git is missing install.sh has downloaded the repository as a tarball whenever git is absent or non-functional since Feb 2026 (978c1a0). install.ps1 never got the same fallback: without Git it prints "Git is not installed." and exits, so on Windows fleets deployed through MDM the four hook installs succeed and the discovery step fails on every daily run. Get-Repository now tries git first and, when git is missing or the clone fails, downloads the branch archive from GitHub with Invoke-WebRequest and expands it with Expand-Archive (both built into Windows PowerShell 5+). Git is no longer a prerequisite for the discovery scan on Windows, which matches macOS and Linux. Also makes the git path check exit codes instead of relying on try/catch, which never fires for a failing native command. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: exercise install.ps1's archive fallback on the Windows CI runner Runs the installer's download functions in Windows PowerShell with git stripped from PATH and asserts the repository still lands, via the GitHub archive. Skips off Windows and when github.com is unreachable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test: keep the PATH helper check portable across pathsep values * Windows installer: report why the archive download failed --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit e603604) * fix: normalize every reported path, not just top-level project entries Project paths reach analytics from more than the top-level project entry: the per-rule/per-skill/per-MCP project_path and nested path keys can each carry a percent-encoded folder name (VS Code / Copilot-family). Walk the whole result at the process_single_tool chokepoint and decode %20 on every path/project_path, so no encoded name escapes from any source. Still %20-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Revert "Merge pull request #259 from websentry-ai/fix/decode-project-path-at-dispatcher" This reverts commit 8878568, reversing changes made to d0540db. * Release-10-09-26-v2 (#317) * fix(windows): support targeted MCP scans * fix(windows): preserve targeted scan inputs and status * fix: read the copilot approvals default, and config files with a bom (#293) * fix: read the copilot session approvals default A user who sets chat.defaultConfiguration to {"approvals": "allowAll"} runs every tool call without being asked, and we reported nothing at all for them — or permission_mode default when another key happened to be present. On the permissions page they looked exactly like a locked-down user. The key is registered (approvals: manual | assisted | allowAll, default manual) and VS Code reads it straight into each new session's auto-approve state, so allowAll now maps to bypassPermissions. The name it carried before the rename, chat.agentSessions.defaultConfiguration, is read the same way for installs that still hold it. Two things deliberately left alone: assisted still prompts, so calling it a bypass would overstate the posture, and mode: autopilot picks the chat mode without touching approvals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: read json config files that carry a byte order mark A settings.json written by PowerShell's Set-Content -Encoding UTF8, or by an older Notepad, starts with a BOM. json.loads rejects it outright, so the parse returned None and the user vanished from the permissions page — the same silent blindness as an unread key, with a different trigger. The file is not a broken artifact: the editors read it happily and preserve the BOM through their own edits, so the config stays live and stays invisible to us until someone rewrites the file by hand. The strip goes in the shared JSONC entry point, so every reader that routes through it is covered at once — Copilot settings and MCP on all three platforms, Augment, and the Copilot CLI — rather than fixing the one file that surfaced it. A BOM character inside a string literal is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: cover the jsonc families and callers this change reaches The BOM strip sits in the shared JSONC entry point, so it changes behaviour for six extractor families. Only Copilot settings had a test for it, which is the one family that happened to surface the bug. Adds coverage for the other paths a reader would otherwise have to take on trust: VS Code MCP, Augment settings and Copilot CLI settings each parsing a BOM'd file, and an identity assertion that the Copilot CLI re-export really is the same function rather than a copy that could drift. Also pins three things the diff could plausibly break but does not: - a settings file written before this change yields a byte-identical record, so the two added keys introduce no drift; - a UTF-16 BOM still fails cleanly instead of being half-stripped into something that parses as the wrong document; - the packaging entry in setup/, which is a second argparse wrapping main() and the front door MDM actually runs, still fails open when unconfigured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: drop the scratch battery file from the branch It hardcodes absolute paths to two other repos on one machine, so it skips silently everywhere else. The BOM behaviour it covered is already guarded by the copilot suite in this repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * Clear the API key from the installer's environment after the scan The mcp-scan path exported UNBOUND_API_KEY and never restored it, so it outlived the scan when install.ps1 runs in a reused session and clobbered a key the caller had already set. Restore the previous value in the existing finally block. * feat: report the copilot posture vs code actually applies (#305) * fix: read the copilot session approvals default A user who sets chat.defaultConfiguration to {"approvals": "allowAll"} runs every tool call without being asked, and we reported nothing at all for them — or permission_mode default when another key happened to be present. On the permissions page they looked exactly like a locked-down user. The key is registered (approvals: manual | assisted | allowAll, default manual) and VS Code reads it straight into each new session's auto-approve state, so allowAll now maps to bypassPermissions. The name it carried before the rename, chat.agentSessions.defaultConfiguration, is read the same way for installs that still hold it. Two things deliberately left alone: assisted still prompts, so calling it a bypass would overstate the posture, and mode: autopilot picks the chat mode without touching approvals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: read json config files that carry a byte order mark A settings.json written by PowerShell's Set-Content -Encoding UTF8, or by an older Notepad, starts with a BOM. json.loads rejects it outright, so the parse returned None and the user vanished from the permissions page — the same silent blindness as an unread key, with a different trigger. The file is not a broken artifact: the editors read it happily and preserve the BOM through their own edits, so the config stays live and stays invisible to us until someone rewrites the file by hand. The strip goes in the shared JSONC entry point, so every reader that routes through it is covered at once — Copilot settings and MCP on all three platforms, Augment, and the Copilot CLI — rather than fixing the one file that surfaced it. A BOM character inside a string literal is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: read the copilot settings vs code actually applies Five mapping defects, each checked against the VS Code 1.136 registry and the public docs rather than taken from the previous key list. - Sandbox state was read from the wrong key on Windows. VS Code reads a Windows-only key there and ignores the generic one, so sandboxing was invisible on every Windows machine, including users who had turned it on. The spelling that shipped before the id was corrected is read too. - An absent sandbox key was reported as unknown. The registered default is "off", so absent means disabled; the backend only counts an explicit False, so the common case never surfaced at all. - The pre-rename chat.tools.autoApprove was treated as a global auto-approve. VS Code never migrated its value and no longer reads the key, so a leftover true grants nothing and reporting a bypass for it is a false positive. The key stays in the extracted set, so a stale value is still visible. - chat.tools.terminal.blockDetectedFileWrites and chat.agent.sandbox.allowNetwork are now captured; both govern real exposure and neither was being read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: report the posture copilot applies when nothing is configured Only 22% of devices running Copilot Chat reported any permission posture. The rest came back blank, which on the page is indistinguishable from a device that was never scanned — and it was the majority of the fleet. The cause was that a record was only built when settings.json held one of the extracted keys. A stock VS Code holds none of them, so nothing was emitted. But Copilot ships permissive: edits are auto-applied across most paths and terminal auto-approval is on, so those users have a real posture and it is not a locked-down one. A user with a Copilot config directory and no relevant keys now reports that shipped posture instead of nothing. Two boundaries are kept deliberately: - A settings file we could not read leaves the posture unknown rather than claiming the defaults, so a refused or malformed file is never silently downgraded into a clean bill of health. - No VS Code at all still reports nothing, so "never scanned" stays legible. The built-in terminal rules are not synthesised into allow_rules. They belong to the tool rather than the user, and reporting them as chosen risk is what makes a default install look configured. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: log the copilot config-dir probe failure instead of swallowing it A failed stat would drop the defaults record with no trace of why, which is the one case where absence and a scan error look identical from the outside. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an untouched copilot install asks for everything, so report default The default posture was mapped to acceptEdits on the reading that Copilot ships permissive. It does not, and the reading was wrong on both halves. chat.tools.edits.autoApprove does default to {"**/*": true}, but the workbench never consumes it — the id appears once, in its own registration, and is forwarded to the agent host. It does not auto-apply in-editor chat edits. chat.tools.terminal.enableAutoApprove does default to true, but it is not the gate. The gate also requires a stored warningAccepted flag that starts false, so nothing is auto-approved until the user accepts that dialog once. A fresh install therefore asks for every tool call, which is what the picker shows and what the docs state. The baseline record still matters — it separates "scanned, nothing configured" from "never scanned" — but it must not claim an elevation the user does not have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: pin the reports this change alters for existing users This PR changes what already-scanned users report, so the tests worth having are the ones that state the direction and size of each move rather than that the code still runs. - An absent sandbox key now reads as off instead of unknown. The backend only counts an explicit false, so this newly raises Sandbox Disabled on every Copilot record. Pinned as exactly +1 against the real deriver, so a change that compounds it shows up here. - Dropping the dead legacy key lowers a score by 5, not by the bypass weight of 4. Losing the bypass also loses "No deny rules present", which only applies when the config grants capability to protect. The extra point is that dependency, and pinning it keeps the two from drifting apart silently. - The default-posture record is asserted at both boundaries: a file we could not read stays unknown, and no VS Code at all still reports nothing. - Its settings_path names a file that does not exist on disk, which is the case the backend has to tolerate; the per-user filter must still keep it for its owner and drop it for anyone else. _sandbox_enabled also moved from a staticmethod to an instance method, so one test calls it unbound through the class. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a settings file that is present but skipped is unknown, not default Enumeration keeps regular files only, so a FIFO or a directory left at settings.json was dropped before anything was read — and because only a failed parse set the unreadable flag, the user fell through to the default posture and reported a clean row. That is the boundary this feature is supposed to hold. Anyone who wanted a real bypass to go unreported could get one by leaving a pipe at that path, which is worse than the coverage gap the default posture exists to close. A present-but-unenumerated candidate now counts as unreadable, so the posture comes back unknown. A readable profile alongside it still reports, so one bad path does not blind the rest of the user's config. The FIFO test is restored to asserting no record. It had been changed to accept one on the reasoning that VS Code cannot read a pipe either, so the user really is on defaults — but that argues from what the editor does, not from what we know, and we do not know. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: distinguish an absent settings file from one we cannot see _skipped_a_present_file treated every lstat failure as "genuinely absent", so a directory we lack permission to read would have looked the same as an empty one. Only a missing file means absent now; any other error means we cannot tell, and the posture stays unknown. The outcome was already correct, because the profiles glob raises on an unreadable parent and short-circuits first. This makes the guarantee explicit rather than incidental, so reordering the candidate list cannot reopen it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: drop the scratch battery file from the branch It hardcodes an absolute path to a second repo on one machine, so it skips silently everywhere else. The behaviour it covered is already guarded by the copilot suite in this repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an unlistable profiles dir leaves the posture unknown Path.glob swallows a listing error and yields nothing, so an execute-only profiles/ looked like a user with no profiles and was reported as a clean default posture — while VS Code still loads a bypass from it by known path. Probing with scandir surfaces the case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a profile we could not inspect must not read as a clean posture Two ways a live bypass could hide behind a confident low-risk row: a profile directory the scan cannot list is now carried through to the verdict rather than being forgotten once any record exists, and a stray plain file under profiles/ no longer counts as an inspection failure — that had let one touch drop a user off the page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a file our own policy refused is unknown, not mild Containment and the read cap are our rules, not the editor's — VS Code applies a 3 MB or symlink-escaping settings.json regardless, so refusing to read one leaves the posture unknown even when another file produced a record. Malformed JSON stays the milder case, since nothing can apply it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: our parser failing is not proof the file is junk Only a syntax error means the editor cannot apply a settings file either. Any other parser failure — a recursion limit, a mangling by the strip passes — is a file we did not manage to read, so it now leaves the posture unknown instead of letting a milder record win. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a settings file we cannot parse leaves the posture unknown Failing to parse never proved the editor would fail too — it recovers what it can from a broken settings file, and a byte order mark alone used to defeat this parser while VS Code read on. Every parse failure now fails closed, which also removes the split that tried to tell the harmless kind apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: trim comments to the rule, not the argument Six rounds of review left several comments retelling how a decision was reached. A maintainer needs the rule as it stands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: extract the copilot settings that take a guard away (#309) * feat: extract the copilot settings that take a guard away We read the settings that say auto-approve is on. We were not reading the ones that say the safety rails are gone, and several of those ship permissive, so their absence from a settings file never meant the protection was in place. VS Code registers 158 chat settings; most are cosmetic and stay excluded. These change what the agent may do: - ignoreDefaultAutoApproveRules discards VS Code's own deny rules, the net that blocks find -delete and sed -e. - autoApproveWorkspaceNpmScripts ships true, so a script defined by a cloned repository can run without a prompt. - Three sandbox escape hatches ship true: running commands outside the sandbox, auto-approving inside it, and retrying a blocked command with network on. - The plugin and extension-tool surface decides whose code the agent can run at all. - maxRequests, autoReply and the autopilot toggle bound how far it runs unattended. - Anonymous access, approved organisations and session sync decide who may use it and what leaves the machine. chat.editing.autoAcceptDelay is mapped rather than only captured: a non-zero delay applies edits on a timer with no prompt, which is acceptEdits by any other name. Zero is the shipped default and still prompts. chat.agentHost.otel.headers is deliberately left out. It carries request headers that routinely hold credentials, and it says nothing about the agent's posture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: extract the copilot extension's own security settings VS Code registers 158 chat settings. The Copilot extension contributes 191 more of its own, and we were reading none of them. Its model and prompt experiment flags are noise and stay excluded; these move data or grant capability. - codeSearchExternalIngest ships true: workspace code is sent out for indexing. enableCodeSearch, the ADO endpoint override and the OTEL endpoint decide where it goes, and an endpoint is something an attacker would rather set than steal. - The GitHub MCP server has its own enable, lockdown, readonly and toolset governance, none of which we could see. - installExtensionSkill lets the agent install extensions, which is a code-execution surface rather than a preference. - backgroundAgent and cloudAgent ship true and decide where the agent runs; cli.sandbox decides whether it is contained when it runs there. - The execution and search subagents have their own enable flags and tool-call limits, and organization agents and instructions are org-managed inputs to every session. - currentEditorContext ships true, so the open editor's contents travel with the request. A test pins the exclusion as well as the inclusion: the prompt and model flags must stay out, or the record dilutes into noise and stops being readable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the settings from every profile, not just the winning one Rules were already unioned across a user's profiles, but raw_settings came from whichever profile ranked riskiest. A guard switched off in another profile was therefore dropped from the report — which defeats extracting those keys at all, since ignoreDefaultAutoApproveRules in a second profile is still in force there. Settings are now unioned as well, with the winning profile taking precedence where both set the same key, so the reported posture and the path it is attributed to stay consistent. Found by the test battery for this PR, and independently by review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the setting, drop the secret inside it Two of the newly captured settings are posture signal whose value can carry a credential, which makes collecting them verbatim a way to ship secrets to the backend rather than a way to see risk. A terminal profile carries an env map that routinely holds API keys. The names are the signal — they show what the agent's shell is given — so those are kept and the values are not. The OTEL and code-search endpoint overrides are URLs, and a URL can hold credentials in its userinfo or query. The destination is the finding, so scheme, host, port and path stay; userinfo and query go. This is the rule already applied to chat.agentHost.otel.headers, which was excluded outright for the same reason. It should have been applied to these at the same time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: redact an endpoint whether or not it has a scheme The redaction only ran when the value contained "://", so collector.internal:4318/v1?api-key=… went to the backend untouched — a scheme-less endpoint is exactly what someone writes in this setting, and it carries a credential just as readily as a fully qualified URL. The authority is now parsed either way, so userinfo and the query go in both shapes and the host still survives, which is the part worth reporting. A value whose port will not parse is replaced outright rather than passed through, so the error path cannot become the leak. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: fold the credential-redaction guards into the copilot suite The scratch battery file is dropped; the guards that cover shipped behaviour move into the real suite so the redaction cannot regress unnoticed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: strip credentials from plugin marketplace settings A private marketplace is a git remote, so it carries a token the same way the endpoints already handled do, and a strict entry can carry auth headers. Plain refs keep their form so the evidence stays readable. Also brackets an IPv6 host, which was ambiguous once a port was appended. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep a marketplace ref the url parser cannot read An scp-style remote (git@host:owner/repo.git) failed to parse and was being discarded whole, losing the identity of a configured code source. The userinfo and query are now cut textually on that path, and nested values inside an entry get the same treatment as top-level ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: redact what carries a credential, not what merely looks like one Three ways the previous pass got the boundary wrong. An npm scope and a wildcard pattern were being rewritten as URLs, corrupting the identity of a configured marketplace. A token carried in an endpoint's path survived, so only its first segment is kept now. And an unencoded question mark inside userinfo cut the value before the credential rather than after it. Marketplace shapes are told apart by their setting: two hold remotes directly, the strict one holds entry objects where only a named field is a remote. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: redact a marketplace credential whatever field it arrives in The entry schema is open, so keying redaction off the single name "url" left a remote under any other name shipping its token. Identity fields are now the named exception and everything else is checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: a marketplace path is identity, not a url A local plugin directory is named by path, so a question mark in one is part of the name rather than a query string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: an identity field must not smuggle a credential Exempting a field by name kept its shape intact but also exempted it at every depth, so a remote written under one shipped its token. Those fields now keep punctuation that belongs to them while still losing userinfo, and auth headers are reduced whatever shape they arrive in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: redact a terminal env whatever shape it arrives in settings.json is user-authored and never schema-checked before we read it, so env can arrive as a list and the profile itself as something other than an object. Auth headers already handled that; env now does too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: vishnu <vishnuvinod072@gmail.com> Co-authored-by: Aakash Velusamy <aakashvpsgtech@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Vishnu <79318686+zeus-12@users.noreply.github.com> * perf(windows): shared-index helper + migrate 9 rules extractors Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf(windows): migrate cline skills extractor to shared index * perf(windows): migrate 8 skills extractors to shared index (codex, gemini_cli, junie, kilocode, opencode, replit, windsurf, copilot_cli) * fix(windows): revert 8 skills extractors that need prune guards the shared helper lacks NGMU gate caught a behavior regression: 8 skills extractors (codex, gemini_cli, junie, kilocode, opencode, replit, windsurf, copilot_cli) applied traverses_other_tool_config_dir + is_symlink_or_junction prunes in their bespoke walk. The generic shared helper prunes only system dirs, so migrating them made the scan dispatch markers bundled inside OTHER tools' config dirs (e.g. ~/.antigravity/extensions/<pkg>/.claude) -- false-positive skill/rule discoveries. Revert those 8 to their bespoke walks (they keep the guards). Keep the 10 guard-free extractors migrated (9 rules + cline skills), which are provably index-equivalent. Adds the test that would have caught this: - test_shared_helper_does_not_prune_other_tool_config_dirs (encodes the limitation) - TestGuardedSkillsExtractorsStayUnmigrated (regression-lock: the 8 must keep their prune and not use the shared helper; proven to fail on the migrated version) - old-bespoke-walk-semantics oracle, depth-boundary, symlink-not-descended, cline-skills routing Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(windows): restore should_skip_path import in junie rules extractor The migration's import cleanup dropped should_skip_path, but _extract_global_rules still calls it -> NameError swallowed by a broad except -> user-level ~/.junie rules silently never reported (a security-inventory gap: a user-level agent rule, e.g. prompt-injection, would be invisible). Flagged by CI (test_global_rules_extracted), Unbound Review P1, and the security consensus (MEDIUM). Also drop now-unused imports the migration left in cursor/kilocode rules extractors (ruff F401). ruff --select F is now clean across all migrated extractors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf: cache a partial index when the root is readable (fixes the Windows no-reuse P1) get_subtree_index only cached a FULLY-readable subtree, so on real Windows (root C:\ readable, deep dirs like other users / protected system paths permanently denied) the index was never cached -> every per-tool walk re-listed the drive and the shared index bought nothing (possibly slower than the old 4-threaded walks). Cache a partial index when the ROOT itself was readable: those deep denials are stable for the scan, the cache is per-process and cleared between scans, and every per-tool walk shares the key -> they reuse ONE pass. An unreadable/absent root is still left uncached so a later lookup can re-attempt and see it appear (test_unreadable_root_is_not_cached preserved). Adds test_partial_readable_root_is_cached (proven to fail pre-fix). Applies to Linux/macOS too, where it only helps (a partially-denied tree now reuses one walk). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert the cross-basename outermost sort (P2): it broke index/fallback order parity The depth sort made dispatch_matches' index route diverge from the fallback walk's DFS order (test_fallback_dispatch_is_identical_to_index). The P2 is a documented corner case (a marker of one tool nested inside another tool's marker dir, which does not occur for real project roots), so it stays as the author's noted limitation rather than trading a real ordering invariant for it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * address review: drop stray .bak, cache-on-root-listing-complete, de-nest cross-basename Three findings from the review on the prior head: - Remove the accidental project_dir_index.py.bak (a sed -i.bak backup that got committed); gitignore *.bak. - P1 residual: caching keyed off "whole subtree read", and the follow-up scandir only checked the root OPENS. A root whose own listing truncated mid-iteration would be frozen. _collect now returns whether CURRENT_DIR's own listing completed; a denied/faulted CHILD subtree stays cacheable (stable within a scan), but a truncated root listing is not cached (re-attempt next lookup). - P2: outermost_only now drops a match that has another match among its ancestors, checking ALL inputs (not just those kept so far), so a cross-basename matcher (cline skills: .cline/.clinerules/.claude) de-nests even when a child is listed before its parent — order-preserving, so index/fallback dispatch parity holds. A depth sort would have broken that parity. Tests: test_denests_child_listed_before_parent (proven to fail on the old filter); partial-cache and unreadable-root caching tests still pass. 55 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: lock the two shared-index P2 behaviors and fix the symlink oracle The migration's backward-compat oracle silently modelled the NEW no-symlink descent semantics, so it never proved the divergence from the real old walkers. Corrected it to follow symlinks like the origin/main _walk_for_* recursion did (verified against the real antigravity walker), and added tests pinning the two deliberate divergences Unbound Review flagged as P2: - symlink handling: old walks descended directory symlinks and even dispatched out-of-root targets; the shared index does neither but still finds the real in-root path — a safer, more-correct behavior, now asserted old-vs-new. - truncated-child caching: a transient mid-iteration fault drops post-fault siblings for one scan but self-heals on the next (per-process cache); the same caching-on-partial that the Windows perf fix requires. Proven the caching guard fails on origin/main (no partial caching) and passes on head. Test-only; no shipped-code change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: don't cache a mid-listing directory truncation, only a stable denial _collect now separates the two ways a listing can fail. A directory that can't be OPENED (a locked profile, Application Data) is a stable denial that reads the same for the rest of the scan, so the partial index around it stays cacheable -- the Windows deep-denial case the single pass depends on. A listing cut short AFTER it began (a transient network-share or cloud-placeholder fault) is no longer frozen in: it propagates up from any descendant and leaves the index uncached, so the next lookup re-lists and recovers whatever the fault hid. Closes the caching P2 that Unbound Review and Cursor both flagged, without re-opening the perf regression the partial-cache fix addressed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: simplify the shared-index comments Trim the tri-state _collect and caching comments to plain language a maintainer can read at a glance -- no jargon, no restating the code. Comment-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: plainer comments across the migrated extractors Replace the repeated "single-pass / memoized" phrasing and the index jargon (buckets, cross-basename, reparse point) with plain sentences: the drive is walked once for all tools, not once per tool. Comment-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: cap every comment in the diff at two lines Compress the remaining multi-line comments and docstrings (the _collect tri-state, the caching notes, the oracle and limitation notes in tests) to two lines each. Comment-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: NandaPranesh <106886030+anonpran@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Vignesh Subbiah <51325334+vigneshsubbiah16@users.noreply.github.com> Co-authored-by: rajaramsrinivas <raj@unboundsecurity.ai> Co-authored-by: pugazhendhi-m <132246623+pugazhendhi-m@users.noreply.github.com> Co-authored-by: Vishnu <79318686+zeus-12@users.noreply.github.com> Co-authored-by: gowshik450526511 <66863804+gowshik450526511@users.noreply.github.com> Co-authored-by: vishnu <vishnuvinod072@gmail.com> Co-authored-by: audit <audit@local> Co-authored-by: Sumit Badsara <sumitbadsara.dev@gmail.com> Co-authored-by: AakashVelusamy <aakash@unboundsecurity.ai>
Two settings a Copilot user can have that made them look locked-down on the permissions page while their agent ran tool calls without asking. Nanda caught the first on a real machine after #285 shipped; the second surfaced while testing the fix for it.
They are different triggers with the same outcome — the user simply isn't there.
chat.defaultConfiguration: {"approvals": "allowAll"}bypassPermissionsdefault— identical to a locked-down userbypassPermissionssettings.json/mcp.jsonwritten with a BOMchat.tools.global.autoApprove: truebypassPermissionschat.permissions.default: "autoApprove"bypassPermissions1. The session approvals default
chat.defaultConfigurationwas not inSECURITY_RELEVANT_KEYS, and a record is only built when at least one key from that set is present — so asettings.jsonholding only this setting produced no row, and one holding it alongside anything else produced a row sayingdefault. Reproduced on releasedmain(332cfb3) before changing anything:It is a real key, checked against the VS Code 1.136 configuration registry rather than the docs:
and the workbench reads it straight into each new session's auto-approve state:
The registry also carries a migration from
chat.agentSessions.defaultConfiguration, so installs that set this before the rename still hold the old name. Both are read, the waychat.tools.autoApproveis kept as the legacy alias for the global switch.Deliberately not mapped.
approvals: "assisted"still prompts, so calling it a bypass would overstate the posture — it staysdefaultand rides along verbatim inraw_settings.mode: "autopilot"selects the chat mode without touching approvals, so it is not a bypass on its own.2. Config files with a byte order mark
json.loadsrejects a leading BOM outright — its own error saysUnexpected UTF-8 BOM (decode using utf-8-sig)— so the parse returnedNoneand the user disappeared.This is not a broken artifact to be ignored. The editors read such a file happily and preserve the BOM through their own edits (the VS Code bundle carries
preserveBOM/addBOMhandling), so the configuration stays live, stays honoured, and stays invisible to us until someone rewrites the file by hand. Two ordinary ways it happens on a Windows fleet:Set-Content -Encoding UTF8, which writes a BOMThe strip goes in the shared JSONC entry point, so every reader that routes through it is fixed at once rather than only the file that surfaced this:
settings.jsonand every profilemcp.jsonsettings.json, MCP configA BOM character inside a string literal is left untouched.
Verified
Both fixes together on the Windows runner as
NT AUTHORITY\SYSTEM, with asettings.jsonwritten the way an MDM script writes one — BOM present, and carrying only the approvals key, so it was invisible twice over onmain:Released
mainwas first validated on both runners with four real user accounts each, which is how the gap was confirmed end to end rather than in the extractor alone:Eight tests. Six fail on
mainwith the real symptoms —_build_recordreturningNone, andJSONDecodeError: Unexpected UTF-8 BOM— and two are guardrails provingassistedandautopilotare not overstated.Verify
{"chat.defaultConfiguration": {"approvals": "allowAll"}}intosettings.json, saving it with a BOM (Set-Content -Encoding UTF8on Windows).permission_mode: bypassPermissions. Previously there was no row at all.🤖 Generated with Claude Code
The PR appears safe to merge; no actionable correctness, security, or repository-rule violations remain.
Summary
allowAllapprovals under both current and legacy Copilot default-configuration keys.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart LR A[Copilot or MCP configuration file] --> B[Shared JSONC cleanup] B --> C[Remove leading UTF-8 BOM] C --> D[Remove comments and trailing commas] D --> E[Parse configuration] E --> F{Copilot default approvals} F -->|allowAll| G[bypassPermissions] F -->|manual or assisted| H[default]