Skip to content

fix: read the copilot approvals default, and config files with a bom - #293

Merged
AakashVelusamy merged 4 commits into
stagingfrom
fix/read-copilot-default-configuration-approvals
Sep 10, 2026
Merged

AakashVelusamy merged 4 commits into
stagingfrom
fix/read-copilot-default-configuration-approvals

Conversation

@AakashVelusamy

@AakashVelusamy AakashVelusamy commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

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.

What the user has Before After
chat.defaultConfiguration: {"approvals": "allowAll"} no record at all bypassPermissions
the same, next to any other key default — identical to a locked-down user bypassPermissions
any settings.json / mcp.json written with a BOM no record at all parsed normally
chat.tools.global.autoApprove: true bypassPermissions unchanged
chat.permissions.default: "autoApprove" bypassPermissions unchanged

1. The session approvals default

chat.defaultConfiguration was not in SECURITY_RELEVANT_KEYS, and a record is only built when at least one key from that set is present — so a settings.json holding only this setting produced no row, and one holding it alongside anything else produced a row saying default. Reproduced on released main (332cfb3) before changing anything:

allowAll alone            -> None
allowAll + another key    -> mode: default   (raw_settings: only chat.agent.enabled)

It is a real key, checked against the VS Code 1.136 configuration registry rather than the docs:

"chat.defaultConfiguration": { type:"object", additionalProperties:false, properties:{
    mode:      { enum:["interactive","plan","autopilot"], default:"interactive" },
    approvals: { enum:["manual","assisted","allowAll"],   default:"manual" }},
  default: { mode:"interactive", approvals:"manual" } }

and the workbench reads it straight into each new session's auto-approve state:

let t = this._configurationService.getValue("chat.defaultConfiguration"),
    n = eWo(t?.approvals);
if (n) { e.autoApprove = ... n }

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 way chat.tools.autoApprove is 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 stays default and rides along verbatim in raw_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.loads rejects a leading BOM outright — its own error says Unexpected UTF-8 BOM (decode using utf-8-sig) — so the parse returned None and 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 / addBOM handling), 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:

  • an MDM or provisioning script using PowerShell's Set-Content -Encoding UTF8, which writes a BOM
  • an older Notepad, which defaulted to UTF-8-with-BOM

The 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:

Reader Files no longer skipped
Copilot settings (macOS / Linux / Windows) settings.json and every profile
Copilot MCP (macOS / Linux / Windows) mcp.json
Augment settings.json, MCP config
Copilot CLI config / settings, MCP config

A BOM character inside a string literal is left untouched.

Verified

Both fixes together on the Windows runner as NT AUTHORITY\SYSTEM, with a settings.json written the way an MDM script writes one — BOM present, and carrying only the approvals key, so it was invisible twice over on main:

head: 7f2befa
devE has BOM  : True
record        : bypassPermissions
settings_path : C:\Users\devE\AppData\Roaming\Code\User\settings.json

Released main was 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:

devA  chat.tools.terminal.autoApprove          -> default            allow=[git status]  deny=[rm]
devB  chat.tools.global.autoApprove (profile)  -> bypassPermissions  allow=[npm run]
devC  chat.permissions.default: autoApprove    -> bypassPermissions
devD  chat.defaultConfiguration: allowAll      -> NO RECORD

Eight tests. Six fail on main with the real symptoms — _build_record returning None, and JSONDecodeError: Unexpected UTF-8 BOM — and two are guardrails proving assisted and autopilot are not overstated.

Verify

  1. Write {"chat.defaultConfiguration": {"approvals": "allowAll"}} into settings.json, saving it with a BOM (Set-Content -Encoding UTF8 on Windows).
  2. Run a discovery scan.
  3. The Copilot row reports permission_mode: bypassPermissions. Previously there was no row at all.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no actionable correctness, security, or repository-rule violations remain.

Summary

  • Recognizes allowAll approvals under both current and legacy Copilot default-configuration keys.
  • Adds those keys to the security-relevant settings set so configurations containing only that setting are reported.
  • Removes a leading UTF-8 BOM at the shared JSONC parsing boundary while preserving BOM characters inside string values.
  • Adds focused permission-mode and BOM regression tests.

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]
Loading

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>
@AakashVelusamy
AakashVelusamy requested a review from a team September 5, 2026 18:01

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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>
@cursor

cursor Bot commented Sep 6, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot 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.

@AakashVelusamy AakashVelusamy changed the title fix: read the copilot session approvals default fix: read the copilot approvals default, and config files with a bom Sep 6, 2026

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Security consensus: no issues found. (reviewers: claude, semgrep, gitleaks)


🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head 7f2befa9 · 2026-09-06T02:00Z

@AakashVelusamy

Copy link
Copy Markdown
Contributor Author

Test battery — Stage 1 + Stage 2 on 7f2befa

PR is open against staging, so this is the pre-merge gate: local first, then both real Azure runners. Nothing ran against a live environment.

Where the risk actually is

The approvals key is confined to one extractor. The BOM strip is not — it sits in _strip_jsonc_comments, which has one definition and 13 call sites across six extractor families. So the battery is weighted there rather than at the key mapping.

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.

@AakashVelusamy

Copy link
Copy Markdown
Contributor Author

@greptile-apps review

Re-review request: I overwrote the PR description with a REST PATCH when expanding it to cover the second fix, which removed your appended summary block along with it. That was my mistake, not a change in the diff — the head is unchanged at 7f2befa.

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 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Security consensus: no issues found. (reviewers: Claude lead review, Semgrep, Gitleaks)


🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head fdb890f4 · 2026-09-08T20:40Z

Comment thread tests/test_nm_pr293_battery.py Outdated
Comment on lines +110 to +131
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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 External tests always skip

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 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Security consensus: no issues found. (reviewers: claude-opus-5, semgrep, gitleaks)


🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head 39bbe6a4 · 2026-09-09T15:08Z

@AakashVelusamy

Copy link
Copy Markdown
Contributor Author

Comment-disposition audit — head 39bbe6a

Every review finding on this PR, with where it landed.

Greptile

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.

@AakashVelusamy
AakashVelusamy merged commit 1f4d633 into staging Sep 10, 2026
8 checks passed
gowshik450526511 added a commit that referenced this pull request Sep 10, 2026
* 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>
sumit-badsara added a commit that referenced this pull request Sep 23, 2026
* 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>
AakashVelusamy added a commit that referenced this pull request Sep 25, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants