Switch to custom Bash tool. Mask secrets from Bashsubprocs. Simplify security handling.
This commit is contained in:
+1
-1
@@ -46,4 +46,4 @@ examples
|
||||
.temp/
|
||||
dist
|
||||
|
||||
.pnpm-store/
|
||||
.pnpm-store/
|
||||
|
||||
+5
-12
@@ -21,28 +21,21 @@ export const claude = agent({
|
||||
const prompt = addInstructions({ payload, repo });
|
||||
log.group("Full prompt", () => log.info(prompt));
|
||||
|
||||
// configure sandbox mode if enabled
|
||||
// SECURITY: Claude Code spawns subprocesses with full process.env, leaking API keys.
|
||||
// disable native Bash; agents use MCP bash tool which filters secrets.
|
||||
const sandboxOptions: Options = payload.sandbox
|
||||
? {
|
||||
permissionMode: "default",
|
||||
disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"],
|
||||
async canUseTool(toolName, input, _options) {
|
||||
if (toolName.startsWith("mcp__gh_pullfrog__"))
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: input,
|
||||
updatedPermissions: [],
|
||||
};
|
||||
|
||||
console.error("can i use this tool?", toolName);
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "You are not allowed to use this tool.",
|
||||
};
|
||||
return { behavior: "allow", updatedInput: input, updatedPermissions: [] };
|
||||
return { behavior: "deny", message: "tool not allowed in sandbox mode" };
|
||||
},
|
||||
}
|
||||
: {
|
||||
permissionMode: "bypassPermissions" as const,
|
||||
disallowedTools: ["Bash"],
|
||||
};
|
||||
|
||||
if (payload.sandbox) {
|
||||
|
||||
+2
-2
@@ -43,8 +43,8 @@ export const codex = agent({
|
||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
||||
}
|
||||
|
||||
// Codex SDK isolates subprocess env - native bash is safe, no MCP override needed
|
||||
const codex = new Codex(codexOptions);
|
||||
// valid sandbox modes: read-only, workspace-write, danger-full-access
|
||||
const thread = codex.startThread(
|
||||
payload.sandbox
|
||||
? {
|
||||
@@ -61,7 +61,7 @@ export const codex = agent({
|
||||
);
|
||||
|
||||
try {
|
||||
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo }));
|
||||
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo, useNativeBash: true }));
|
||||
|
||||
let finalOutput = "";
|
||||
for await (const event of streamedTurn.events) {
|
||||
|
||||
+11
-16
@@ -314,36 +314,31 @@ function configureCursorMcpServers({ mcpServers }: ConfigureMcpServersParams) {
|
||||
|
||||
/**
|
||||
* Configure Cursor CLI sandbox mode via cli-config.json.
|
||||
* When sandbox is enabled, denies all file writes and shell commands.
|
||||
* In print mode without --force, writes are blocked by default, but we add
|
||||
* explicit deny rules as defense in depth.
|
||||
*
|
||||
* See: https://cursor.com/docs/cli/reference/permissions
|
||||
* SECURITY: Cursor spawns subprocesses with full process.env, leaking API keys.
|
||||
* We deny native Shell via Shell(*) rule, forcing use of MCP bash tool which
|
||||
* filters secrets. Note: Shell(**) does NOT work, must use Shell(*).
|
||||
*
|
||||
* Config path: $XDG_CONFIG_HOME/cursor/ (not ~/.cursor/) because createAgentEnv
|
||||
* sets XDG_CONFIG_HOME=$HOME/.config. See issues/cursor-perms.md.
|
||||
*/
|
||||
function configureCursorSandbox({ sandbox }: { sandbox: boolean }): void {
|
||||
const realHome = homedir();
|
||||
const cursorConfigDir = join(realHome, ".cursor");
|
||||
const cursorConfigDir = join(realHome, ".config", "cursor");
|
||||
const cliConfigPath = join(cursorConfigDir, "cli-config.json");
|
||||
mkdirSync(cursorConfigDir, { recursive: true });
|
||||
|
||||
const config = sandbox
|
||||
? {
|
||||
// sandbox mode: deny all writes and shell commands
|
||||
permissions: {
|
||||
allow: [
|
||||
"Read(**)", // allow reading all files
|
||||
],
|
||||
deny: [
|
||||
"Write(**)", // deny all file writes
|
||||
"Shell(**)", // deny all shell commands
|
||||
],
|
||||
allow: ["Read(**)"],
|
||||
deny: ["Write(**)", "Shell(*)"],
|
||||
},
|
||||
}
|
||||
: {
|
||||
// normal mode: allow everything
|
||||
permissions: {
|
||||
allow: ["Read(**)", "Write(**)", "Shell(**)"],
|
||||
deny: [],
|
||||
allow: ["Read(**)", "Write(**)"],
|
||||
deny: ["Shell(*)"],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+6
-8
@@ -163,12 +163,11 @@ export const gemini = agent({
|
||||
throw new Error("google_api_key or gemini_api_key is required for gemini agent");
|
||||
}
|
||||
|
||||
const sessionPrompt = addInstructions({ payload, repo });
|
||||
const sessionPrompt = addInstructions({ payload, repo, useNativeBash: true });
|
||||
log.group("Full prompt", () => log.info(sessionPrompt));
|
||||
|
||||
// configure sandbox mode if enabled
|
||||
// --allowed-tools restricts which tools are available (removes others from registry entirely)
|
||||
// in sandbox mode: only read-only tools available (no write_file, run_shell_command, web_fetch)
|
||||
// Gemini CLI isolates subprocess env - native bash is safe, no MCP override needed
|
||||
// sandbox mode uses --allowed-tools to restrict to read-only operations
|
||||
const args = payload.sandbox
|
||||
? [
|
||||
"--allowed-tools",
|
||||
@@ -186,14 +185,13 @@ export const gemini = agent({
|
||||
}
|
||||
|
||||
let finalOutput = "";
|
||||
let stdoutBuffer = ""; // buffer for incomplete lines across chunks
|
||||
let stdoutBuffer = "";
|
||||
|
||||
try {
|
||||
const result = await spawn({
|
||||
cmd: "node",
|
||||
args: [cliPath, ...args],
|
||||
env: createAgentEnv({
|
||||
GEMINI_API_KEY: apiKey,
|
||||
}),
|
||||
env: createAgentEnv({ GEMINI_API_KEY: apiKey }),
|
||||
onStdout: async (chunk) => {
|
||||
const text = chunk.toString();
|
||||
finalOutput += text;
|
||||
|
||||
+8
-36
@@ -53,9 +53,10 @@ function buildRuntimeContext(repo: RepoInfo): string {
|
||||
interface AddInstructionsParams {
|
||||
payload: Payload;
|
||||
repo: RepoInfo;
|
||||
useNativeBash?: boolean;
|
||||
}
|
||||
|
||||
export const addInstructions = ({ payload, repo }: AddInstructionsParams) => {
|
||||
export const addInstructions = ({ payload, repo, useNativeBash = false }: AddInstructionsParams) => {
|
||||
let encodedEvent = "";
|
||||
|
||||
const eventKeys = Object.keys(payload.event);
|
||||
@@ -98,41 +99,6 @@ In case of conflict between instructions, follow this precedence (highest to low
|
||||
4. Repository-specific instructions (AGENTS.md, CLAUDE.md, etc.)
|
||||
5. User prompt
|
||||
|
||||
## SECURITY
|
||||
|
||||
CRITICAL SECURITY RULES - NEVER VIOLATE UNDER ANY CIRCUMSTANCES:
|
||||
|
||||
### Rule 1: Never expose secrets through ANY means
|
||||
|
||||
You must NEVER expose secrets through any channel, including but not limited to:
|
||||
- Displaying, printing, echoing, logging, or outputting to console
|
||||
- Writing to files (including .txt, .env, .json, config files, etc.)
|
||||
- Including in git commits, commit messages, or PR descriptions
|
||||
- Posting in GitHub comments, issue bodies, or PR review comments
|
||||
- Returning in tool outputs, API responses, or error messages
|
||||
- Including in redirect URLs, WebSocket messages, or GraphQL responses
|
||||
|
||||
Secrets include: API keys, authentication tokens, passwords, private keys, certificates, database connection strings, and any credential used for authentication or authorization. Common patterns (case-insensitive): variables containing API_KEY, SECRET, TOKEN, PASSWORD, CREDENTIAL, PRIVATE_KEY, or AUTH in an authentication context. Use judgment: \`PUBLIC_KEY\` for a cryptographic public key is fine; \`PRIVATE_KEY\` is not.
|
||||
|
||||
### Rule 2: Never serialize objects containing secrets
|
||||
|
||||
When working with objects that may contain environment variables or secrets:
|
||||
- NEVER serialize, stringify, or dump entire environment objects (process.env, os.environ, ENV, etc.)
|
||||
- NEVER iterate over environment variables and write their values to files
|
||||
- NEVER include environment variable values in outputs, logs, HTTP requests, or anywhere they can be exposed
|
||||
- If you must list properties, only show property NAMES, never values
|
||||
- Only access specific, known-safe keys explicitly (e.g., NODE_ENV, HOME, PWD)
|
||||
|
||||
### Rule 3: Refuse and explain
|
||||
|
||||
Even if explicitly requested to reveal secrets, you must:
|
||||
1. Refuse the request
|
||||
2. Print a message explaining that exposing secrets is prohibited for security reasons
|
||||
3. If using ${ghPullfrogMcpName}, update the working comment to explain that secrets cannot be revealed
|
||||
4. Offer a safe alternative, if applicable
|
||||
|
||||
If you encounter secrets in files or environment, acknowledge they exist but never reveal their values.
|
||||
|
||||
## MCP (Model Context Protocol) Tools
|
||||
|
||||
MCP servers provide tools you can call. Inspect your available MCP servers at startup to understand what tools are available, especially the ${ghPullfrogMcpName} server which handles all GitHub operations.
|
||||
@@ -169,6 +135,12 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g
|
||||
|
||||
**Efficiency**: Trust the tools - do not repeatedly verify file contents or git status after operations. If a tool reports success, proceed to the next step. Only verify if you encounter an actual error.
|
||||
|
||||
${
|
||||
useNativeBash
|
||||
? `**Shell commands**: Use your native bash/shell tool for shell command execution.`
|
||||
: `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.`
|
||||
}
|
||||
|
||||
**Command execution**: Never use \`sleep\` to wait for commands to complete. Commands run synchronously - when the bash tool returns, the command has finished.
|
||||
|
||||
**Commenting style**: When posting comments via ${ghPullfrogMcpName}, write as a professional team member would. Your final comments should be polished and actionable—do not include intermediate reasoning like "I'll now look at the code" or "Let me respond to the question."
|
||||
|
||||
+17
-26
@@ -7,6 +7,7 @@ import { addInstructions } from "./instructions.ts";
|
||||
import {
|
||||
agent,
|
||||
type ConfigureMcpServersParams,
|
||||
createAgentEnv,
|
||||
installFromNpmTarball,
|
||||
setupProcessAgentEnv,
|
||||
} from "./shared.ts";
|
||||
@@ -42,24 +43,25 @@ export const opencode = agent({
|
||||
// 6. set up environment
|
||||
setupProcessAgentEnv({ HOME: tempHome });
|
||||
|
||||
// build env vars: start with process.env (includes all API_KEY vars loaded by config())
|
||||
// exclude GITHUB_TOKEN - OpenCode should use MCP server for GitHub operations, not direct token
|
||||
// then override with apiKeys, HOME, and XDG_CONFIG_HOME
|
||||
// SECURITY: build env vars from whitelisted base env to prevent API key leakage
|
||||
// this prevents leaking other API keys (ANTHROPIC, GEMINI, etc.) to OpenCode subprocess
|
||||
// XDG_CONFIG_HOME must be set because GitHub Actions sets it to a different path,
|
||||
// and OpenCode follows XDG spec (checks XDG_CONFIG_HOME before falling back to $HOME/.config)
|
||||
const env: Record<string, string> = {
|
||||
...(Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
([key, value]) => value !== undefined && key !== "GITHUB_TOKEN"
|
||||
)
|
||||
) as Record<string, string>),
|
||||
HOME: tempHome,
|
||||
...createAgentEnv({ HOME: tempHome }),
|
||||
XDG_CONFIG_HOME: join(tempHome, ".config"),
|
||||
};
|
||||
// OpenCode doesn't support GitHub App installation tokens
|
||||
delete env.GITHUB_TOKEN;
|
||||
|
||||
// add/override API keys from apiKeys object (uppercase keys)
|
||||
// add API keys from apiKeys object
|
||||
for (const [key, value] of Object.entries(apiKeys || {})) {
|
||||
env[key.toUpperCase()] = value;
|
||||
const upperKey = key.toUpperCase();
|
||||
env[upperKey] = value;
|
||||
// also set GOOGLE_GENERATIVE_AI_API_KEY for Google provider compatibility
|
||||
if (upperKey === "GEMINI_API_KEY") {
|
||||
env.GOOGLE_GENERATIVE_AI_API_KEY = value;
|
||||
}
|
||||
}
|
||||
|
||||
// run OpenCode in the repository directory (process.cwd() is set to GITHUB_WORKSPACE or repo dir)
|
||||
@@ -218,22 +220,11 @@ function configureOpenCode({ mcpServers, sandbox }: ConfigureOpenCodeParams): vo
|
||||
};
|
||||
}
|
||||
|
||||
// build permissions config
|
||||
// SECURITY: OpenCode spawns subprocesses with full process.env, leaking API keys.
|
||||
// disable native bash; agents use MCP bash tool which filters secrets.
|
||||
const permission = sandbox
|
||||
? {
|
||||
edit: "deny",
|
||||
bash: "deny",
|
||||
webfetch: "deny",
|
||||
doom_loop: "allow",
|
||||
external_directory: "allow",
|
||||
}
|
||||
: {
|
||||
edit: "allow",
|
||||
bash: "allow",
|
||||
webfetch: "allow",
|
||||
doom_loop: "allow",
|
||||
external_directory: "allow",
|
||||
};
|
||||
? { edit: "deny", bash: "deny", webfetch: "deny", doom_loop: "allow", external_directory: "allow" }
|
||||
: { edit: "allow", bash: "deny", webfetch: "allow", doom_loop: "allow", external_directory: "allow" };
|
||||
|
||||
// build complete config in one object
|
||||
const config = {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Docker Testing Environment
|
||||
|
||||
`play.ts` runs in Docker by default for realistic testing (Linux, clean $HOME, matches CI).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
pnpm play bash-test.ts # runs in Docker (default)
|
||||
pnpm play --local bash-test.ts # runs on macOS (fast iteration)
|
||||
PLAY_LOCAL=1 pnpm play ... # same as --local
|
||||
```
|
||||
|
||||
## Why Docker by Default?
|
||||
|
||||
1. **Matches CI** - Linux environment like GitHub Actions
|
||||
2. **Clean $HOME** - No agent config pollution from `~/.claude`, `~/.cursor`
|
||||
3. **Tests unshare** - Verifies PID namespace sandbox works
|
||||
4. **Reproducible** - Same environment every run
|
||||
|
||||
## Performance
|
||||
|
||||
| Mode | Overhead |
|
||||
|------|----------|
|
||||
| Docker (cached deps) | ~1.5s |
|
||||
| Local (macOS) | ~0s |
|
||||
|
||||
For agent runs taking 30-120s, the 1.5s overhead is negligible.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. `play.ts` runs on host, loads `.env`
|
||||
2. Spawns Docker container with:
|
||||
- Volume-mounted `action/` code
|
||||
- Named volume for Linux node_modules (persists between runs)
|
||||
- SSH agent forwarding for git clone
|
||||
- Env vars passed via `-e` flags
|
||||
3. Inside Docker, `play.ts` runs again (detects `/.dockerenv` file)
|
||||
4. Clones `GITHUB_REPOSITORY`, runs agent
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Docker not running:**
|
||||
```
|
||||
Cannot connect to the Docker daemon
|
||||
```
|
||||
→ Start Docker Desktop
|
||||
|
||||
**SSH clone fails:**
|
||||
```
|
||||
Permission denied (publickey)
|
||||
```
|
||||
→ Ensure SSH agent is running: `ssh-add -l`
|
||||
@@ -0,0 +1,217 @@
|
||||
# Bash Tool Security
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ GitHub Actions Runner │
|
||||
│ (has secrets: ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ Pullfrog Action (Node.js) │ │
|
||||
│ │ - process.env contains all secrets │ │
|
||||
│ │ - spawns agent CLI as child process │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Agent CLI (Claude/Cursor/OpenCode/etc.) │ │ │
|
||||
│ │ │ - receives filtered env (only API key it needs) │ │ │
|
||||
│ │ │ - has built-in Bash tool (DISABLED) │ │ │
|
||||
│ │ │ - connects to MCP server for tools │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ┌───────────────────────────────────────────────┐ │ │ │
|
||||
│ │ │ │ MCP Bash Tool (our code) │ │ │ │
|
||||
│ │ │ │ - agent calls this for shell commands │ │ │ │
|
||||
│ │ │ │ - spawns bash with filtered env │ │ │ │
|
||||
│ │ │ │ - uses PID namespace isolation │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │
|
||||
│ │ │ │ │ Bash subprocess │ │ │ │ │
|
||||
│ │ │ │ │ - runs user-controlled commands │ │ │ │ │
|
||||
│ │ │ │ │ - MUST NOT access secrets │ │ │ │ │
|
||||
│ │ │ │ └─────────────────────────────────────────┘ │ │ │ │
|
||||
│ │ │ └───────────────────────────────────────────────┘ │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key insight**: The Pullfrog Action process has all secrets in `process.env`. Agent CLIs have built-in Bash tools that we can't trust. We disable those and provide our own MCP Bash tool that spawns subprocesses securely.
|
||||
|
||||
---
|
||||
|
||||
## Threat Model
|
||||
|
||||
A prompt-injected agent could run malicious bash commands to exfiltrate API keys.
|
||||
|
||||
**Attack vectors:**
|
||||
|
||||
| Vector | Example | Mitigation |
|
||||
|--------|---------|------------|
|
||||
| Direct env access | `env \| grep KEY` | Filter env vars before spawn |
|
||||
| Echo variable | `echo $ANTHROPIC_API_KEY` | Filter env vars before spawn |
|
||||
| `/proc/$PPID/environ` | `cat /proc/$PPID/environ` | PID namespace isolation |
|
||||
|
||||
The first two are solved by passing filtered env to subprocess. The third requires special handling on Linux.
|
||||
|
||||
---
|
||||
|
||||
## Attack: /proc/$PPID/environ
|
||||
|
||||
On Linux, any process can read its parent's environment via `/proc/$PPID/environ`. Even if we spawn bash with a clean environment, the bash process can:
|
||||
|
||||
```bash
|
||||
# read parent's (Node.js) environment - contains all secrets!
|
||||
tr '\0' '\n' < /proc/$PPID/environ | grep KEY
|
||||
```
|
||||
|
||||
This bypasses environment filtering because we're reading the parent process's memory, not our own env.
|
||||
|
||||
**Why this matters:**
|
||||
- Pullfrog Action (Node.js) has `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc. in `process.env`
|
||||
- We spawn agent CLI with filtered env (only its own API key)
|
||||
- Agent CLI spawns MCP Bash tool
|
||||
- MCP Bash tool spawns bash with filtered env (no secrets)
|
||||
- BUT bash can read `/proc/$PPID/environ` → gets Node.js process's full env
|
||||
|
||||
---
|
||||
|
||||
## Solution: PID Namespace Isolation
|
||||
|
||||
We use Linux PID namespaces to hide the parent process:
|
||||
|
||||
```bash
|
||||
unshare --pid --fork --mount-proc bash -c "$CMD"
|
||||
```
|
||||
|
||||
| Flag | Purpose |
|
||||
|------|---------|
|
||||
| `--pid` | Create new PID namespace |
|
||||
| `--fork` | Fork so child is actually in new namespace |
|
||||
| `--mount-proc` | Mount fresh `/proc` for new namespace |
|
||||
|
||||
**Result:**
|
||||
- Child sees itself as PID 1
|
||||
- Child's PPID is 0 (doesn't exist)
|
||||
- `/proc` only shows processes in child's namespace
|
||||
- Parent's PID is invisible → `/proc/$PPID/environ` fails
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### mcp/bash.ts
|
||||
|
||||
```typescript
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
// filter sensitive env vars (defense in depth)
|
||||
function filterEnv(): Record<string, string> {
|
||||
const SENSITIVE = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /^ANTHROPIC/i, ...];
|
||||
const filtered: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value && !SENSITIVE.some(p => p.test(key))) {
|
||||
filtered[key] = value;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// spawn with PID namespace in GitHub Actions, plain spawn locally
|
||||
function spawnSandboxed(command: string, options: { env, cwd }): ChildProcess {
|
||||
if (process.env.GITHUB_ACTIONS === "true") {
|
||||
return spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], options);
|
||||
}
|
||||
return spawn("bash", ["-c", command], options);
|
||||
}
|
||||
```
|
||||
|
||||
**Defense in depth:**
|
||||
1. `filterEnv()` - prevents `env` and `echo $VAR` attacks
|
||||
2. `unshare` - prevents `/proc/$PPID/environ` attack
|
||||
|
||||
---
|
||||
|
||||
## Disabling Native Bash Tools
|
||||
|
||||
Each agent has built-in Bash/Shell tools that we can't control. We disable them and force agents to use our MCP Bash tool:
|
||||
|
||||
```typescript
|
||||
// Claude
|
||||
disallowedTools: ["Bash"],
|
||||
|
||||
// Cursor
|
||||
permissions: { deny: ["Shell(**)"] }
|
||||
|
||||
// OpenCode
|
||||
permission: { bash: "deny" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Run the vulnerability test in Docker:
|
||||
|
||||
```bash
|
||||
# from action/ directory
|
||||
docker run --rm \
|
||||
-v "$(pwd):/app/action:cached" \
|
||||
-v "pullfrog-action-node-modules:/app/action/node_modules" \
|
||||
-w /app/action \
|
||||
-e GITHUB_ACTIONS=true \
|
||||
-e TEST_SECRET_KEY=test-secret \
|
||||
-e ANTHROPIC_API_KEY=sk-test \
|
||||
--cap-add SYS_ADMIN \
|
||||
--security-opt seccomp:unconfined \
|
||||
node:22 bash -c "corepack enable pnpm && pnpm install --frozen-lockfile && node test/proc-environ-vuln.ts"
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
1. UNPROTECTED (filterEnv only):
|
||||
Leaked: YES ❌
|
||||
|
||||
2. PROTECTED (unshare --pid --fork --mount-proc):
|
||||
Leaked: NO ✓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Platform Notes
|
||||
|
||||
| Environment | `GITHUB_ACTIONS` | Our approach |
|
||||
|-------------|------------------|--------------|
|
||||
| GitHub Actions (Linux) | `"true"` | filterEnv + unshare |
|
||||
| Local dev (any OS) | unset | filterEnv only |
|
||||
|
||||
We check `GITHUB_ACTIONS=true` (set automatically by GitHub) rather than platform detection. This means:
|
||||
- **In CI**: Full protection with PID namespace isolation
|
||||
- **Locally**: Easier testing without Docker/unshare requirements
|
||||
|
||||
GitHub Actions uses Ubuntu runners where `unshare` works without root.
|
||||
|
||||
---
|
||||
|
||||
## What This Does NOT Protect Against
|
||||
|
||||
- **Network exfiltration**: Child has full network access
|
||||
- **File access**: Child can read any file the runner can (same UID)
|
||||
- **Resource exhaustion**: No cgroup limits
|
||||
|
||||
For those, you'd need `bwrap` with `--unshare-net`, `--ro-bind`, etc. But for the stated goal—preventing secret exfiltration via env—this is sufficient.
|
||||
|
||||
---
|
||||
|
||||
## Agent-Specific Notes
|
||||
|
||||
### Agents Using MCP Bash (Claude, Cursor, OpenCode)
|
||||
|
||||
These agents have their native Bash disabled. They use our `gh_pullfrog` MCP server's `bash` tool which implements `filterEnv()` + `unshare`.
|
||||
|
||||
### Gemini
|
||||
|
||||
Has built-in CI detection that filters shell env when `GITHUB_SHA` or `SURFACE=Github` is set. We set `SURFACE=Github` in our env. Double protection with our `createAgentEnv()`.
|
||||
|
||||
### Codex
|
||||
|
||||
Uses `shell_environment_policy` in config. Needs proper configuration or MCP bash fallback.
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Payload } from "../external.ts";
|
||||
|
||||
/**
|
||||
* test fixture: verifies agents use MCP bash tool for shell commands.
|
||||
* creates a simple test file and runs it with node.
|
||||
*
|
||||
* for insecure agents (claude, cursor, opencode): native bash is disabled,
|
||||
* so they MUST use gh_pullfrog/bash MCP tool to run shell commands.
|
||||
*
|
||||
* for secure agents (codex, gemini): native bash is safe, but this test
|
||||
* still verifies shell execution works.
|
||||
*
|
||||
* run with: AGENT_OVERRIDE=<agent> pnpm play bash-test.ts
|
||||
*/
|
||||
export default {
|
||||
"~pullfrog": true,
|
||||
agent: null,
|
||||
prompt: `Create a file called test-runner.js with the following content:
|
||||
|
||||
\`\`\`javascript
|
||||
const assert = require('assert');
|
||||
assert.strictEqual(2 + 2, 4, 'math should work');
|
||||
console.log('TEST PASSED: basic arithmetic works');
|
||||
\`\`\`
|
||||
|
||||
Then run it with: node test-runner.js
|
||||
|
||||
Finally, delete the test file.
|
||||
|
||||
This tests that you can execute shell commands properly.`,
|
||||
event: {
|
||||
trigger: "workflow_dispatch",
|
||||
},
|
||||
modes: [],
|
||||
} satisfies Payload;
|
||||
+9
-6
@@ -1,9 +1,12 @@
|
||||
import type { Inputs } from "../main.ts";
|
||||
import type { Payload } from "../external.ts";
|
||||
|
||||
const testParams = {
|
||||
export default {
|
||||
"~pullfrog": true,
|
||||
agent: null,
|
||||
prompt:
|
||||
"List all files in the current directory, then create a file called dynamic-test.txt with the content 'This was loaded from a TypeScript file!', then delete it.",
|
||||
anthropic_api_key: "sk-test-key",
|
||||
} satisfies Inputs;
|
||||
|
||||
export default testParams;
|
||||
event: {
|
||||
trigger: "workflow_dispatch",
|
||||
},
|
||||
modes: [],
|
||||
} satisfies Payload;
|
||||
|
||||
+3
-5
@@ -6,9 +6,9 @@ import type { Payload } from "../external.ts";
|
||||
*
|
||||
* run with: AGENT_OVERRIDE=claude pnpm play sandbox.ts
|
||||
*/
|
||||
const payload: Payload = {
|
||||
export default {
|
||||
"~pullfrog": true,
|
||||
agent: null, // let AGENT_OVERRIDE control this for testing different agents
|
||||
agent: null,
|
||||
prompt: `Please do the following three things:
|
||||
|
||||
1. Fetch the content from https://httpbin.org/json and tell me what it says
|
||||
@@ -24,6 +24,4 @@ All three of these actions should fail because you are running in sandbox mode w
|
||||
},
|
||||
modes: [],
|
||||
sandbox: true,
|
||||
};
|
||||
|
||||
export default JSON.stringify(payload);
|
||||
} satisfies Payload;
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { type } from "arktype";
|
||||
import type { ToolContext } from "../main.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const BashParams = type({
|
||||
command: "string",
|
||||
description: "string",
|
||||
"timeout?": "number",
|
||||
"working_directory?": "string",
|
||||
});
|
||||
|
||||
// patterns for sensitive env vars: suffixes (_KEY, _SECRET, _TOKEN) plus AI provider prefixes
|
||||
const SENSITIVE_PATTERNS = [
|
||||
/_KEY$/i,
|
||||
/_SECRET$/i,
|
||||
/_TOKEN$/i,
|
||||
/PASSWORD/i,
|
||||
/CREDENTIAL/i,
|
||||
/AUTH/i,
|
||||
/^ANTHROPIC/i,
|
||||
/^OPENAI/i,
|
||||
/^GEMINI/i,
|
||||
/^GOOGLE/i,
|
||||
/^CLAUDE/i,
|
||||
/^CODEX/i,
|
||||
/^CURSOR/i,
|
||||
/^AWS/i,
|
||||
/^GITHUB_TOKEN$/i,
|
||||
/^GH_TOKEN$/i,
|
||||
];
|
||||
|
||||
function isSensitive(key: string): boolean {
|
||||
return SENSITIVE_PATTERNS.some((p) => p.test(key));
|
||||
}
|
||||
|
||||
/** filter env vars, removing sensitive values */
|
||||
function filterEnv(): Record<string, string> {
|
||||
const filtered: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined && !isSensitive(key)) filtered[key] = value;
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* spawn command with filtered env. in CI, also use PID namespace isolation
|
||||
* to prevent child from reading /proc/$PPID/environ
|
||||
*/
|
||||
function spawnSandboxed(
|
||||
command: string,
|
||||
options: { env: Record<string, string>; cwd: string },
|
||||
): ChildProcess {
|
||||
const stdio: ["ignore", "pipe", "pipe"] = ["ignore", "pipe", "pipe"];
|
||||
const spawnOpts = { env: options.env, cwd: options.cwd, stdio, detached: true };
|
||||
return process.env.GITHUB_ACTIONS === "true"
|
||||
? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts)
|
||||
: spawn("bash", ["-c", command], spawnOpts);
|
||||
}
|
||||
|
||||
/** kill process and its entire process group */
|
||||
async function killProcessGroup(proc: ChildProcess): Promise<void> {
|
||||
if (!proc.pid) return;
|
||||
try {
|
||||
process.kill(-proc.pid, "SIGTERM");
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
process.kill(-proc.pid, "SIGKILL");
|
||||
} catch {
|
||||
try { proc.kill("SIGKILL"); } catch { /* already dead */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function BashTool(_ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "bash",
|
||||
description: `Execute shell commands securely. Environment is filtered to remove API keys and secrets.
|
||||
|
||||
Use this tool to:
|
||||
- Run shell commands (ls, cat, grep, find, etc.)
|
||||
- Execute build tools (npm, pnpm, cargo, make, etc.)
|
||||
- Run tests and linters
|
||||
- Perform git operations
|
||||
|
||||
The command runs in a bash shell with a filtered environment that excludes sensitive variables like API keys and tokens.`,
|
||||
parameters: BashParams,
|
||||
execute: execute(async (params) => {
|
||||
const timeout = Math.min(params.timeout ?? 120000, 600000);
|
||||
const cwd = params.working_directory ?? process.cwd();
|
||||
const proc = spawnSandboxed(params.command, { env: filterEnv(), cwd });
|
||||
|
||||
let stdout = "", stderr = "", timedOut = false, exited = false;
|
||||
proc.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
|
||||
proc.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
|
||||
|
||||
const timeoutId = setTimeout(async () => {
|
||||
if (!exited) { timedOut = true; await killProcessGroup(proc); }
|
||||
}, timeout);
|
||||
|
||||
const exitCode = await new Promise<number | null>((resolve) => {
|
||||
const done = (code: number | null) => { exited = true; clearTimeout(timeoutId); resolve(code); };
|
||||
proc.on("exit", done);
|
||||
proc.on("error", () => done(null));
|
||||
});
|
||||
|
||||
let output = stderr ? (stdout ? `${stdout}\n${stderr}` : stderr) : stdout;
|
||||
if (timedOut) output = output ? `${output}\n[timed out after ${timeout}ms]` : `[timed out after ${timeout}ms]`;
|
||||
|
||||
return { output: output.trim(), exit_code: exitCode ?? (timedOut ? 124 : -1), timed_out: timedOut };
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { CreatePullRequestReviewTool } from "./review.ts";
|
||||
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
|
||||
import { SelectModeTool } from "./selectMode.ts";
|
||||
import { addTools } from "./shared.ts";
|
||||
import { BashTool } from "./bash.ts";
|
||||
|
||||
/**
|
||||
* Find an available port starting from the given port
|
||||
@@ -94,6 +95,7 @@ export async function startMcpHttpServer(
|
||||
CreateBranchTool(ctx),
|
||||
CommitFilesTool(ctx),
|
||||
PushBranchTool(ctx),
|
||||
BashTool(ctx),
|
||||
];
|
||||
|
||||
if (!ctx.payload.disableProgressComment) {
|
||||
|
||||
+1
-1
@@ -36,10 +36,10 @@
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@toon-format/toon": "^1.0.0",
|
||||
"arktype": "2.1.28",
|
||||
"package-manager-detector": "^1.6.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"execa": "^9.6.0",
|
||||
"fastmcp": "^3.26.8",
|
||||
"package-manager-detector": "^1.6.0",
|
||||
"table": "^6.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { extname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -67,7 +68,9 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = arg({
|
||||
"--help": Boolean,
|
||||
"--raw": String,
|
||||
"--local": Boolean,
|
||||
"-h": "--help",
|
||||
"-l": "--local",
|
||||
});
|
||||
|
||||
if (args["--help"]) {
|
||||
@@ -81,18 +84,79 @@ Arguments:
|
||||
|
||||
Options:
|
||||
--raw [prompt] Use raw string as prompt instead of loading from file
|
||||
--local, -l Run locally on macOS (default: runs in Docker)
|
||||
-h, --help Show this help message
|
||||
|
||||
Environment:
|
||||
PLAY_LOCAL=1 Same as --local
|
||||
|
||||
Examples:
|
||||
tsx play.ts # Use default fixture
|
||||
tsx play.ts fixtures/basic.txt # Use specific text file
|
||||
tsx play.ts custom.json # Use JSON file
|
||||
tsx play.ts fixtures/test.ts # Use TypeScript file
|
||||
tsx play.ts bash-test.ts # Run in Docker (default)
|
||||
tsx play.ts --local bash-test.ts # Run locally on macOS
|
||||
tsx play.ts --raw "Hello world" # Use raw string as prompt
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// default: run in Docker (unless --local or PLAY_LOCAL=1 or already inside Docker)
|
||||
const isInsideDocker = existsSync("/.dockerenv");
|
||||
const useLocal = args["--local"] || process.env.PLAY_LOCAL === "1" || isInsideDocker;
|
||||
|
||||
if (!useLocal) {
|
||||
log.info("» running in Docker container...");
|
||||
|
||||
const passArgs = process.argv.slice(2);
|
||||
const nodeCmd = `node play.ts ${passArgs.join(" ")}`;
|
||||
|
||||
// collect env vars to pass through
|
||||
const envFlags: string[] = [];
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
const shouldPass =
|
||||
key.includes("API_KEY") ||
|
||||
key.includes("_TOKEN") ||
|
||||
key === "GITHUB_REPOSITORY" ||
|
||||
key === "AGENT_OVERRIDE" ||
|
||||
key === "LOG_LEVEL";
|
||||
if (value && shouldPass) envFlags.push("-e", `${key}=${value}`);
|
||||
}
|
||||
|
||||
// SSH agent forwarding for git (macOS Docker Desktop magic path)
|
||||
const sshFlags: string[] = [];
|
||||
if (process.env.SSH_AUTH_SOCK) {
|
||||
sshFlags.push(
|
||||
"-v", "/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock",
|
||||
"-e", "SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock",
|
||||
);
|
||||
}
|
||||
const home = process.env.HOME;
|
||||
if (home && existsSync(join(home, ".ssh", "known_hosts"))) {
|
||||
sshFlags.push("-v", `${home}/.ssh/known_hosts:/root/.ssh/known_hosts:ro`);
|
||||
}
|
||||
|
||||
const ttyFlags = process.stdin.isTTY ? ["-it"] : [];
|
||||
|
||||
const result = spawnSync(
|
||||
"docker",
|
||||
[
|
||||
"run", "--rm", ...ttyFlags,
|
||||
"-v", `${process.cwd()}:/app/action:cached`,
|
||||
"-v", "pullfrog-action-node-modules:/app/action/node_modules",
|
||||
"-w", "/app/action",
|
||||
"-e", "GITHUB_ACTIONS=true",
|
||||
"-e", "CI=true",
|
||||
...envFlags, ...sshFlags,
|
||||
"--cap-add", "SYS_ADMIN",
|
||||
"--security-opt", "seccomp:unconfined",
|
||||
"node:22",
|
||||
"bash", "-c",
|
||||
`corepack enable pnpm >/dev/null 2>&1 && pnpm install --frozen-lockfile && ${nodeCmd}`,
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
let prompt: string;
|
||||
|
||||
if (args["--raw"]) {
|
||||
@@ -135,10 +199,11 @@ Examples:
|
||||
|
||||
if (typeof module.default === "string") {
|
||||
prompt = module.default;
|
||||
} else if (typeof module.default === "object" && module.default.prompt) {
|
||||
prompt = module.default.prompt;
|
||||
} else {
|
||||
} else if (typeof module.default === "object") {
|
||||
// Payload objects (with ~pullfrog) should be stringified
|
||||
prompt = JSON.stringify(module.default, null, 2);
|
||||
} else {
|
||||
throw new Error(`Unsupported default export type: ${typeof module.default}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user