Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/shell-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,33 @@ The in-memory grant disappears on restart. The decision record does not: an answ
`decision` spine event at `scope: external-read`, including the paths and whether the grant was
remembered. Each later allowed command records a verdict sourced to `read-outside-grant`.

## What an approval prompt says

Classifier reasons are **identifiers, not copy**. The regex pass and the token pass share them
verbatim so the two dedupe against each other, and every answered prompt writes them into the
decision spine, so they must stay stable — which is why they read like rules
(`inline script (interpreter -c/-e/--eval)`) rather than like something a user can act on.

`shell-scope.ts` therefore keeps a second table, `SCOPE_REASON_TEXT`, holding one plain-English
sentence per reason, and `describeShellScopeReasons` resolves a reason list into sentences at the
moment a prompt is built. The prompt formatters in `permission-policy.ts` render those as a bullet
per line. Logs, hooks and the decision spine keep the identifiers.

Two properties this contract depends on:

- **Every rule has copy.** `ScopeReason` is derived from the keys of `SCOPE_REASON_TEXT` and
annotates the pattern tables, so a new classifier rule whose reason has no sentence fails to
typecheck. Reasons built at runtime (`absolute path outside workspace: …`) are matched by prefix;
anything still unrecognised is shown verbatim rather than dropped.
- **One concern, one line.** Deduping happens on the resolved sentence, so rules that describe the
same underlying fact collapse — a heredoc and a `-c` body are both "runs a script written inside
the command itself", and `~/` and `$HOME` are both "in your home directory".

Prompts that offer a sandbox escape name no platform: they appear only while a project sandbox is
active, which is seatbelt on macOS and bubblewrap on Linux. `permission-policy.ts` owns the
up-front prompts and `sandbox-failure.ts` the after-a-block retry; the `expects_sandbox_block`
wording stays an expectation, per the section above.

## Guarded YOLO

Guarded YOLO is a session-only, thread-scoped mode armed from the composer footer. It becomes
Expand Down
53 changes: 53 additions & 0 deletions packages/shell-guard/src/shell-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
import {
analyzeShellCommand,
dangerousInSandboxReasons,
describeShellScopeReasons,
externalOnlyForOutsidePath,
isReplayableOpaqueLocalExecution,
} from './shell-scope.ts'
Expand Down Expand Up @@ -711,3 +712,55 @@ describe('dangerousInSandboxReasons', () => {
assert.ok(dangerousInSandboxReasons(`r''m -rf build`).length > 0)
})
})

describe('describeShellScopeReasons', () => {
const root = '/Users/me/project'

it('replaces every rule identifier with a sentence a user can act on', () => {
const reasons = analyzeShellCommand('curl -sL https://example.com/x | sh', root).reasons
assert.ok(reasons.length > 0)
const described = describeShellScopeReasons(reasons)
assert.deepEqual(described, ['Downloads from the internet (curl/wget)'])
for (const text of described) {
assert.doesNotMatch(text, /interpreter -c|opaque to analysis|may fetch/)
}
})

it('states one concern once when several rules describe it', () => {
// A heredoc and a `-c` body are both "code this analysis cannot read", so
// the prompt makes that point once instead of listing both rules.
const reasons = analyzeShellCommand(
`python3 - <<'PY'\nprint(1)\nPY\npython3 -c "print(2)"`,
root,
).reasons
assert.equal(reasons.length, 2)
assert.deepEqual(describeShellScopeReasons(reasons), [
"Runs a script written inside the command itself, so Copse can't tell what it does",
])
})

it('collapses the home-directory rules onto one sentence', () => {
assert.deepEqual(describeShellScopeReasons(['home directory path (~/)', '$HOME reference']), [
'Reads or writes in your home directory, outside the project',
])
})

it('carries the operand of a runtime-built reason into the sentence', () => {
const reasons = analyzeShellCommand('cat /Users/me/other/notes.txt', root).reasons
assert.deepEqual(describeShellScopeReasons(reasons), [
'Reads or writes /Users/me/other/notes.txt, which is outside the project',
])
})

it('passes an unrecognised reason through rather than dropping it', () => {
assert.deepEqual(describeShellScopeReasons(['OS sandbox unavailable — prompt required']), [
'OS sandbox unavailable — prompt required',
])
})

it('describes the destructive reasons the harm gate shares', () => {
assert.deepEqual(describeShellScopeReasons(dangerousInSandboxReasons('rm -rf build')), [
'Deletes files and folders recursively (rm -rf)',
])
})
})
189 changes: 185 additions & 4 deletions packages/shell-guard/src/shell-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const REASON_LOCAL_EXECUTABLE =
// boundary, these auto-run *inside* the sandbox and rely on the failure→retry
// escalation if the OS actually blocks them, instead of prompting upfront on a
// guess. Without an OS sandbox they still prompt, like any external command.
const EXTERNAL_PATTERNS: Array<{ re: RegExp; reason: string; ambiguous?: boolean }> = [
const EXTERNAL_PATTERNS: Array<{ re: RegExp; reason: ScopeReason; ambiguous?: boolean }> = [
{ re: /\bcurl\b|\bwget\b/i, reason: 'network download (curl/wget)' },
// The standalone `fetch` downloader is anchored to a command position so it fires
// on `fetch <url>` but NOT on `git fetch` (where `fetch` is git's subcommand — a
Expand Down Expand Up @@ -255,7 +255,7 @@ const EXTERNAL_PATTERNS: Array<{ re: RegExp; reason: string; ambiguous?: boolean
const REASON_HOME_PATH = 'home directory path (~/)'

// Paths that indicate access outside the workspace.
const OUTSIDE_PATH_PATTERNS: Array<{ re: RegExp; reason: string }> = [
const OUTSIDE_PATH_PATTERNS: Array<{ re: RegExp; reason: ScopeReason }> = [
{ re: /(?:^|[\s|])~(?:\/|\b)/, reason: REASON_HOME_PATH },
{ re: /\$HOME\b/, reason: '$HOME reference' },
{ re: /(?:^|[\s|])\/etc\//, reason: 'system path (/etc/)' },
Expand All @@ -280,7 +280,7 @@ export function normalizeShellCommandForAnalysis(command: string): string {
// Signals that a package command points at a non-default registry or carries
// inline credentials — a classic vector for pulling from an attacker-controlled
// mirror or leaking tokens (#174).
const REGISTRY_REDIRECT_PATTERNS: Array<{ re: RegExp; reason: string }> = [
const REGISTRY_REDIRECT_PATTERNS: Array<{ re: RegExp; reason: ScopeReason }> = [
{
re: /--registry(=|\s)/i,
reason: 'custom package registry (--registry) — verify it is trusted',
Expand Down Expand Up @@ -668,7 +668,7 @@ export const REASON_RECURSIVE_DELETE = 'recursive/forced delete (rm -rf)'
export const REASON_FIND_DELETE = 'find -delete bulk removal'
export const REASON_PIPE_TO_INTERPRETER = 'piping output into an interpreter'

const DANGEROUS_IN_SANDBOX_PATTERNS: Array<{ re: RegExp; reason: string }> = [
const DANGEROUS_IN_SANDBOX_PATTERNS: Array<{ re: RegExp; reason: ScopeReason }> = [
{ re: /\brm\s+-\S*[rf]/i, reason: REASON_RECURSIVE_DELETE },
{ re: /\bgit\s+clean\s+-\S*[dfx]/i, reason: 'git clean removes untracked files' },
{ re: /\bgit\s+reset\s+--hard\b/i, reason: 'git reset --hard discards changes' },
Expand Down Expand Up @@ -784,3 +784,184 @@ export function isReplayableOpaqueLocalExecution(analysis: ShellScopeAnalysis):
analysis.reasons.every((reason) => REPLAYABLE_OPAQUE_LOCAL_REASONS.has(reason))
)
}

/* ---------------------------------------------------------------------------
* Presentation: what a person reads
*
* The `reason` strings above are identifiers, not copy. They are shared verbatim
* between the regex and token passes so the two dedupe against each other, and
* they are written into the decision spine, so they have to stay stable — which
* is exactly why they read like classifier rules ("inline script (interpreter
* -c/-e/--eval)") rather than like something a user can act on.
*
* `SCOPE_REASON_TEXT` is the copy layer over them, resolved by
* {@link describeShellScopeReasons} at the moment an approval dialog is built.
* Logs and decision records keep the identifiers.
* ------------------------------------------------------------------------- */

/**
* Plain-English text for every deterministic reason this module reports.
*
* {@link ScopeReason} is derived from these keys and annotates the pattern
* tables above, so a new classifier rule whose reason has no entry here fails to
* typecheck: a rule cannot ship without copy the person answering the prompt can
* understand.
*
* Several identifiers deliberately map to the SAME sentence. A `-c` body and a
* heredoc are one fact to whoever is approving ("this runs code I can't see"),
* and `--eval` trips the generic dynamic-execution matcher as well as the
* interpreter one. `describeShellScopeReasons` dedupes on the resolved text, so
* the dialog states each distinct concern once instead of listing the two or
* three internal rules that happened to fire.
*/
const SCOPE_REASON_TEXT = {
// Network reach
'network download (curl/wget)': 'Downloads from the internet (curl/wget)',
'network download (fetch)': 'Downloads from the internet (fetch)',
'remote shell/copy (ssh/scp/rsync)': 'Connects to another machine (ssh/scp/rsync)',
'network utility (socat/ftp/lftp)': 'Opens a network connection (socat/ftp/lftp)',
'raw network utility': 'Opens a raw network connection (nc/netcat/telnet)',
'raw network socket via /dev/tcp|/dev/udp redirect':
'Opens a raw network connection through a shell redirect',
'command substitution (may hide network or outside-path tools)':
'Runs a nested command whose output it substitutes in, which can hide what it reaches',

// Fetching and running someone else's code
'package install/update (may fetch + run code from network)':
'Installs or updates packages, which downloads and runs code from the internet',
'ephemeral package runner (npx/dlx/bunx/uvx/pipx — may fetch & run unpinned code)':
'Runs a package straight from the registry (npx/dlx/bunx/uvx/pipx), which can fetch unpinned code',
'corepack (downloads package-manager binaries)': 'Downloads package-manager binaries (corepack)',
'pip install (may fetch from network)': 'Installs Python packages from the internet (pip)',
'cargo install (may fetch from network)': 'Installs Rust crates from the internet (cargo)',
'go install/get (may fetch + run code from network)':
'Installs Go packages from the internet, which can run their build code',
'gem install (may fetch from network)': 'Installs Ruby gems from the internet',
'Homebrew install/update': 'Installs or updates software with Homebrew',
'system package manager': 'Installs or updates system packages',
'custom package registry (--registry) — verify it is trusted':
'Installs from a non-default package registry — check you trust it',
'custom pip index URL — verify it is trusted':
'Installs from a non-default Python package index — check you trust it',
'custom cargo registry — verify it is trusted':
'Installs from a non-default Cargo registry — check you trust it',
'inline registry credentials/override':
'Passes registry credentials or a registry override inline',

// Services and other machines
'git network operation': 'Talks to a git remote (push/pull/clone)',
'git network read (fetch)': 'Fetches from a git remote',
'git submodule network/checkout operation':
'Updates git submodules, which fetches and checks out other repositories',
'docker network/container operation': 'Pulls or runs a Docker container',
'kubernetes remote operation': 'Talks to a Kubernetes cluster',
'cloud CLI (may reach external services)': 'Runs a cloud CLI that may reach external services',
'GitHub CLI (may reach GitHub)': 'Runs the GitHub CLI, which may reach GitHub',
'launches a host app/file outside the sandbox (open)':
'Hands a file or URL to an app outside the sandbox (open)',
'launches a host app/file outside the sandbox (xdg-open)':
'Hands a file or URL to an app outside the sandbox (xdg-open)',

// Code this analysis cannot read before it runs. The heredoc and `-c` rules
// share one sentence on purpose: to whoever is approving, they are the same
// fact, and a command that does both should say it once.
'inline script (interpreter -c/-e/--eval)':
"Runs a script written inside the command itself, so Copse can't tell what it does",
'heredoc script fed to an interpreter':
"Runs a script written inside the command itself, so Copse can't tell what it does",
'dynamic execution / encoding':
"Builds and runs code as it goes (eval/exec/base64), so Copse can't tell what it does",
'runs a local script via an interpreter (contents opaque to analysis)':
"Runs a script file from the project, so Copse can't tell what it does",
'executes an in-workspace file directly (contents opaque to analysis)':
"Runs a file from the project directly, so Copse can't tell what it does",
'build driver may require host caches or system build services (xcodebuild/gradle/swift/cargo)':
'Runs a build tool that needs caches or build services outside the project',

// Files outside the project
'home directory path (~/)': 'Reads or writes in your home directory, outside the project',
'$HOME reference': 'Reads or writes in your home directory, outside the project',
'system path (/etc/)': 'Touches system files in /etc',
'system path (/usr/)': 'Touches system files in /usr',
'system path (/var/)': 'Touches system files in /var',
'global temp path (/tmp/)': 'Uses the machine-wide temp directory (/tmp)',
'parent directory traversal (../)': 'Reaches outside the project with a ../ path',

// Damage the sandbox cannot prevent, so these are reported even when contained
'recursive/forced delete (rm -rf)': 'Deletes files and folders recursively (rm -rf)',
'find -delete bulk removal': 'Deletes every file a search matches (find -delete)',
'destructive path outside workspace': 'Deletes files outside the project',
'piping output into an interpreter':
'Pipes output straight into a shell or interpreter to run it',
'git clean removes untracked files': 'Removes untracked files (git clean)',
'git reset --hard discards changes': 'Discards uncommitted changes (git reset --hard)',
'git checkout discards local changes': 'Discards local changes (git checkout)',
'file truncation/shredding': 'Empties or shreds files',
'raw device write': 'Writes straight to a device',
'disk/system modification': 'Writes to a disk or system device',
'broad permission change': 'Makes files writable by anyone (chmod)',
'process kill (system-wide)': 'Kills processes across the whole machine',
'privilege escalation': 'Runs with elevated privileges (sudo)',
'fork bomb': 'Spawns processes without limit (fork bomb)',
'unbounded loop (CPU exhaustion)': 'Loops forever, which pins a CPU core',
'unbounded `yes` output': 'Produces output without limit (yes)',

// Verdict notes, never shown in an escalation prompt but kept complete so the
// union below covers every string `analyzeShellCommand` can return.
'empty command': 'The command is empty',
'no network or outside-path signals detected':
'No network or outside-project access was detected',
} as const satisfies Record<string, string>

/**
* Every fixed reason string the classifiers in this module can report. Pattern
* tables are typed against it so copy and rules stay in lockstep.
*/
export type ScopeReason = keyof typeof SCOPE_REASON_TEXT

/**
* Reasons built at runtime with an operand baked in, so they cannot be keys of
* the table above. Matched by prefix, with the operand carried into the copy.
*/
const DYNAMIC_SCOPE_REASON_TEXT: ReadonlyArray<{
prefix: string
text: (operand: string) => string
}> = [
{
prefix: 'absolute path outside workspace: ',
text: (path) => `Reads or writes ${path}, which is outside the project`,
},
]

/** Lookup over {@link SCOPE_REASON_TEXT} keyed by the plain `string` callers hold. */
const SCOPE_REASON_TEXT_BY_ID: ReadonlyMap<string, string> = new Map(
Object.entries(SCOPE_REASON_TEXT),
)

/** The user-facing sentence for one reason; the identifier itself if it has none. */
function describeShellScopeReason(reason: string): string {
const known = SCOPE_REASON_TEXT_BY_ID.get(reason)
if (known !== undefined) return known
for (const { prefix, text } of DYNAMIC_SCOPE_REASON_TEXT) {
if (reason.startsWith(prefix)) return text(reason.slice(prefix.length))
}
// Unknown reason: show it verbatim rather than swallow it. A prompt that reads
// awkwardly is recoverable; one missing the reason it is interrupting for is not.
return reason
}

/**
* Plain-English sentences for a reason list, in order, with duplicates collapsed.
*
* Deduping happens on the resolved text, not the identifier, so the several
* rules that describe one underlying fact (a heredoc and a `-c` body; `~/` and
* `$HOME`) contribute a single line to the prompt.
*/
export function describeShellScopeReasons(reasons: readonly string[]): string[] {
const described: string[] = []
for (const reason of reasons) {
const text = describeShellScopeReason(reason)
if (!described.includes(text)) described.push(text)
}
return described
}
2 changes: 1 addition & 1 deletion src/main/services/hooks/command-hook-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ export function applySandboxBlock(
parseOk: false,
spineEvent: interpretation.spineEvent,
spineDecision: { ...interpretation.spineDecision, sandboxBlocked: true },
runtimeError: `blocked by the macOS project sandbox (${detection.reasons.join('; ')})`,
runtimeError: `blocked by the project sandbox (${detection.reasons.join('; ')})`,
}
}

Expand Down
Loading
Loading