feat: extract VS Code Copilot agent permissions from settings.json - #285
AakashVelusamy merged 25 commits into
Conversation
The discovery scan reported GitHub Copilot (VS Code) with rules, MCP, and skills but no permissions. Copilot's agent-mode permissions live in VS Code's settings.json (JSONC) — user scope plus any <workspace>/.vscode/settings.json — not a dedicated file. This reads the security-relevant auto-approve / terminal / MCP keys and emits the same backend-ready permission record the Cursor extractor does, so it routes as tool-level permissions with no backend or frontend change. - BaseGitHubCopilotSettingsExtractor maps settings.json → permission_mode (global auto-approve = bypassPermissions, file auto-approve = acceptEdits), allow/deny Bash rules (terminal autoApprove + agent blocklist), and MCP allow/deny policies. - Per-OS user paths: ~/Library/Application Support/Code/User (macOS), %APPDATA%\Code\User resolved per-user from the home, not the running token (Windows), ~/.config/Code/User (Linux); stable Code and Insiders both read. - Workspace .vscode/settings.json is in SKIP_DIRS, so the walk exempts that leaf the same way workspace mcp.json discovery does. - Attached to the canonical VS Code Copilot row only, mirroring skills. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An embedded git worktree was accidentally staged; untrack it and ignore .claude/worktrees so it can't be re-added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Some Windows editors write settings.json with a UTF-8 BOM, which json.loads rejects. Read with utf-8-sig so a BOM is stripped; plain UTF-8 is unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restore the .kilocode/ ignore (a stray append had merged it into one line) and add .claude/worktrees/ so local worktrees stay untracked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
3 findings — 1 high-confidence, 2 to triage. Reviewers: Claude (lead), Semgrep, Gitleaks, Greptile.
-
🔴 [MEDIUM] Workspace walk follows symlinked
.vscodedirs andsettings.jsonleaves —scripts/coding_discovery_tools/coding_tool_base.py:1166- What: In
_walk_workspace_settings, the.vscodebranch runs before theis_symlink_or_junction(item)guard, and thesettings.jsonleaf is only checked withis_file()(which follows links), so a symlinked config dir or leaf is read and reported. - Why: Under an elevated/root scan across all user homes, an unprivileged local user plants
~/proj/.vscode -> /root/somewhere(orsettings.json -> /root/x.json). The root-owned scan reads it, and if it parses as a JSON dict with at least oneSECURITY_RELEVANT_KEYSentry, its values land inraw_settingswith the unresolved in-home path insettings_path— content from outside the scan boundary, attributed to the user's workspace. There is also no size cap onread_text, so a link to a very large regular file inflates scan memory. (Regular-file-onlyis_file()does block FIFO/device tricks.) - Fix: Apply the same guards the sibling MCP walker uses — check
is_symlink_or_junction(item)before the.vscodebranch, and checkis_symlink_or_junction(settings)on the leaf beforeis_file(). Optionally confirm the resolved path stays underuser_home. - Flagged by: Claude (lead), Greptile (P2, security)
- What: In
-
🟡 [MEDIUM] MCP server entries are copied verbatim into
raw_settings—scripts/coding_discovery_tools/coding_tool_base.py:1200- What:
_build_recordcopies the whole value of every security-relevant key, includingchat.mcp.allowedServers/deniedServers, intoraw_settingswith no field filtering. - Why: The code's own
_mcp_listshandles list items that are objects withurl/commandkeys, so object-shaped entries are expected. MCP server objects commonly carry auth material — bearer tokens in a URL query string,headers, orenv. Those would be shipped to the backend and stored in the permission record.⚠️ Uncertain how often realallowedServersentries are objects rather than plain names — worth checking against a real install before deciding severity. - Fix: Whitelist the fields kept from MCP entries (
name/idonly), or redactheaders,env, and URL query strings before writing toraw_settings. - Flagged by: Claude (lead)
- What:
-
🟡 [LOW] Multi-user scans silently drop every record but the first —
scripts/coding_discovery_tools/coding_tool_base.py:1195- What:
extract_settings()logs a warning and returnsrecords[0]when several users have Copilot settings. - Why: This is a security-posture reporting tool. On a shared/multi-user device, a user running
chat.tools.global.autoApprove: true(YOLO) is reported as having no permission record at all if another user's record sorts first — a false negative on exactly the condition this PR was written to surface. The kept record is also attached to the canonical row regardless of which user it came from. - Fix: Return all per-user records and let the per-user report filter pick the matching one, or key the record by the user home it came from.
- Flagged by: Claude (lead), Greptile (P1)
- What:
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head 064cbf82 · 2026-09-04T15:03Z
There was a problem hiding this comment.
Stale comment
Agentic security review of the Copilot VS Code
settings.jsonpermission extractor. One medium finding: the new workspace walk follows.vscode/settings.jsonredirects that sibling Copilot MCP extractors already refuse, which can leak another user's Copilot posture into a privileged scan report.Sent by Cursor Security Agent: Security Reviewer
Copilot permissions can differ per VS Code profile — a locked-down default with a YOLO named profile is a real risk. Read the default settings.json plus every profiles/<id>/settings.json (stable Code + Insiders), reusing the same shared enumerate_vscode_user_files helper the workspace mcp.json discovery uses, and merge all profiles + workspaces to the most permissive posture. Generalizes enumerate_vscode_mcp_files into enumerate_vscode_user_files(base, filename); the mcp variant is now a thin wrapper, so profile enumeration is one implementation shared by both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er scope
Verified every key against the VS Code 1.136 configuration registry (workbench
bundle + Copilot extension package.json). Dropped five keys that the tool never
registers — github.copilot.chat.agent.autoApproveFileChanges / autoApproveTerminal
/ terminalCommands.blocklist / agent.enabled (blog-era or Business-only) — which
had made the acceptEdits mode and a blocklist→deny path into dead code that could
only ever over-report. Mode is now bypass (global auto-approve) or default; deny
rules come only from the real chat.tools.terminal.autoApprove.
Scope is user + every VS Code profile, not workspace files: chat.tools.global.
autoApprove is policy/application-scoped ("applies globally across all workspaces")
and chat.tools.terminal.autoApprove is a restricted setting VS Code honors only in
a trusted workspace — so reading them from a workspace .vscode/settings.json would
report a posture the tool never applies. User and profile settings are the
authoritative surface.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
X-1: the permission record stays within the Cursor record's key vocabulary — the shape gateway-data's AIToolPermissions ingest and the fe already accept — so no backend/frontend change is needed. ISO-1: permissions attach to exactly the canonical VS Code Copilot row; a non-canonical row (plain when Chat is canonical) never double-attaches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
extract_settings returned records[0]; under an elevated all-users scan the per-user filter keeps the canonical row's permissions only for the user whose home holds settings_path, so a benign first user would hide a later user's YOLO posture. Return the most-permissive user's record (with its own settings_path) so the riskiest posture always surfaces. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An MCP server entry in chat.mcp.allowedServers/deniedServers can be an object carrying auth material (a token in its URL, headers, env). raw_settings copied these verbatim, so they would ship to and be stored in the permission record. Keep only the server name/id; the policy lists already carry just names. Also skip a pathologically large settings.json (>8MB) rather than reading it whole. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review disposition — every finding accounted for (as of
|
| Finding | Disposition |
|---|---|
P1 — Multi-user settings discarded (extract_settings returned records[0]) |
Fixed. Now returns the most-permissive user's record (with its own settings_path), so under an elevated scan a YOLO user is never hidden behind a benign first user. Regression test proven to fail on records[0]. |
| P1 — Editor channel mismatch (selected stable's first file) | Fixed. Both stable Code and Insiders are read and merged to the most-permissive posture; an Insiders-only install surfaces correctly. |
| P2 (security) — Workspace links bypass scan boundaries | Fixed by removal. The workspace .vscode walk is gone entirely — permission scope is user + profiles only (the auto-approve keys are policy/application-scoped and restricted = trusted-workspace-only, so workspace files would report a posture the tool never applies). |
Consensus / Vignesh security review (head 064cbf8)
| Finding | Disposition |
|---|---|
🔴 [MEDIUM] Workspace walk follows symlinked .vscode |
Fixed by removal (as above — no workspace walk). |
🟡 [MEDIUM] MCP server entries copied verbatim into raw_settings (token/headers/env leak) |
Fixed. _redact_mcp_secrets keeps only the server name/id in raw_settings; the policy lists already carry just names. Regression test asserts no auth material reaches the record (proven to fail without the redaction). |
| 🟡 [LOW] Multi-user drops all but first | Fixed — same most-permissive-user change as the Greptile P1. |
| (reviewer note) no size cap on read_text | Addressed. _parse_jsonc skips a >8MB settings file. |
Cursor (Bugbot + Security)
No open findings on the latest commit.
Key-set correctness (from the /never-gonna-mess-you-up pass)
Every extracted key was verified against the VS Code 1.136 configuration registry; five blog-era/Business-only phantom keys were removed (they made an acceptEdits path and a blocklist→deny path into dead over-reporting). A guard test blocks their reintroduction.
All fixes carry regression tests proven to fail on the pre-fix code. Live-verified on real Windows + Linux VMs + macOS.
There was a problem hiding this comment.
Stale comment
Config/template injection review — Copilot VS Code settings extractor
Verdict: NO_FINDING
The new
BaseGitHubCopilotSettingsExtractordoes not newly wire CODE DATA into a config object, prompt template, or template variables. It reads local user-scope VS Codesettings.jsonand attaches a filtered permissions record to the discovery report payload only.Evidence
tool_dict["permissions"]is a discovery report field, not a config/template sink
- Attached at
ai_tools_discovery.py:2561-2563for the canonical VS Code Copilot row.- Flows into
generate_single_tool_report→tools[0](ai_tools_discovery.py:2976-2985).- Uploaded via
send_report_to_backend/ S3 (utils.py:834+,s3_uploader.pyhashespermissionsfor dedup only).- Local
mcp-tools-cache.jsonrefresh usesprojects[].mcpServersonly — nopermissionsreads/writes (mcp_tools_cache.py).Parity with Cursor
- Cursor already emits the same backend record shape including
settings_path+ filteredraw_settings(coding_tool_base.py:865-869).- Copilot mirrors that contract (
coding_tool_base.py:1241-1257).
raw_settingscontent
- Built only from
SECURITY_RELEVANT_KEYS(coding_tool_base.py:1126-1136,1235).- Values are permission flags, terminal auto-approve patterns →
Bash(...)rules, MCP server names (secrets redacted at1296-1312), URLs/domains — not source files, prompts, completions, snippets, or stack traces.
enumerate_vscode_user_files
- Filename is a fixed literal (
"settings.json"/"mcp.json"); path join +profiles/*/filenameglob (mcp_extraction_helpers.py:552-590, call atcoding_tool_base.py:1161).- No user-controlled glob/template injection surface in this path.
Read vs inject
- This is a local settings read → report serialization path. No write of extracted user code into a config file or prompt/template.
Sent by Cursor Security Agent: Security Reviewer
There was a problem hiding this comment.
Stale comment
Verdict: CONFIRMED (blast: medium)
The new VS Code Copilot
settings.jsonextractor follows POSIX symlinks / Windows junctions and can spoof per-user permission identity via unresolvedsettings_path+filter_tool_projects_by_user.Evidence
No symlink/junction guard on user settings leaves
enumerate_vscode_user_filesnever callsis_symlink_or_junction; it accepts leaves viaPath.exists()/Path.is_file()(mcp_extraction_helpers.py:576,:585), which follow redirects._parse_jsoncalso skips the guard and usespath.is_file()+path.read_text()(coding_tool_base.py:1217,:1224) — content is whatever the redirect targets.- Contrast: workspace Copilot MCP leaves do skip redirects (
macos/.../mcp_config_extractor.py:216-217and OS siblings). User-scopemcp.jsonvia this same helper does not.
settings_pathis the unresolved in-home path
- Record stores
str(path)from enumeration (coding_tool_base.py:1244), notresolve()/ realpath. A planted…/Code/User/settings.json(orprofiles/*/settings.json) symlink/junction still looks like the attacker’s home path while content can be out-of-home.
filter_tool_projects_by_usertreats that path as ownership
- Keeps
permissionswhensettings_pathis underuser_home(non-managed) (ai_tools_discovery.py:1847-1855). Extractor comments explicitly rely on this (coding_tool_base.py:1181-1185). Attachment:ai_tools_discovery.py:2558-2563.Attack path (root/admin all-user scan)
- Local user plants
Code/User/settings.jsonorprofiles/<id>/settings.jsonas symlink/junction → victim’s settings.- Elevated scan reads victim content under attacker’s path string → most-permissive merge can promote that posture → filter attributes the record to the attacker.
- Requires elevated scan (root/admin can follow into the victim tree); non-elevated single-user scans are out of scope for cross-user spoof.
NEW vs pre-existing
- Pre-existing for user-scope
mcp.json: same unguardedexists()/is_file()enumeration (this PR generalizedenumerate_vscode_mcp_files→enumerate_vscode_user_files).- New risk for permissions: expanding that helper to
settings.jsonand wiring the record throughsettings_path→filter_tool_projects_by_usercreates a cross-user identity/attribution channel for Copilot permission posture (bypassPermissions, terminal auto-approve, MCP allow/deny) that did not exist on this surface before.Blast: medium — integrity of per-user security posture on elevated multi-user scans (misattribution / spoofed YOLO), not RCE. Workspace MCP leaves are already guarded; user settings leaves are not.
Sent by Cursor Security Agent: Security Reviewer
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
2 findings — 1 high-confidence, 1 to triage. Reviewers: Claude (lead), Semgrep, Gitleaks.
-
[MEDIUM] MCP URL fallback leaks credentials into the shipped policy lists —
scripts/coding_discovery_tools/coding_tool_base.py(_mcp_lists, in the newBaseGitHubCopilotSettingsExtractor)- What:
_redact_mcp_secretssanitizesraw_settings, but_mcp_listsindependently re-reads the same raw server objects withitem.get("name") or item.get("id") or item.get("url") or item.get("command"), so a server entry with noname/idputs its full URL — query string included — intomcp_tool_allowlistandmcp_policies. - Why: A real-world entry like
{"url": "https://mcp.example/?token=SECRET123"}(or{"command": "mcp-server --api-key=..."}) is emitted verbatim in the backend-bound permission record. This is the residual path from the already-fixedraw_settingsleak, not a re-flag: the regression test only asserts onraw_settings, and its fixture object has aname, so the URL fallback never fires. Your own mapping test asserts the leaky behavior as expected (mcp_tool_allowlist == [..., "https://x"]). - Fix: Route the policy lists through the same redaction. Either drop the
url/commandfallbacks (fall back to"<redacted>"or a stable hash), or strip credentials before emitting — parse the URL and keep only scheme+host+path, discard query/userinfo, and never emit a rawcommandstring. Extend the redaction test with aname-less, token-bearing server object. - Flagged by: Claude (lead)
- What:
-
[MEDIUM] Shared VS Code file enumeration still follows symlinks/junctions —
scripts/coding_discovery_tools/mcp_extraction_helpers.py:~576(enumerate_vscode_user_files)- What:
default_file.exists()and the profile-globis_file()both follow POSIX symlinks and Windows junctions, and_parse_jsoncthen reads through the link whilesettings_pathkeeps the unresolved in-home path. - Why: On an elevated multi-user scan, a local user can point their own
~/.config/Code/User/settings.json(or aprofiles/<id>/leaf) at another user's file. The victim's Copilot posture is parsed and attributed to the planter, becausefilter_tool_projects_by_userkeys off the unresolvedsettings_path. Disclosure is bounded to the security-relevant keys, so this is posture disclosure and misattribution, not secret theft. Note this gap is pre-existing in the helper formcp.json; this PR touches those lines and extends the helper tosettings.json, which is what feeds the permissions identity path.⚠️ I could not verify from the diff whether any caller applies a link guard upstream — worth checking before acting. - Fix: Apply the same
is_symlink_or_junctionguard the workspace MCP walkers use, on both the default leaf and each profile leaf, and storestr(path.resolve())(or drop the record when the resolved target escapesuser_home) so attribution can't be forged. - Flagged by: Claude (lead), Cursor Security Reviewer (prior comment on
883fab8)
- What:
Previously acknowledged (not re-flagged)
- Workspace
.vscodesymlink walk bypasses scan boundaries — fixed by removing the workspace walk entirely; permission scope is user + profiles only. - MCP server entries copied verbatim into
raw_settings— fixed via_redact_mcp_secrets(see finding 1 for the sink that fix does not cover). - Multi-user records collapsed to
records[0]— changed to return the most-permissive user's record with its ownsettings_path. - Stable vs. Insiders channel mismatch — both channels are now read and merged to the most-permissive posture.
- No size cap on
read_text—_parse_jsoncnow skips files over 8 MB.
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head 883fab85 · 2026-09-04T16:13Z
…ource Under an elevated all-users scan a user could symlink their own Code/User/settings.json (or a profile's) to a root-owned file and have its contents reported as theirs. Drop any settings file whose realpath escapes the user's home; an in-home stow/chezmoi symlink still resolves inside and is read. Also base the per-user profile/channel merge on the most-permissive record so settings_path attributes to where the risk actually is (e.g. an Insiders YOLO profile), not merely the first file read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addendum — Greptile 1/5 findings (as of
|
| Finding | Disposition |
|---|---|
| Linked settings escape the scan boundary (user-scope reads followed symlinks) | Fixed in c941fb9. _resolves_within drops any settings.json / profile file whose realpath escapes the user's home — under a root scan a user can no longer symlink their settings.json to a root-owned file and have it reported as theirs. An in-home stow/chezmoi symlink still resolves inside and is read. Regression test proven to fail without the guard. |
| Channels merged without preserving attribution | Fixed in c941fb9. The per-user merge now bases on the most-permissive profile/channel record, so settings_path attributes to the actual risk source (e.g. an Insiders YOLO profile), not the first file read. |
| Multi-user collapses to one record | Accepted architectural limitation, mitigated. The scan builds each tool report once for all users then filters per user (process_single_tool once → filter_tool_projects_by_user per home) — a single permissions field per canonical row. This is the model every sibling settings extractor uses (Cursor/Claude), not new here. The most-permissive pick makes it security-correct for the case that matters: a YOLO user's own report keeps their record (its settings_path is under their home); only benign users omit permissions. Preserving every user's distinct posture (two simultaneous YOLO users) needs a per-user permission structure + filter_tool_projects_by_user/backend changes — out of scope for this PR, and I've flagged it for a follow-up rather than re-architecting the shared attachment path here. |
All fixable findings are fixed with prove-fail regression tests. The remaining item is a deliberate, documented tradeoff shared fleet-wide — surfaced for the reviewer's call on the score.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
3 findings — 1 high-confidence, 2 to triage. Reviewers: consensus reviewer, semgrep (0), gitleaks (0).
-
🔴 [MEDIUM] MCP server URLs bypass
_redact_mcp_secretsand reach the report —scripts/coding_discovery_tools/coding_tool_base.py(_mcp_lists, ~L1350)- What:
_redact_mcp_secretssanitizesraw_settings, but_mcp_listsruns on the unredacteddataand falls back toitem.get("url") or item.get("command")when a server object has noname/id— so the full URL or command string lands inmcp_tool_allowlistandmcp_policies. - Why: A remote MCP server is commonly configured as
{"url": "https://mcp.example.com/sse?key=SECRET"}with nonamekey. That whole string, API key included, is copied into the permission record and shipped to the backend. Your own test asserts this flow:test_mcp_allow_deny_strings_and_objectsexpectsmcp_tool_allowlist == [..., "https://x"]. The redaction test doesn't catch it because its object has aname, so the URL fallback never fires. This contradicts the disposition note "the policy lists already carry just names". - Fix: In
names(), when falling back tourl, emit only scheme+host (drop query, fragment, and userinfo); drop thecommandfallback or reduce it to the bare executable name. Add a regression test with a name-less{"url": "...?token=SECRET"}server assertingSECRETappears nowhere in the record. - Flagged by: consensus reviewer (provable from diff + existing tests)
- What:
-
🟡 [LOW] Check-then-open race on the symlink guard —
scripts/coding_discovery_tools/coding_tool_base.py:1245(_parse_jsonc)- What:
_resolves_withincallsos.realpathduring enumeration, then_parse_jsoncindependently re-opens the same path withis_file()/stat()/read_text(). The path is re-resolved at open time, not pinned. - Why: Narrower than the escape you already fixed in
c941fb9. Under a root all-user scan, the settings file sits in a directory the unprivileged user owns, so they can flip the link between the check and the read (a tight swap loop wins often enough to matter) and get an out-of-home file parsed and attributed to them. - Fix: Open once and reuse the handle —
os.open(path, O_RDONLY | O_NOFOLLOW), then run the containment check againstos.readlink('/proc/self/fd/N')(or compareos.fstatto thelstattaken at enumeration) and read from that same descriptor. - Flagged by: consensus reviewer
- What:
-
🟡 [LOW]
raw_settingscopies terminal and URL auto-approve values verbatim —scripts/coding_discovery_tools/coding_tool_base.py:~1258(_build_record)- What: Redaction covers only
chat.mcp.allowedServers/deniedServers.chat.tools.terminal.autoApproveandchat.tools.urls.autoApproveare copied intoraw_settingsunchanged. - Why: Those values are user-authored command patterns and URLs. An auto-approve entry like a
curlinvocation with anAuthorizationheader, or an allow-listed URL with a signed query token, is stored and shipped in the same record the MCP redaction was added to protect.⚠️ Lower confidence than the finding above — plausible from the key semantics, not proven by a fixture in this diff. - Fix: Extend the redaction pass to strip query strings from URL entries and mask obvious credential substrings (
Bearer,token=,api[_-]?key=) in terminal patterns before they enterraw_settings. - Flagged by: consensus reviewer
- What: Redaction covers only
Previously acknowledged (not re-flagged)
- Workspace
.vscodesymlink/junction follow (Greptile P2, Cursor MEDIUM ×2) — fixed by removing the workspace walk entirely; permission scope is user + profiles only. - User-scope settings symlink escaping home (Greptile 1/5) — fixed in
c941fb9via_resolves_within, with a prove-fail regression test. - Multi-user records collapsed to one — accepted architectural limitation; mitigated by returning the most-permissive user's record, matching every sibling settings extractor; follow-up flagged.
- Stable vs Insiders channel merge — fixed; merge now bases on the most-permissive profile/channel so
settings_pathattributes to the real risk source. - MCP server objects copied verbatim into
raw_settings— fixed by_redact_mcp_secrets(the residual gap in the policy lists is finding 1 above, which is a different code path). - No size cap on
read_text— fixed;_parse_jsoncskips files over 8 MB.
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head c941fb93 · 2026-09-04T17:22Z
|
@greptile-apps re-review request — on the two remaining P1s, with the consistency + backend context. These are deliberate, fleet-consistent choices, not defects unique to this PR: 1. "Multi-user settings collapse to one record." This is the established pattern of every settings extractor in this repo, not new here:
Full per-user permission attribution (a record for every user simultaneously) is achievable given the backend's per-user 2. "Channels merged without matching the detected installation." For a security-posture tool this is intentional: if any installed channel (stable or Insiders) has a YOLO config, the device has a YOLO Copilot, and the most-permissive merge surfaces it. The genuinely-fixable items you raised — the workspace-symlink escape, MCP-secret leakage into |
…extractors VS Code writes settings.json as UTF-8 without a BOM, and the copilot_cli and augment settings extractors both read plain utf-8. Drop the utf-8-sig special case so the encoding matches them; the earlier BOM only came from a PowerShell test fixture, not the real tool. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
2 findings — 1 high-confidence, 1 to triage. Reviewers: Claude (lead), Semgrep, Gitleaks.
-
[MEDIUM] MCP server URL with embedded credentials survives redaction and ships in the policy lists —
scripts/coding_discovery_tools/coding_tool_base.py:1342- What:
_mcp_lists.names()falls back toitem.get("url")(thenitem.get("command")) when an MCP server object has noname/id, and that raw string is written verbatim intomcp_tool_allowlistandmcp_policies. - Why:
_redact_mcp_secretsonly sanitizesraw_settings, and only for the twochat.mcp.*keys. A settings entry such as"chat.mcp.allowedServers": [{"url": "https://mcp.example/?token=SECRET123"}]is redacted to"<redacted>"inraw_settingsbut is emitted in full asmcp_tool_allowlist: ["https://mcp.example/?token=SECRET123"], so the token leaves the customer machine in the permission record. The existing testtest_mcp_allow_deny_strings_and_objectsasserts exactly this pass-through ("https://x"in the allowlist);test_mcp_server_secrets_redacted_from_raw_settingsonly grepsraw_settings, so nothing catches it. - Note on prior discussion: the disposition table says "the policy lists already carry just names." That holds only when the object has a
nameorid— theurl/commandfallback is the gap. - Fix: drop the
url/commandfallback innames()(use<unnamed>or a hash), or strip userinfo and the query string before appending. Extend the redaction test to assertSECRETis absent fromjson.dumps(rec), not justrec["raw_settings"]. - Flagged by: this review (provable from the diff and from the committed test expectations)
- What:
-
[MEDIUM] Containment check is not enforced at read time (check-then-read race) —
scripts/coding_discovery_tools/coding_tool_base.py:1164- What:
_iter_user_settings_filescalls_resolves_withinon a path, then_parse_jsoncre-opens that same pathname withis_file()/stat()/read_text(), none of which re-check where the name now points. - Why: during a root/admin all-users scan, the scanned user owns the directory and can replace
settings.json(or a parent component) with a symlink after the check passes. The privileged process then reads the new target. Practical impact is bounded — the target must still parse as a JSON object and containchat.*Copilot keys — so this leaks another user's VS Code posture rather than arbitrary files.⚠️ Exploitability depends on winning a short race against a scan the attacker does not schedule; I could not confirm a reliable trigger from the diff alone. - Fix: open once and validate the descriptor rather than the name —
os.open(path, O_RDONLY | O_NOFOLLOW), thenos.fstatand compare against the home-directory containment rule, and read from that fd. This closes the race and also removes the need for the separaterealpathpass. - Flagged by: this review; Greptile (P1, 2026-09-04T18:29:58Z on
c941fb9) — no maintainer response yet
- What:
Previously acknowledged (not re-flagged)
- Workspace
.vscodesymlink/junction walk follows links — maintainer removed the workspace walk entirely in883fab8; permission scope is user + profiles only. - MCP server objects copied verbatim into
raw_settings— fixed by_redact_mcp_secretswith a regression test. (The sibling policy-list path is a separate code path and is reported above.) - User settings symlink escaping the home directory — fixed in
c941fb9via_resolves_within, with a regression test. - Stable vs Insiders channel attribution mismatch — fixed in
c941fb9; the merge now bases on the most-permissive record sosettings_pathpoints at the real risk source. - Multi-user scan collapses to one permission record — maintainer accepted as an architectural limitation shared with the Cursor/Claude extractors, mitigated by returning the most-permissive user's record.
- No size cap on
read_text— addressed;_parse_jsoncskips files over 8 MB.
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head b262a1f8 · 2026-09-04T19:02Z
There was a problem hiding this comment.
Stale comment
Agentic security review of the Copilot VS Code permissions extractor on the current head. One medium finding remains: the new home-containment check is not bound to the later file open, so a privileged all-user scan can still follow a raced redirect.
Sent by Cursor Security Agent: Security Reviewer
A verdict may be a bare boolean or the object form VS Code also accepts
({"approve": true, "matchCommandLine": true}). Only booleans were read, so every
object-form entry was silently dropped — including an object-form false, which
hid a real terminal denial from the report. Read `approve` out of the object.
Adds an extended battery: terminal verdict shapes (object form, unknown shapes,
empty key, unicode, dedupe), mode edge cases (truthy non-boolean is not bypass,
legacy key), parse resilience (malformed / non-dict / missing / unreadable), and
channel+profile coverage (Insiders-only, riskiest channel attribution, 20-profile
merge determinism, stray file under profiles/, escaping profile-dir symlink).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Root bypasses file permissions, so chmod 000 does not make the file unreadable and the case asserts nothing. Surfaced by running the suite on the Linux runner, which executes elevated — the same way an MDM scan does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… scans The containment check resolved the path, then the read opened it again. In that window a user could swap their settings.json for a link out of their home, and an elevated scan would read and report a file they cannot see — attributed to their own in-home path. Proven with a regression test that fails on the previous commit. Open the file once and validate the descriptor actually held: it must be a regular file, its resolved path must stay inside the home, and that path's identity must match the open descriptor's — which refuses a file swapped either side of the open. An in-home symlink (stow/chezmoi) still resolves inside and is read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
3 findings — 1 high-confidence, 2 to triage. Reviewers: Claude (lead), Semgrep, Gitleaks.
-
[MEDIUM] 🔴
os.open()happens before any type or containment check —scripts/coding_discovery_tools/coding_tool_base.py:1163- What:
_read_containedopens the attacker-reachable path first (os.open(str(path), os.O_RDONLY)) and only checksS_ISREGand home-containment afterwards, on the already-open descriptor. - Why: opening a FIFO for read blocks until a writer appears. Under a root/admin all-user scan, any local user replaces their
Code/User/settings.jsonwithmkfifoand the whole discovery scan hangs indefinitely — theS_ISREGguard never runs because the open never returns. The same open-first order means a symlink to a character device is genuinely opened (open-time side effects) before the containment check rejects it. The oldpath.is_file()order could not hang, so this is introduced by the new reader. - Fix: open with
O_NONBLOCK(os.open(str(path), os.O_RDONLY | getattr(os, "O_NONBLOCK", 0))), keep the existingfstat/S_ISREGcheck, then clearO_NONBLOCKviafcntlbefore reading. Optionally addos.O_NOFOLLOWon the leaf and re-open in-home symlink targets deliberately. - Flagged by: this review (not seen by semgrep/gitleaks or the prior bot threads).
- What:
-
[MEDIUM] 🟡 Containment check accepts a hard link and never checks ownership —
scripts/coding_discovery_tools/coding_tool_base.py:1172- What: containment is
realpath(path)underrealpath(user_home)plus a(st_dev, st_ino)identity match. A hard link has no target path — it is an in-home path — so the check passes, and there is nost_nlinkorst_uidtest against the scanned user. - Why: on a shared host, a local user hard-links another user's
settings.jsoninto their own VS Code profile dir. The privileged scan reads the victim's Copilot posture (chat.tools.global.autoApprove, terminal allow/deny,chat.mcp.*) intoraw_settings, keepssettings_pathunder the planter's home, andfilter_tool_projects_by_userattributes it to the planter. Greptile notes the repo already has a link-count/ownership safeguard this reader skips. ⚠️ Exploitability is conditional: needs the same filesystem, traverse access into the victim's directory, and on Linuxfs.protected_hardlinks=1(the distro default) additionally requires the attacker to own or have read+write on the target. macOS lacks that sysctl.- Fix: after
fstat, rejectst.st_nlink > 1, and requirest.st_uidto match the scanned user's uid (skip the uid check on Windows, where the link-count check still applies tomklink /H). - Flagged by: this review + greptile (security, P2/summary).
- What: containment is
-
[LOW] 🟡 No size cap on the settings file read —
scripts/coding_discovery_tools/coding_tool_base.py:1221- What:
_parse_jsoncreads the whole file withhandle.read(), then runs_strip_jsonc_commentsand_strip_trailing_commasover it, with no byte limit. - Why: the sibling helpers in the same module deliberately cap reads (
_VSCODE_MCP_PROVIDER_CACHE_MAX_BYTES = 5 MB,_VSCODE_EXTENSION_STATE_MAX_BYTES = 1 MB); this path has no equivalent. Any local user plants a multi-GBsettings.json(or a profile full of them) and the privileged scan holds several copies in memory through the regex passes andjson.loads— memory exhaustion or a very long stall in the discovery agent. Values are also copied verbatim intoraw_settingsand shipped to the backend. - Fix:
fstatsize is already available in_read_contained— bail out above a cap in the same style as the existing constants, and read at most that many bytes. - Flagged by: this review.
- What:
Previously acknowledged (not re-flagged)
- Multi-user records collapsed to one (
max(records)) — greptile P1, raised 4×; the author states the rationale in code atcoding_tool_base.py("the canonical row carries ONE permissions record… return the MOST-PERMISSIVE user's record so a YOLO user is never hidden behind a benign one") and locks it withtest_multi_user_scan_surfaces_the_riskiest_user. Accepted design choice. - Stable and Insiders channels merged into one record — greptile P1; documented in the class docstring and asserted by
test_riskiest_channel_wins_and_is_attributed, which requiressettings_pathto point at the channel carrying the risk. Accepted design choice. - Workspace
.vscode/settings.jsonsymlink/junction follow — cursor MEDIUM (commits064cbf82,d30333c4); the workspace walk is no longer in the diff, and the PR body documents workspace scope as deliberately excluded. - Containment TOCTOU between validation and read — greptile P1 + cursor MEDIUM (
883fab85,d30333c4); addressed in the current diff by_read_contained, which validates the open descriptor and compares(st_dev, st_ino)against the in-home path.
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head 190e99ed · 2026-09-04T20:12Z
A hard link keeps its target's owner while its path stays inside the home, so realpath containment cannot see through one: a link placed in a scanned home pointed at a file outside it, and the foreign contents were reported as that user's Copilot permissions. Require the open descriptor's uid to match the home directory's owner, which a hard link to a foreign file cannot satisfy. (st_uid is 0 for every file on Windows, where the comparison is a no-op.) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review disposition — as of
|
| Finding | Disposition |
|---|---|
| Containment race during privileged reads | Fixed in 190e99e. The check resolved the path, then the read opened it separately; a user could swap their settings.json for an out-of-home link in that window and an elevated scan would report a file they cannot see, attributed to their own in-home path. Now the file is opened once and the descriptor actually held is validated: regular file, resolved path inside the home, and that path's (st_dev, st_ino) must match the descriptor's — refusing a swap on either side of the open. In-home stow/chezmoi symlinks still work. Guard proven to fail on 3714820. |
| Foreign hard links bypass containment | Fixed in d537ebb. A hard link keeps its target's owner while its path stays inside the home, so realpath containment is blind to it. Now the descriptor's uid must match the home directory's owner. Proven on the Linux runner with real privilege separation: a root-owned file hard-linked into /home/ubuntu → before: leaked root content; after: refused. (st_uid is 0 for all files on Windows, where the comparison is a no-op.) |
Also fixed this round (found by extended testing, not by review)
| Finding | Disposition |
|---|---|
Object form of chat.tools.terminal.autoApprove silently dropped |
Fixed in e55bb0b. VS Code also accepts {"approve": true, "matchCommandLine": true}; only bare booleans were read, so every object-form entry vanished — including an object-form false, which hid a real terminal denial. Verified on real Windows/Linux: deny now contains Bash(curl | sh *). |
| Unreadable-settings case asserted nothing as root | Fixed in 3714820 — root bypasses chmod, so the case is skipped when elevated (the shape an MDM scan actually runs in). |
Held — documented, fleet-wide behaviour
| Finding | Disposition |
|---|---|
| Multi-user permissions collapse to one record | Not introduced here. transform_settings_to_backend_format states it "selects the highest precedence settings file … No merging is performed", so every tool (Claude, Augment, Copilot CLI, Cursor) collapses to a single permissions dict, which filter_tool_projects_by_user then keeps for one user. This extractor differs only in the tiebreak: siblings pick highest-precedence scope, this picks most-permissive — and since every record here is scope: "user", precedence could not discriminate anyway, so the tiebreak is strictly safer (a YOLO user can never be hidden behind a benign one). Changing it means changing the transform, the per-user filter and the backend record shape for all tools — tracked separately, not smuggled into this PR. |
| Stable and Insiders merged | Deliberate. For a security-posture tool, if any installed channel carries a YOLO config the device has a YOLO Copilot; settings_path attributes to the channel that carries the risk. Reporting one channel could hide a risk present in the other. |
Verification on d537ebb
39 tests. macOS 39 passed · Linux VM 38 passed, 1 skipped · Windows VM 33 passed, 6 skipped. The extended battery added terminal verdict shapes, mode edge cases, parse resilience, channel/profile coverage (Insiders-only, riskiest-channel attribution, 20-profile determinism, stray entries under profiles/), and both containment guards.
There was a problem hiding this comment.
Stale comment
Agentic security review for PR 285 (head
d537ebb). One medium finding remains: Windows privileged reads of Copilotsettings.jsonomit the repo'sst_nlink > 1hard-link guard while the new uid check is a documented no-op on NT. Prior TOCTOU betweenrealpathandread_textis addressed by the single-open_read_containeddescriptor checks.Sent by Cursor Security Agent: Security Reviewer
Disposition for the two remaining findings (
|
| Machine | Stable Code | Insiders |
|---|---|---|
| dev Mac | yes | no |
win runner (unbound) |
yes | no |
win runner (unbound.poc-win-runner) |
yes | no |
linux runner (root) |
yes | no |
No dual-channel install anywhere, and the one VS Code profile present uses useDefaultFlags — it inherits the default settings and has no settings.json of its own, so it produces no divergent record either. The merge therefore almost never fires.
Where it could fire, the behaviour is deliberate for a security-posture tool: if any installed channel or profile carries an auto-approve config, the device has one, and settings_path attributes to the source that carries the risk rather than to the first file read.
That said, the fleet's "report as-is, never merge" rule is the better long-term shape — reporting one real file unchanged rather than a union. Tracking that separately rather than churning a green PR for a scenario that does not occur on any observed machine.
State on d537ebb
CI green (6/6 matrix + Cursor + Greptile, 0 failures). 39 tests: macOS 39 passed · Linux VM 38 passed, 1 skipped · Windows VM 33 passed, 6 skipped.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
2 findings — 1 high-confidence, 1 to triage. Reviewers: Claude (lead), Semgrep, Gitleaks.
-
🔴 [MEDIUM] Privileged scan can be hung indefinitely by a FIFO planted at
settings.json—scripts/coding_discovery_tools/coding_tool_base.py:1180- What:
_read_containedcallsos.open(str(path), os.O_RDONLY)before checkingstat.S_ISREG, and withoutO_NONBLOCK, so a non-regular file is opened first and rejected only afterwards. - Why: On POSIX, opening a FIFO read-only blocks until a writer appears. Any unprivileged user can run
mkfifo ~/.config/Code/User/settings.json(or in aprofiles/<id>/dir). During a root/all-user scan the extractor blocks forever on that user's home, stalling the rest of the discovery run — the whole scan, not just that user. The old code path (_parse_jsoncwithoutuser_home) usedpath.is_file(), which returnsFalsefor a FIFO; this new path removed that pre-open type gate. - Fix: Add
os.O_NONBLOCKto the open flags on POSIX (getattr(os, "O_NONBLOCK", 0)), which makes a FIFO open return immediately, and keep the existingS_ISREGrejection right afterfstat.O_NOFOLLOWis not needed given the realpath + dev/ino checks, but addingO_NONBLOCKis the minimal fix. - Flagged by: Claude (lead). Semgrep/Gitleaks: no findings.
- What:
-
🟡 [LOW]
settings.jsonis read with no size cap —scripts/coding_discovery_tools/coding_tool_base.py:1198- What:
handle.read()slurps the whole file, then_strip_jsonc_comments/_strip_trailing_commas/json.loadseach build further copies of it. - Why: A user can plant a multi-GB
settings.json(or one per profile) and drive the privileged scan into memory exhaustion. The sibling VS Code helpers in the same codebase already cap this kind of read —_VSCODE_EXTENSION_STATE_MAX_BYTES(1 MB) and_VSCODE_MCP_PROVIDER_CACHE_MAX_BYTES(5 MB) inmcp_extraction_helpers.py— so this new read is the odd one out. - Fix: Reuse the existing pattern: check
st.st_size(already available from thefstat) against a new module constant, e.g. 1 MB, and returnNonewith a debug log when it is exceeded. - Flagged by: Claude (lead). Semgrep/Gitleaks: no findings.
- What:
Previously acknowledged (not re-flagged)
- Symlink / junction following on
settings.jsonand profile dirs (cursor MEDIUM ×4, greptile P1 security) — fixed in this diff._read_containednow opens the file once, then validates the descriptor withfstat+realpathcontainment + owner-uid check +dev/inoidentity comparison, with tests covering the escaping-symlink, escaping-profile-dir, and hard-link-owner cases. - Containment check races the file read (TOCTOU) (greptile P1 security, cursor MEDIUM) — fixed in this diff. The dev/ino comparison between
fstat(fd)andstat(realpath(path))rejects a file swapped on either side of the open;TestContainmentRaceexercises exactly this swap. - Multi-user permission records collapsed to one (greptile P1 ×4) — author's documented design: the canonical VS Code row carries one permissions record, and
max(records, key=_permissiveness)deliberately surfaces the riskiest user rather than the first. Reporting-coverage tradeoff, not a security defect. - Stable Code and Insiders settings merged into one posture (greptile P1) — author's documented design: all channels and profiles are merged to the most-permissive posture, with
settings_pathattributed to the channel that carries the risk (test_riskiest_channel_wins_and_is_attributed).
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head d537ebb8 · 2026-09-04T21:22Z
|
Checked this against the VS Code 1.136 registry and the current docs. Key set, per-OS paths, profile enumeration and the skills paths all hold up, and the object form is right (microsoft/vscode#270641 confirms Six things I'd want in before merge:
Description also still promises the On Greptile's multi-user finding: it's real, but I'd ticket it rather than block on it. One correction to the defence though. Claude Code, Cursor CLI, Copilot CLI and Augment all return I checked gateway-data and unbound-fe: no coordinated change needed. Worth separate tickets: managed policy lives outside |
Six review findings, each verified against the VS Code 1.136 configuration registry rather than taken on faith: - `chat.tools.terminal.enableAutoApprove` is the master switch for terminal auto-approval. With it off VS Code approves nothing, so the allow patterns are inert and are no longer reported as exposure; denials still stand. - `chat.permissions.default` is what the permissions picker writes. Its elevated levels (`autoApprove`, `autopilot`) now map to bypassPermissions, so someone parked on Bypass Approvals no longer reads as default. - `chat.tools.edits.autoApprove` does exist, which is why the `acceptEdits` rank was never assigned. It is now read and mapped. - A FIFO planted at `settings.json` hung a privileged scan indefinitely. The open takes O_NONBLOCK and enumeration takes `is_file()`, not `exists()`. - The hard-link defence rested on `st_uid`, which is 0 for every file on Windows. `st_nlink` now carries it on every platform, matching the sibling privileged readers. - `unittest.main()` sat mid-file, so a direct run of the test file executed 18 of 39 tests and silently skipped the containment suites. Comments across the extractor are trimmed to the decision they record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review disposition — as of
|
| Finding | Disposition |
|---|---|
chat.tools.terminal.enableAutoApprove never checked — the key sat in SECURITY_RELEVANT_KEYS but _terminal_rules ignored it, so a user with the master switch off still shipped allow_rules, and the backend deriver scored them for command execution. |
Fixed. Registry confirms it: {restricted: true, type: "boolean", default: true, policy: ChatToolsTerminalEnableAutoApprove, minimumVersion: "1.104"}, and the analyzer gates on it twice — S = getValue(…) === true; S && a ? … : a = false. With it off, the allow patterns are inert, so they are dropped. Denials are kept — nothing is auto-approved, so they still hold. TestTerminalMasterSwitch. |
chat.permissions.default not read — someone parked on Bypass Approvals read as default. |
Fixed. Registry: {type: "string", enum: ["default","autoApprove","autopilot"], default: "default"}, read by getDefaultPermissionLevel(). Both elevated levels now map to bypassPermissions. TestPermissionLevelAndEdits. |
chat.tools.edits.autoApprove claimed not to exist — which is why the acceptEdits rank in _permissiveness was unreachable. |
Fixed. It exists (type: "object", additionalProperties: boolean, scope APPLICATION). Now read and mapped: any glob set true → acceptEdits, with global auto-approve still outranking it. |
One correction to the finding, though. {"**/*": true} is not a .env bypass — it is the shipped default. VS Code's default value is {"**/*": true} plus a deny-list that already covers .env, package.json, lockfiles, .git/**, project files and more. So the risky edit posture is a user re-enabling one of the denied patterns, not the wildcard itself. The exact globs go to the backend verbatim in raw_settings, so that distinction is visible to an analyst rather than flattened by us.
Fixed — security
| Finding | Disposition |
|---|---|
FIFO at settings.json hangs the scan (@anonpran #4, and consensus 🔴 MEDIUM) — os.open() ran before the S_ISREG check, and a read-only FIFO open blocks until a writer appears. Not one user's scan: the whole run, holding the LIVE lock. |
Fixed. O_NONBLOCK on the open (a no-op for the regular files we keep), and enumerate_vscode_user_files takes is_file() instead of exists() in the default-profile branch, matching the profiles branch beside it. This also removes the same hang from the mcp.json path. Reproduced: on d537ebb the test thread was still alive at the 15 s join; now it returns None immediately. TestNonRegularFiles. |
Hard-link defence is a no-op on Windows (Cursor MEDIUM, open on d537ebb) — it rested on st_uid, which is 0 for every file there. |
Fixed. st_nlink > 1 now carries it on every platform, which is exactly what the sibling privileged readers do (utils.py:1379, plugin_extraction_helpers.py:732, _is_foreign_hardlink). Reproduced with a real hard link: on d537ebb the linked out-of-home content came back in the record ("chat.mcp.access": "OUT-OF-HOME"); now it is refused. TestHardLinkContainment. |
Fixed — test hygiene
| Finding | Disposition |
|---|---|
unittest.main() mid-file — python tests/test_copilot_vscode_permissions.py ran 18 of 39 and silently skipped the containment, ownership and profile suites. |
Fixed. Moved to the end. Direct run now executes all 50; pytest agrees. |
Accepted limitation
| Finding | Disposition |
|---|---|
Root-owned settings.json is dropped (@anonpran #5) — the uid check refuses it. |
Kept, but no longer silent (debug → info, so it appears in the scan log). Two reasons for keeping the drop: it is the fleet pattern — utils.py:1382 applies the same uid-equality rule in the same situation — and real managed policy does not live at this path anyway. It lives in HKLM\SOFTWARE\Policies, com.github.copilot prefs and /etc/github-copilot/managed-settings.json, which is a separate extraction surface and now its own ticket. A root-owned file at the user path is a provisioning script that forgot to chown. |
Greptile — still 3/5, both points stand
| Finding | Disposition |
|---|---|
| Multi-user records collapse to one | Accepted, ticketed. One correction to my earlier defence in this thread, which overstated it: Copilot CLI, Augment and Claude Code extractors all return List[Dict] and keep every user; only Cursor IDE collapses. So it is a known weakness with one precedent, not the convention. What is fleet-wide is the layer below — transform_settings_to_backend_format "selects the highest precedence settings file… No merging is performed", so even a List[Dict] becomes one record in the payload. Fixing this properly means per-user records end to end, which is a backend change, not a rename here. max(records, key=_permissiveness) means the collapse surfaces the riskiest user rather than the first. |
| Stable and Insiders merged into one posture | Accepted. Merged to the most-permissive posture with settings_path attributed to the channel carrying the risk (test_riskiest_channel_wins_and_is_attributed). No machine observed in the fleet has both channels installed. |
Description
Corrected: the chat.mcp.allowedServers → mcp_tool_allowlist row promised a mapping af2550f removed (those keys ride in raw_settings as context), the file-edit line is gone, and the mapping table and test count now match the code.
🤖 Generated with Claude Code
Addendum — consensus round on a4c22ce
anonpran approved. CI is green on all six matrix legs plus both bots. The consensus reviewer returned 3 findings, 0 high-confidence, all to triage (the MEDIUM is now fixed), and confirmed the four security fixes (symlink escape, hard link, TOCTOU, FIFO) as closed. Dispositions:
| Finding | Disposition |
|---|---|
[MEDIUM] No size cap on the settings.json read — a local user can plant a multi-GB file in their own home and OOM a privileged scan. |
Fixed in b41d2cc. Capped at 5 MB, matching the largest sibling VS Code read cap (_VSCODE_MCP_PROVIDER_CACHE_MAX_BYTES), checked against the st_size already in hand from the fstat. That is ~1000x a real settings.json, so it cannot truncate anything a customer wrote — a test proves a 2 MB file still parses. Measured on a 3 GB sparse file: uncapped the read peaked at ~12 GB RSS over 84 s (the strip and parse passes each hold another copy); capped it returns immediately. |
[LOW] _parse_jsonc(user_home=None) is an insecure default |
Refuted. The unguarded branch is more guarded than the fleet, not less: the sibling _parse_jsonc in macos/augment/ and macos/copilot_cli/ take a bare path and read it with no containment at all, unconditionally. The sole production caller here always passes user_home. Making it required would force the parse-resilience fixtures to live inside the real user home, which is a worse trade than the footgun it removes. |
| [LOW] Terminal rule patterns are copied verbatim into the record | Accepted as intended. This is the same call already made on MCP server URLs in this PR: extractors report configuration as written, and every sibling ships raw_settings verbatim. Redacting here would make the Copilot record the only one in the fleet that silently alters what the customer configured, and a rule we rewrite is a rule an analyst cannot match against what is on disk. |
Greptile holds at 3/5 on a4c22ce for the same two reporting-fidelity points — both accepted above and ticketed. No new security findings this round.
Addendum — Greptile round on 8badd39
| Finding | Disposition |
|---|---|
| P1 security — "Windows containment remains pathname-based" (a parent junction swapped after the open) | Fixed in 8badd39, and the scenario did not reproduce. On the real Windows runner as SYSTEM, with the guard disabled, a genuine mklink /J parent leaked nothing — ntpath.realpath resolves junctions on 3.8+ and containment already refused it. The residual is realpath's unresolved-path fallback, which I could not reach deterministically. The fix makes the refusal independent of realpath by checking the parent chain directly on Windows; POSIX is untouched so stow/chezmoi keeps working. Handle-anchored containment via GetFinalPathNameByHandleW was not added: it needs ctypes, no sibling reader does it (utils.py:1370 is pathname-based in the same situation), and it belongs to a repo-wide decision. Full thread |
| Score dropped 3/5 → 1/5 | No new evidence behind it. The two reporting-fidelity points are unchanged and ticketed; the third is the junction item above, now guarded. CI is green on 8badd39 across all six matrix legs plus both bots. |
VM battery for this head is here — including the Windows hard-link leak reproduced and fixed, which is the one that was genuinely exploitable.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
3 findings — 0 high-confidence, 3 to triage. Reviewers: claude-opus-5 (lead), semgrep (0 findings), gitleaks (0 secrets).
-
[MEDIUM] Unbounded read of an attacker-influenceable
settings.json—scripts/coding_discovery_tools/coding_tool_base.py:_read_contained/_parse_jsonc- What: The new read path has no size cap —
handle.read()pulls the whole file, then_strip_jsonc_comments/_strip_trailing_commas/json.loadseach hold another copy. - Why: Under a root/admin all-user scan, any local user can plant a multi-GB regular file at
~/.config/Code/User/settings.json(or in anyprofiles/<id>/) inside their own home. It passes every containment, ownership andnlinkcheck because it genuinely is their own file, so the privileged scanner reads it and can OOM. That kills the scan for every other user on the host. The same file inmcp_extraction_helpers.pyalready establishes the opposite pattern:_VSCODE_MCP_PROVIDER_CACHE_MAX_BYTES = 5 * 1024 * 1024and_VSCODE_EXTENSION_STATE_MAX_BYTES = 1024 * 1024. - Fix: Cap the read in
_read_contained— checkst.st_sizeagainst a constant (a few hundred KB is generous forsettings.json) right after thefstat, and returnNonewith a log line when it is over. Reuse the constant style already inmcp_extraction_helpers.py. - Flagged by: claude-opus-5
- What: The new read path has no size cap —
-
[LOW]
_parse_jsoncdefaults to skipping every containment check —scripts/coding_discovery_tools/coding_tool_base.py:_parse_jsonc- What:
user_homedefaults toNone, and that branch reads with plainpath.is_file()/path.read_text()— no descriptor check, no realpath containment, no ownership check, no hard-link check. - Why: Not exploitable today; the only production caller (
_extract_for_user) always passesuser_home, and theNonebranch is exercised only by tests. It is an insecure default on a@classmethodthat sits in a shared base class, so the next caller silently gets the unguarded read that this PR spent most of its review cycles closing. - Fix: Make
user_homea required positional argument and update the tests to pass a temp home, or invert the default so the unguarded branch has to be requested explicitly (e.g.allow_uncontained=False). - Flagged by: claude-opus-5
- What:
-
[LOW] User-authored command strings are copied verbatim into the transmitted record —
scripts/coding_discovery_tools/coding_tool_base.py:_terminal_rules/_build_record- What: Every key of
chat.tools.terminal.autoApprovebecomes aBash(<pattern> *)rule and is also kept whole inraw_settings, which then ships to the backend on the canonical Copilot row. - Why: With
matchCommandLine, those keys are full command lines a developer wrote themselves — an auto-approvedcurl -H "Authorization: Bearer …"or a command with an inline token ends up in the discovery report and anywhere that report is stored or rendered. The fixed 17-key allowlist correctly prevents arbitrary settings from leaking; these two fields are the one place free-form user text still flows through. - Fix: Either truncate/redact rule patterns above a short length, or run the pattern through a simple secret-shaped regex (
Bearer\s+\S+,sk-[A-Za-z0-9]{20,},--password[= ]\S+) and replace the match before it reachesallow_rules/deny_rules/raw_settings. - Flagged by: claude-opus-5
- What: Every key of
Previously acknowledged (not re-flagged)
- Symlink / junction escape on the settings leaf and the profile dir (cursor, greptile) — fixed in the current tree:
_read_containedopens the descriptor, requiresS_ISREG, and refuses anything whoserealpathfalls outside the scanned home. - Hard link to another user's settings file (cursor) — fixed:
st_nlink > 1refusal, backed byTestHardLinkContainment. - Containment check races the file read (TOCTOU) (greptile, cursor) — fixed: the
(st_dev, st_ino)comparison between the held descriptor and the re-stat rejects a file swapped inside the window. Covered byTestContainmentRace. - FIFO planted at
settings.jsonhangs the scan — fixed:O_NONBLOCKon open plus theis_file()change inenumerate_vscode_user_files. Covered byTestNonRegularFiles. - Multi-user records collapsed to one; stable and Insiders merged without channel context (greptile P1, repeated) — reporting-completeness issues, not security ones, and the author states the intended behaviour in code (
max(records, key=self._permissiveness), "the canonical row carries one permissions record"). Out of scope for this review; still worth a maintainer decision on the product question.
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head a4c22ce6 · 2026-09-05T08:12Z
…emory A local user can plant a huge regular file at their own settings.json. It passes every containment, ownership and link check because it genuinely is their file, so a privileged scan reads it and can take the whole run down with it — not just that user's. The cap is 5 MB, matching the largest sibling VS Code read cap. A real settings.json is a few KB, so this is roughly a thousand times headroom and cannot truncate anything a customer actually wrote; the accompanying test proves a 2 MB file still parses. Measured on a 3 GB sparse file: uncapped the read peaked at ~12 GB RSS over 84 seconds, since the strip and parse passes each hold another copy. Capped it returns immediately. 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. |
Containment resolved the pathname with realpath, which silently returns the path unchanged when it cannot resolve a reparse point. A junction planted between the home and settings.json therefore redirected a privileged read out of the home while the recorded path still looked in-home, and the ownership check cannot catch it because Windows reports zero for those fields. The parent chain from the home down is now checked directly on Windows, where VS Code never creates a junction in its own config tree. POSIX is unchanged and still resolved by realpath, so a stow/chezmoi symlink into the home keeps being read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VM battery — Stage 1 + Stage 2 on
|
| Leg | Environment | Result |
|---|---|---|
| Unit suite | macOS (local) | 53 passed, 2 skipped (skips are the Windows-junction cases) |
| Unit suite | sentinel-agent-runner, as root |
52 passed, 3 skipped |
| Unit suite | poc-win-runner, as NT AUTHORITY\SYSTEM |
47 passed, 8 skipped — the two junction tests ran and passed here |
| CI | GitHub matrix | 6/6 + Cursor + Greptile, all green |
Multi-user privileged scan — real accounts, real separation
Three synthetic users with distinct UIDs on Linux, three profile trees on Windows. Both runners agreed:
| Check | Linux (root) | Windows (SYSTEM) |
|---|---|---|
FIFO planted at settings.json |
no hang, 0.0 s | n/a (POSIX) |
| Riskiest user wins, attributed to their own file | ✓ …/cdtu2/…/profiles/yolo/settings.json |
✓ …\winu2\…\profiles\yolo\settings.json |
Object-form {"approve": true} allow read |
✓ Bash(npm run *) |
✓ |
| Hard link to a file the user cannot read | not leaked (root-owned) | not leaked (SYSTEM-owned) |
3 GB planted settings.json |
not read | not read |
| Master switch off → allows dropped, denies kept | ✓ curl gone, rm + rm -rf kept |
✓ same |
mcp.json enumeration after the is_file() change |
✓ still found | ✓ still found |
Per-user %APPDATA% resolved from each home |
n/a | ✓ never systemprofile |
Picker level and edit auto-approval verified on Linux against a real tree: chat.permissions.default: autopilot → bypassPermissions, chat.tools.edits.autoApprove alone → acceptEdits, and the pre-rename chat.tools.autoApprove → bypassPermissions (backward compatibility for installs written before the key was renamed).
End to end through the real CLI
python -m scripts.coding_discovery_tools.ai_tools_discovery --api-key … --domain … on the Linux runner as root, against a local capture stub — a real Copilot extension footprint planted first, no traffic left the host.
exit=0 captured POSTs: 37
path : /api/v1/ai-tools/report/
tool : GitHub Copilot Chat (VS Code)
mode : bypassPermissions
settings_path : /home/ubuntu/.config/Code/User/profiles/e2e_yolo/settings.json
allow : ['Bash(git status *)']
deny : ['Bash(curl | sh *)', 'Bash(rm *)']
source/scope : user / user
raw keys : chat.permissions.default, chat.tools.edits.autoApprove,
chat.tools.global.autoApprove, chat.tools.terminal.autoApprove
settings_source: user satisfies the ['user','project','managed'] validation in _process_permissions, and both new keys ride through to the backend in raw_settings.
Cross-repo consumer, executed not assumed
Ran ai-gateway-data's real permissions_risk_deriver against the records this PR now emits:
| Record | Derived autonomy |
|---|---|
bypassPermissions + scoped allows |
full_auto (risk score 7) |
acceptEdits — newly reachable |
auto_edit |
default, no rules |
ask_first |
default + Bash(git status *) |
ask_first |
acceptEdits lands as auto_edit exactly as the deriver intends, and permission_mode is a CharField(max_length=50) that already documents the value — no backend or migration change needed.
Guards proven to fail without the fix
Both on the real Windows runner, by disabling only the guard under test:
| Guard | Without it | With it |
|---|---|---|
st_nlink > 1 (hard link) |
leaked a SYSTEM-only file as the user's posture — st_uid = 0, so the uid check is a no-op there |
refused |
| Parent-chain junction check | did not leak — realpath already resolved the junction |
refused |
The second row is recorded honestly: the junction scenario did not reproduce, so 8badd39 is defence-in-depth against realpath's unresolved-path fallback rather than a fix for a demonstrated leak. Detail in the thread reply.
Residual risk and hygiene
- Every fixture, synthetic user and planted file was torn down on both runners; residue checks came back clean. The Linux runner keeps a
~/.unbound/discovery-cache.jsonwritten by the real run — the tool's own state on a dedicated test host. - Not tested: Greptile's remaining two points (multi-user collapse, channel merge) are unchanged by design and ticketed; live
stagingis out of scope until this merges.
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
1 finding — 0 high-confidence, 1 to triage. Reviewers: Claude (lead), Semgrep, Gitleaks.
- [LOW] Symlink and hard-link guards apply only through
extract_settings(), not to direct_parse_jsonc(path)calls —scripts/coding_discovery_tools/coding_tool_base.py:1225- What:
_parse_jsonconly runs the containment and ownership checks when a caller passesuser_home; withuser_home=Noneit falls back to plainpath.is_file()/path.read_text(), which follow symlinks and junctions. - Why: The extractor path always passes
user_home, so this is not exploitable today. It is a footgun: any future caller (or subclass) that reuses this helper without the second argument silently loses every protection this PR added, including the size cap and the FIFO guard. - Fix: Make
user_homerequired, or route the unguarded branch through_read_containedwith the file's own resolved parent, so the safe path is the only path. - Flagged by: Claude (lead)
- What:
Previously acknowledged (not re-flagged)
- Workspace
.vscode/settings.jsonsymlink/junction follow (cursor, greptile, multiple commits) — the PR no longer reads workspace settings at all; the author documented user + profile scope as deliberate, and the diff contains no workspace walk. - Containment check races the file read (TOCTOU) (greptile P1, cursor MEDIUM on
coding_tool_base.py:1240) — addressed in the reviewed diff:_read_containednow opens one descriptor withO_RDONLY|O_NONBLOCK, checksfstatfor regular-file, size,st_nlink, andst_uid, and re-compares(st_dev, st_ino)against the realpath target before reading, so a swap after the open is refused. TestTestContainmentRacecovers it. - Windows junction in the parent chain (cursor, greptile) — addressed by
_parents_are_direct, which walks parents to the home and refuses any reparse point on Windows. - Unresolved
settings_pathused as ownership identity (cursor CONFIRMED,coding_tool_base.py:1312) — the underlying read is now containment- and uid-checked, so an out-of-home target can no longer be attributed to the planter. - Multi-user records collapsed to one / most-permissive
max()(greptile P1, raised four times) — the author's stated design: the canonical VS Code row carries exactly one permission record, andmax()by permissiveness is a deliberate choice so the riskiest posture is never hidden. Correctness/coverage concern, not a security vulnerability introduced by this diff. - Stable and Insiders channels merged into one posture (greptile P1) — same stated design: all channels and profiles merge to the most-permissive posture, documented in the PR body and in the class docstring.
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head 8badd390 · 2026-09-05T09:14Z
Two reporting defects that made the record say something the machine did not. An elevated all-users scan collapsed every user to one record, and the per-user report filter then removed it from everyone the record did not belong to. A host with a locked-down user and a user running auto-approve reported the second posture once and nothing at all for the first. The extractor now returns one record per user, riskiest first, and the per-user filter picks the record whose settings file lives in that user's home. Profiles also merged across stable Code and Code Insiders, so a reported record could carry stable's deny rules under an Insiders settings path — a posture neither installation defines. Profiles now merge within a channel only, and the riskiest channel is reported whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both remaining Greptile P1s fixed in
|
| Leg | Result |
|---|---|
| macOS local | 56 passed, 2 skipped |
sentinel-agent-runner, as root |
55 passed, 3 skipped |
poc-win-runner, as NT AUTHORITY\SYSTEM |
50 passed, 8 skipped |
| Related suites (962 selected) | no new failures — the only reds are test_copilot_cli_discovery and test_discovery_flow, both failing identically on staging on this machine |
Fixtures and synthetic users torn down on both runners.
Added for a reported junction bypass that did not reproduce. On the Windows runner as SYSTEM, with the guard disabled, a real mklink /J parent leaked nothing: realpath resolves junctions on the Python versions we support and the existing containment check already refused it. It was also the only check in this reader with no precedent in the repo — the sibling privileged reader in utils.py validates the descriptor the same way without it. A guard for a case that cannot be produced is cost without cover. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
4 findings — 1 high-confidence, 3 to triage. Reviewers: Claude (lead), Semgrep, Gitleaks.
-
🟡 [MEDIUM] Privileged
os.open()happens before any validation —scripts/coding_discovery_tools/coding_tool_base.py:1172- What:
_read_containedopens the attacker-reachable path first, then checks file type, containment, ownership and inode. Every guard runs after the descriptor already exists. - Why: The enumeration
is_file()filter rejects a device node, but the swap window the class already defends against (seeTestContainmentRace) also lets a local user replace the leaf with a symlink to a special file between enumeration and read. A root scan then opens it.O_NONBLOCKprevents the hang, but opening some device nodes has side effects at open time (the classic case is/dev/watchdog, where a root open arms a reboot timer). No post-open check can undo that. - Fix: Open the leaf with
os.O_NOFOLLOWand do arealpathcontainment check before the open, keeping the existing fd re-checks as the anti-race layer.O_NOFOLLOWstill permits the supported in-home stow/chezmoi case if you resolve and re-open the target explicitly. - Flagged by: Claude (lead) only — distinct from the earlier bot reports, which concerned checks after the open.
- What:
-
🔴 [LOW] The 5 MB read cap is advisory and can be exceeded —
scripts/coding_discovery_tools/coding_tool_base.py:1198- What: The size cap is enforced against
st.st_sizefromos.fstat, but the read is an unboundedhandle.read(). - Why: A local user can plant a small
settings.json, let thefstatpass, then append to the same inode beforeread()returns. The scan pulls the whole file into memory, so the cap does not actually bound memory during a privileged all-user scan. - Fix: Read a bounded amount and reject anything longer:
raw = handle.read(_VSCODE_SETTINGS_MAX_BYTES + 1), then returnNoneiflen(raw) > _VSCODE_SETTINGS_MAX_BYTES. - Flagged by: Claude (lead) — provable directly from the diff.
- What: The size cap is enforced against
-
🟡 [LOW]
_parse_jsonchas an unguarded mode that skips every protection —scripts/coding_discovery_tools/coding_tool_base.py:1252- What: When
user_homeisNone, the method falls back topath.is_file()+path.read_text()— no containment, no ownership check, no size cap. - Why: Not reachable from today's production path (
_extract_for_useralways passesuser_home), so this is hardening, not a live bug. But it is a protected-by-default API where the unsafe mode is the shorter call, so a future caller loses all four guards silently. - Fix: Make
user_homerequired, or raise/returnNonewhen it is missing and update the tests to pass a home. - Flagged by: Claude (lead) only.
- What: When
-
🟡 [LOW] Verbatim terminal-command patterns are shipped to the backend —
scripts/coding_discovery_tools/coding_tool_base.py:1276- What:
raw_settingscopies the allowlisted keys verbatim, andallow_rules/deny_rulesembed thechat.tools.terminal.autoApprovekeys as-is. - Why: Those keys are user-authored command patterns, and VS Code's
matchCommandLinemode encourages full command lines. A pattern likecurl -H "Authorization: Bearer …"would be copied into the discovery report and sent upstream. The key allowlist correctly keeps the rest ofsettings.jsonout, so the exposure is limited to this one value shape. - Fix: Redact patterns matching common secret shapes (bearer tokens,
sk-/ghp_prefixes,--password/--tokenflags) before they enterraw_settingsand the rule strings, reusing whatever redaction the Cursor extractor path already applies if one exists.⚠️ Whether users actually put secrets in these patterns is unverified — this is a plausible class, not an observed instance. - Flagged by: Claude (lead) only.
- What:
Previously acknowledged (not re-flagged)
- Workspace
.vscode/settings.jsonsymlink/junction walk (cursor,coding_tool_base.py:1163/:1181) — the workspace walk was removed; the author documents user + profile scope as the authoritative surface because these keys are application-scoped andrestricted. - Profiles merged to the most-permissive posture (implied over-report) — stated design choice: a locked-down default with a YOLO named profile is a real risk the report must surface.
- Multi-user records collapsed to one (greptile P1, repeated) — resolved in this diff by
_permissions_by_userplus the per-user filter inai_tools_discovery.py. - Stable/Insiders channel policies merged (greptile P1) — resolved:
_extract_for_usernow merges only within a channel and picks one channel whole. - Symlink/junction follow and unresolved
settings_pathattribution (cursor, multiple) — resolved by_read_contained's realpath containment,st_nlink, uid, and post-open dev/ino re-check. The remaining gap is the pre-validation open above, not the checks these comments asked for.
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head 5f291676 · 2026-09-05T10:29Z


The discovery scan reported GitHub Copilot (VS Code) with rules, MCP servers, and skills — but never its permissions. A user who had turned on Copilot's "auto-approve every tool" mode looked identical to a locked-down one. This closes that gap.
Copilot has no dedicated permissions file — its agent-mode controls live in VS Code's
settings.json(JSONC). This reads the security-relevant keys and emits the same backend-ready permission record the Cursor extractor produces, so it routes as tool-levelpermissionswith no backend or frontend change.Every key verified against the actual VS Code binary
The key set was validated against the VS Code 1.136 configuration registry (workbench bundle + Copilot extension
package.json) — not blogs. Five keys that appear in blog/Business-era write-ups but the tool never registers were dropped (github.copilot.chat.agent.autoApproveFileChanges/autoApproveTerminal/terminalCommands.blocklist/agent.enabled, and the pre-renamechat.tools.autoApprovekept only as a legacy alias). Reading them would have over-reported settings the tool ignores.Extracted (all confirmed real):
chat.tools.global.autoApprove,chat.permissions.default,chat.tools.eligibleForAutoApproval,chat.tools.terminal.autoApprove(+enableAutoApprove),chat.tools.edits.autoApprove,chat.tools.urls.autoApprove,chat.agent.enabled/sandbox.enabled/networkFilter/allowed+deniedNetworkDomains,chat.mcp.access/allowedServers/deniedServers,github.copilot.chat.claudeAgent.enabled.chat.tools.global.autoApprove: truepermission_mode: bypassPermissionschat.tools.terminal.autoApproveallow/denyallow_rules/deny_rules(Bash(<cmd> *), regex/…/stripped)chat.permissions.default: "autoApprove"/"autopilot"permission_mode: bypassPermissionschat.tools.edits.autoApprovewith any globtruepermission_mode: acceptEditschat.tools.terminal.enableAutoApprove: falsechat.mcp.*(access / allow / deny servers)raw_settingsas contextchat.agent.sandbox.enabled: "on"/"off"sandbox_enabledScope: user + every profile, not workspace
Read the user-scope
settings.jsonfor the default profile and every named profile (profiles/<id>/settings.json), on both stable Code and Insiders, via the same sharedenumerate_vscode_user_fileshelper the workspacemcp.jsondiscovery uses. All profiles are read and merged to the most-permissive posture — a locked-down default with a YOLO named profile is a real risk.Workspace
.vscode/settings.jsonis deliberately not read:chat.tools.global.autoApproveis policy/application-scoped ("applies globally across all workspaces") andchat.tools.terminal.autoApproveis arestrictedsetting VS Code honors only in a trusted workspace — so a workspace file's values are a posture the tool may never apply. User + profile settings are the authoritative surface (documented in the class, like Cursor's extractor documents excluding the cloud admin dashboard).Per-OS paths (verified against real installs)
~/Library/Application Support/Code/User%APPDATA%\Code\User, resolved per-user from the home — never%APPDATA%of the running token (systemprofile under SYSTEM)~/.config/Code/UserSkills
VS Code Copilot skills already flow through the shared
_get_copilot_cli_skillsextractor — paths web-verified against the official VS Code agent-skills docs:~/.copilot/skills/(user) and repo.github/.claude/.agents/skills/(project). Home/repo-based, not profile-scoped; unchanged here, attached to the canonical VS Code row alongside permissions.Testing
tests/test_copilot_vscode_permissions.py): the key→record mapping, the phantom-key guard, profile merge/escalation, JSONC/BOM, and the real macOS extractor over planted default + named-profile fixtures.bypassPermissionsfrom a YOLO named profile,deny= only the real terminal key, MCP-deny merged from a second profile, skills attach — per-user path correct on each OS.🤖 Generated with Claude Code
Greptile Summary
The PR extracts VS Code Copilot agent permissions from user and named-profile settings and attaches them to the canonical Copilot report.
Confidence Score: 4/5
The PR is not yet safe to merge because a reported stable or Insiders installation can still receive the other channel’s Copilot permission posture.
The extractor independently scans both channels and returns whichever is most permissive, while the attachment path supplies no installation or channel identity, leaving the previously reported channel-attribution failure outstanding.
Files Needing Attention: scripts/coding_discovery_tools/coding_tool_base.py, scripts/coding_discovery_tools/ai_tools_discovery.py
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart LR A[Scan user homes] --> B[Read stable profiles] A --> C[Read Insiders profiles] B --> D[Merge profiles within stable] C --> E[Merge profiles within Insiders] D --> F[Select most-permissive channel] E --> F F --> G[Attach to canonical VS Code Copilot row] G --> H[Filter permission record per user]Reviews (19): Last reviewed commit: "revert: drop the windows parent-junction..." | Re-trigger Greptile