Updates
This commit is contained in:
+4
-2
@@ -21,8 +21,10 @@ export const claude = agent({
|
|||||||
const prompt = addInstructions({ payload, repo });
|
const prompt = addInstructions({ payload, repo });
|
||||||
log.group("Full prompt", () => log.info(prompt));
|
log.group("Full prompt", () => log.info(prompt));
|
||||||
|
|
||||||
// SECURITY: Claude Code spawns subprocesses with full process.env, leaking API keys.
|
// SECURITY: For PUBLIC repos, Claude Code spawns subprocesses with full process.env, leaking API keys.
|
||||||
// disable native Bash; agents use MCP bash tool which filters secrets.
|
// disable native Bash; agents use MCP bash tool which filters secrets.
|
||||||
|
// for private repos, native Bash is allowed since secrets are less exposed.
|
||||||
|
const disallowedTools = repo.isPublic ? ["Bash"] : [];
|
||||||
const sandboxOptions: Options = payload.sandbox
|
const sandboxOptions: Options = payload.sandbox
|
||||||
? {
|
? {
|
||||||
permissionMode: "default",
|
permissionMode: "default",
|
||||||
@@ -35,7 +37,7 @@ export const claude = agent({
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
permissionMode: "bypassPermissions" as const,
|
permissionMode: "bypassPermissions" as const,
|
||||||
disallowedTools: ["Bash"],
|
disallowedTools,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (payload.sandbox) {
|
if (payload.sandbox) {
|
||||||
|
|||||||
+11
-2
@@ -43,7 +43,16 @@ export const codex = agent({
|
|||||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Codex SDK isolates subprocess env - native bash is safe, no MCP override needed
|
// SECURITY NOTE: Codex SDK does not have an option to disable native shell commands.
|
||||||
|
// For public repos, we rely on instructions telling the agent to use MCP bash instead.
|
||||||
|
// The MCP bash tool filters sensitive environment variables.
|
||||||
|
// This is not as robust as Claude/Cursor/OpenCode which can explicitly disable native bash.
|
||||||
|
if (repo.isPublic) {
|
||||||
|
log.info(
|
||||||
|
"🔒 public repo: instructions direct to MCP bash (native shell NOT disabled in SDK)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const codex = new Codex(codexOptions);
|
const codex = new Codex(codexOptions);
|
||||||
const thread = codex.startThread(
|
const thread = codex.startThread(
|
||||||
payload.sandbox
|
payload.sandbox
|
||||||
@@ -61,7 +70,7 @@ export const codex = agent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo, useNativeBash: true }));
|
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo }));
|
||||||
|
|
||||||
let finalOutput = "";
|
let finalOutput = "";
|
||||||
for await (const event of streamedTurn.events) {
|
for await (const event of streamedTurn.events) {
|
||||||
|
|||||||
+18
-6
@@ -93,7 +93,7 @@ export const cursor = agent({
|
|||||||
},
|
},
|
||||||
run: async ({ payload, apiKey, cliPath, mcpServers, repo }) => {
|
run: async ({ payload, apiKey, cliPath, mcpServers, repo }) => {
|
||||||
configureCursorMcpServers({ mcpServers, cliPath });
|
configureCursorMcpServers({ mcpServers, cliPath });
|
||||||
configureCursorSandbox({ sandbox: payload.sandbox ?? false });
|
configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic });
|
||||||
|
|
||||||
// track logged model_call_ids to avoid duplicates
|
// track logged model_call_ids to avoid duplicates
|
||||||
// cursor emits each assistant message twice: once without model_call_id, then again with it
|
// cursor emits each assistant message twice: once without model_call_id, then again with it
|
||||||
@@ -315,33 +315,45 @@ function configureCursorMcpServers({ mcpServers }: ConfigureMcpServersParams) {
|
|||||||
/**
|
/**
|
||||||
* Configure Cursor CLI sandbox mode via cli-config.json.
|
* Configure Cursor CLI sandbox mode via cli-config.json.
|
||||||
*
|
*
|
||||||
* SECURITY: Cursor spawns subprocesses with full process.env, leaking API keys.
|
* SECURITY: For PUBLIC repos, Cursor spawns subprocesses with full process.env, leaking API keys.
|
||||||
* We deny native Shell via Shell(*) rule, forcing use of MCP bash tool which
|
* We deny native Shell via Shell(*) rule, forcing use of MCP bash tool which
|
||||||
* filters secrets. Note: Shell(**) does NOT work, must use Shell(*).
|
* filters secrets. Note: Shell(**) does NOT work, must use Shell(*).
|
||||||
|
* For private repos, native Shell is allowed.
|
||||||
*
|
*
|
||||||
* Config path: $XDG_CONFIG_HOME/cursor/ (not ~/.cursor/) because createAgentEnv
|
* Config path: $XDG_CONFIG_HOME/cursor/ (not ~/.cursor/) because createAgentEnv
|
||||||
* sets XDG_CONFIG_HOME=$HOME/.config. See issues/cursor-perms.md.
|
* sets XDG_CONFIG_HOME=$HOME/.config. See issues/cursor-perms.md.
|
||||||
*/
|
*/
|
||||||
function configureCursorSandbox({ sandbox }: { sandbox: boolean }): void {
|
function configureCursorSandbox({
|
||||||
|
sandbox,
|
||||||
|
isPublicRepo,
|
||||||
|
}: {
|
||||||
|
sandbox: boolean;
|
||||||
|
isPublicRepo: boolean;
|
||||||
|
}): void {
|
||||||
const realHome = homedir();
|
const realHome = homedir();
|
||||||
const cursorConfigDir = join(realHome, ".config", "cursor");
|
const cursorConfigDir = join(realHome, ".config", "cursor");
|
||||||
const cliConfigPath = join(cursorConfigDir, "cli-config.json");
|
const cliConfigPath = join(cursorConfigDir, "cli-config.json");
|
||||||
mkdirSync(cursorConfigDir, { recursive: true });
|
mkdirSync(cursorConfigDir, { recursive: true });
|
||||||
|
|
||||||
|
// deny native shell for public repos to prevent secret leakage
|
||||||
|
const denyShell = isPublicRepo ? ["Shell(*)"] : [];
|
||||||
|
|
||||||
const config = sandbox
|
const config = sandbox
|
||||||
? {
|
? {
|
||||||
permissions: {
|
permissions: {
|
||||||
allow: ["Read(**)"],
|
allow: ["Read(**)"],
|
||||||
deny: ["Write(**)", "Shell(*)"],
|
deny: ["Write(**)", ...denyShell],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
permissions: {
|
permissions: {
|
||||||
allow: ["Read(**)", "Write(**)"],
|
allow: ["Read(**)", "Write(**)"],
|
||||||
deny: ["Shell(*)"],
|
deny: denyShell,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8");
|
writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8");
|
||||||
log.info(`» CLI config written to ${cliConfigPath} (sandbox: ${sandbox})`);
|
log.info(
|
||||||
|
`» CLI config written to ${cliConfigPath} (sandbox: ${sandbox}, isPublicRepo: ${isPublicRepo})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-17
@@ -157,28 +157,37 @@ export const gemini = agent({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
run: async ({ payload, apiKey, mcpServers, cliPath, repo }) => {
|
run: async ({ payload, apiKey, mcpServers, cliPath, repo }) => {
|
||||||
configureGeminiMcpServers({ mcpServers, cliPath });
|
configureGeminiMcpServers({ mcpServers, isPublicRepo: repo.isPublic });
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error("google_api_key or gemini_api_key is required for gemini agent");
|
throw new Error("google_api_key or gemini_api_key is required for gemini agent");
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionPrompt = addInstructions({ payload, repo, useNativeBash: true });
|
const sessionPrompt = addInstructions({ payload, repo });
|
||||||
log.group("Full prompt", () => log.info(sessionPrompt));
|
log.group("Full prompt", () => log.info(sessionPrompt));
|
||||||
|
|
||||||
// Gemini CLI isolates subprocess env - native bash is safe, no MCP override needed
|
// build CLI args based on sandbox mode
|
||||||
// sandbox mode uses --allowed-tools to restrict to read-only operations
|
// for public repos, native shell is disabled via excludeTools in settings.json
|
||||||
const args = payload.sandbox
|
let args: string[];
|
||||||
? [
|
if (payload.sandbox) {
|
||||||
"--allowed-tools",
|
// sandbox mode: read-only tools only
|
||||||
"read_file,list_directory,search_file_content,glob,save_memory,write_todos",
|
args = [
|
||||||
"--allowed-mcp-server-names",
|
"--allowed-tools",
|
||||||
"gh_pullfrog",
|
"read_file,list_directory,search_file_content,glob,save_memory,write_todos",
|
||||||
"--output-format=stream-json",
|
"--allowed-mcp-server-names",
|
||||||
"-p",
|
"gh_pullfrog",
|
||||||
sessionPrompt,
|
"--output-format=stream-json",
|
||||||
]
|
"-p",
|
||||||
: ["--yolo", "--output-format=stream-json", "-p", sessionPrompt];
|
sessionPrompt,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// normal mode: --yolo for auto-approval
|
||||||
|
// for public repos, shell is excluded via settings.json excludeTools
|
||||||
|
args = ["--yolo", "--output-format=stream-json", "-p", sessionPrompt];
|
||||||
|
if (repo.isPublic) {
|
||||||
|
log.info("🔒 public repo: native shell disabled via excludeTools, using MCP bash");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.sandbox) {
|
if (payload.sandbox) {
|
||||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
||||||
@@ -264,12 +273,19 @@ export const gemini = agent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type ConfigureGeminiParams = {
|
||||||
|
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
||||||
|
isPublicRepo: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure MCP servers for Gemini by writing to settings.json.
|
* Configure MCP servers for Gemini by writing to settings.json.
|
||||||
* Gemini CLI uses `httpUrl` for HTTP/streamable transport, `url` for SSE transport.
|
* Gemini CLI uses `httpUrl` for HTTP/streamable transport, `url` for SSE transport.
|
||||||
* See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md
|
* See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md
|
||||||
|
*
|
||||||
|
* For public repos, also configures excludeTools to disable native shell.
|
||||||
*/
|
*/
|
||||||
function configureGeminiMcpServers({ mcpServers }: ConfigureMcpServersParams): void {
|
function configureGeminiMcpServers({ mcpServers, isPublicRepo }: ConfigureGeminiParams): void {
|
||||||
const realHome = homedir();
|
const realHome = homedir();
|
||||||
const geminiConfigDir = join(realHome, ".gemini");
|
const geminiConfigDir = join(realHome, ".gemini");
|
||||||
const settingsPath = join(geminiConfigDir, "settings.json");
|
const settingsPath = join(geminiConfigDir, "settings.json");
|
||||||
@@ -314,11 +330,16 @@ function configureGeminiMcpServers({ mcpServers }: ConfigureMcpServersParams): v
|
|||||||
}
|
}
|
||||||
|
|
||||||
// merge with existing settings, overwriting mcpServers
|
// merge with existing settings, overwriting mcpServers
|
||||||
const newSettings = {
|
// for public repos, exclude native shell tool to prevent secret leakage via env
|
||||||
|
const newSettings: Record<string, unknown> = {
|
||||||
...existingSettings,
|
...existingSettings,
|
||||||
mcpServers: geminiMcpServers,
|
mcpServers: geminiMcpServers,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isPublicRepo) {
|
||||||
|
newSettings.excludeTools = ["run_shell_command"];
|
||||||
|
}
|
||||||
|
|
||||||
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
|
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
|
||||||
log.info(`» MCP config written to ${settingsPath}`);
|
log.info(`» MCP config written to ${settingsPath}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface RepoInfo {
|
|||||||
owner: string;
|
owner: string;
|
||||||
name: string;
|
name: string;
|
||||||
defaultBranch: string;
|
defaultBranch: string;
|
||||||
|
isPublic: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,14 +54,12 @@ function buildRuntimeContext(repo: RepoInfo): string {
|
|||||||
interface AddInstructionsParams {
|
interface AddInstructionsParams {
|
||||||
payload: Payload;
|
payload: Payload;
|
||||||
repo: RepoInfo;
|
repo: RepoInfo;
|
||||||
useNativeBash?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addInstructions = ({
|
export const addInstructions = ({ payload, repo }: AddInstructionsParams) => {
|
||||||
payload,
|
// for public repos, always use MCP bash for security (filters secrets)
|
||||||
repo,
|
// for private repos, agents can use their native bash
|
||||||
useNativeBash = false,
|
const useNativeBash = !repo.isPublic;
|
||||||
}: AddInstructionsParams) => {
|
|
||||||
let encodedEvent = "";
|
let encodedEvent = "";
|
||||||
|
|
||||||
const eventKeys = Object.keys(payload.event);
|
const eventKeys = Object.keys(payload.event);
|
||||||
|
|||||||
+24
-5
@@ -28,7 +28,11 @@ export const opencode = agent({
|
|||||||
const configDir = join(tempHome, ".config", "opencode");
|
const configDir = join(tempHome, ".config", "opencode");
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
|
|
||||||
configureOpenCode({ mcpServers, sandbox: payload.sandbox ?? false });
|
configureOpenCode({
|
||||||
|
mcpServers,
|
||||||
|
sandbox: payload.sandbox ?? false,
|
||||||
|
isPublicRepo: repo.isPublic,
|
||||||
|
});
|
||||||
|
|
||||||
const prompt = addInstructions({ payload, repo });
|
const prompt = addInstructions({ payload, repo });
|
||||||
log.group("Full prompt", () => log.info(prompt));
|
log.group("Full prompt", () => log.info(prompt));
|
||||||
@@ -190,13 +194,14 @@ export const opencode = agent({
|
|||||||
interface ConfigureOpenCodeParams {
|
interface ConfigureOpenCodeParams {
|
||||||
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
||||||
sandbox: boolean;
|
sandbox: boolean;
|
||||||
|
isPublicRepo: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure OpenCode via opencode.json config file.
|
* Configure OpenCode via opencode.json config file.
|
||||||
* Builds complete config with MCP servers and permissions in a single write to avoid race conditions.
|
* Builds complete config with MCP servers and permissions in a single write to avoid race conditions.
|
||||||
*/
|
*/
|
||||||
function configureOpenCode({ mcpServers, sandbox }: ConfigureOpenCodeParams): void {
|
function configureOpenCode({ mcpServers, sandbox, isPublicRepo }: ConfigureOpenCodeParams): void {
|
||||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
||||||
const configDir = join(tempHome, ".config", "opencode");
|
const configDir = join(tempHome, ".config", "opencode");
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
@@ -220,11 +225,25 @@ function configureOpenCode({ mcpServers, sandbox }: ConfigureOpenCodeParams): vo
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY: OpenCode spawns subprocesses with full process.env, leaking API keys.
|
// SECURITY: For PUBLIC repos, OpenCode spawns subprocesses with full process.env, leaking API keys.
|
||||||
// disable native bash; agents use MCP bash tool which filters secrets.
|
// disable native bash; agents use MCP bash tool which filters secrets.
|
||||||
|
// for private repos, native bash is allowed.
|
||||||
|
const bashPermission = isPublicRepo ? "deny" : "allow";
|
||||||
const permission = sandbox
|
const permission = sandbox
|
||||||
? { edit: "deny", bash: "deny", webfetch: "deny", doom_loop: "allow", external_directory: "allow" }
|
? {
|
||||||
: { edit: "allow", bash: "deny", webfetch: "allow", doom_loop: "allow", external_directory: "allow" };
|
edit: "deny",
|
||||||
|
bash: "deny",
|
||||||
|
webfetch: "deny",
|
||||||
|
doom_loop: "allow",
|
||||||
|
external_directory: "allow",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
edit: "allow",
|
||||||
|
bash: bashPermission,
|
||||||
|
webfetch: "allow",
|
||||||
|
doom_loop: "allow",
|
||||||
|
external_directory: "allow",
|
||||||
|
};
|
||||||
|
|
||||||
// build complete config in one object
|
// build complete config in one object
|
||||||
const config = {
|
const config = {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface RepoInfo {
|
|||||||
owner: string;
|
owner: string;
|
||||||
name: string;
|
name: string;
|
||||||
defaultBranch: string;
|
defaultBranch: string;
|
||||||
|
isPublic: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+112
-43
@@ -1,6 +1,8 @@
|
|||||||
# Bash Tool Security
|
# Bash Tool Security
|
||||||
|
|
||||||
## Architecture
|
> **Note**: Security measures described here apply to **PUBLIC repositories only**. For private repos, agents can use native bash with full environment access.
|
||||||
|
|
||||||
|
## Architecture (Public Repos)
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
@@ -15,7 +17,7 @@
|
|||||||
│ │ ┌─────────────────────────────────────────────────────┐ │ │
|
│ │ ┌─────────────────────────────────────────────────────┐ │ │
|
||||||
│ │ │ Agent CLI (Claude/Cursor/OpenCode/etc.) │ │ │
|
│ │ │ Agent CLI (Claude/Cursor/OpenCode/etc.) │ │ │
|
||||||
│ │ │ - receives filtered env (only API key it needs) │ │ │
|
│ │ │ - receives filtered env (only API key it needs) │ │ │
|
||||||
│ │ │ - has built-in Bash tool (DISABLED) │ │ │
|
│ │ │ - has built-in Bash tool (DISABLED for public) │ │ │
|
||||||
│ │ │ - connects to MCP server for tools │ │ │
|
│ │ │ - connects to MCP server for tools │ │ │
|
||||||
│ │ │ │ │ │
|
│ │ │ │ │ │
|
||||||
│ │ │ ┌───────────────────────────────────────────────┐ │ │ │
|
│ │ │ ┌───────────────────────────────────────────────┐ │ │ │
|
||||||
@@ -35,11 +37,24 @@
|
|||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**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.
|
**Key insight**: For **public repos**, the Pullfrog Action process has all secrets in `process.env`. Agent CLIs have built-in Bash tools that we can't trust since malicious actors can submit PRs with prompt injections. We disable those and provide our own MCP Bash tool that spawns subprocesses securely.
|
||||||
|
|
||||||
|
For **private repos**, the threat model is different — only trusted collaborators can trigger workflows, so we allow native bash with full environment access for better performance and compatibility.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Threat Model
|
## Public vs Private Repos
|
||||||
|
|
||||||
|
| Repo Visibility | Native Bash | Env Filtering | PID Isolation |
|
||||||
|
|-----------------|-------------|---------------|---------------|
|
||||||
|
| **Public** | Disabled | Yes | Yes (in CI) |
|
||||||
|
| **Private** | Enabled | No | No |
|
||||||
|
|
||||||
|
**Rationale**: Public repos are at risk from prompt injection attacks via pull requests from untrusted contributors. Private repos only allow trusted collaborators, so the attack surface is much smaller.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Threat Model (Public Repos)
|
||||||
|
|
||||||
A prompt-injected agent could run malicious bash commands to exfiltrate API keys.
|
A prompt-injected agent could run malicious bash commands to exfiltrate API keys.
|
||||||
|
|
||||||
@@ -55,7 +70,7 @@ The first two are solved by passing filtered env to subprocess. The third requir
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Attack: /proc/$PPID/environ
|
## Attack: /proc/$PPID/environ (Public Repos)
|
||||||
|
|
||||||
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:
|
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:
|
||||||
|
|
||||||
@@ -75,7 +90,7 @@ This bypasses environment filtering because we're reading the parent process's m
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Solution: PID Namespace Isolation
|
## Solution: PID Namespace Isolation (Public Repos)
|
||||||
|
|
||||||
We use Linux PID namespaces to hide the parent process:
|
We use Linux PID namespaces to hide the parent process:
|
||||||
|
|
||||||
@@ -104,53 +119,72 @@ unshare --pid --fork --mount-proc bash -c "$CMD"
|
|||||||
```typescript
|
```typescript
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
// filter sensitive env vars (defense in depth)
|
// filter sensitive env vars (only for public repos)
|
||||||
function filterEnv(): Record<string, string> {
|
function filterEnv(isPublicRepo: boolean): Record<string, string> {
|
||||||
const SENSITIVE = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /^ANTHROPIC/i, ...];
|
const SENSITIVE = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i];
|
||||||
const filtered: Record<string, string> = {};
|
const filtered: Record<string, string> = {};
|
||||||
for (const [key, value] of Object.entries(process.env)) {
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
if (value && !SENSITIVE.some(p => p.test(key))) {
|
if (value === undefined) continue;
|
||||||
filtered[key] = value;
|
// only filter sensitive vars for public repos
|
||||||
}
|
if (isPublicRepo && SENSITIVE.some(p => p.test(key))) continue;
|
||||||
|
filtered[key] = value;
|
||||||
}
|
}
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
// spawn with PID namespace in GitHub Actions, plain spawn locally
|
// spawn with PID namespace in CI for public repos, plain spawn otherwise
|
||||||
function spawnSandboxed(command: string, options: { env, cwd }): ChildProcess {
|
function spawnSandboxed(command: string, options: { env, cwd, isPublicRepo }): ChildProcess {
|
||||||
if (process.env.GITHUB_ACTIONS === "true") {
|
const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo;
|
||||||
|
if (useNamespaceIsolation) {
|
||||||
return spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], options);
|
return spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], options);
|
||||||
}
|
}
|
||||||
return spawn("bash", ["-c", command], options);
|
return spawn("bash", ["-c", command], options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BashTool uses ctx.repo.private to determine visibility
|
||||||
|
export function BashTool(ctx: ToolContext) {
|
||||||
|
const isPublicRepo = !ctx.repo.private;
|
||||||
|
// ... spawns with filterEnv(isPublicRepo) and isPublicRepo flag
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Defense in depth:**
|
**Defense in depth (public repos only):**
|
||||||
1. `filterEnv()` - prevents `env` and `echo $VAR` attacks
|
1. `filterEnv(true)` - prevents `env` and `echo $VAR` attacks
|
||||||
2. `unshare` - prevents `/proc/$PPID/environ` attack
|
2. `unshare` - prevents `/proc/$PPID/environ` attack
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Disabling Native Bash Tools
|
## Disabling Native Bash Tools (Public Repos)
|
||||||
|
|
||||||
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:
|
For **public repos**, each agent's built-in Bash/Shell tools are disabled. Agents use our MCP Bash tool which filters secrets:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Claude
|
// Claude - conditional based on repo.isPublic
|
||||||
disallowedTools: ["Bash"],
|
const disallowedTools = repo.isPublic ? ["Bash"] : [];
|
||||||
|
{ permissionMode: "bypassPermissions", disallowedTools }
|
||||||
|
|
||||||
// Cursor
|
// Cursor - conditional shell denial
|
||||||
permissions: { deny: ["Shell(**)"] }
|
const denyShell = isPublicRepo ? ["Shell(*)"] : [];
|
||||||
|
{ permissions: { allow: ["Read(**)", "Write(**)"], deny: denyShell } }
|
||||||
|
|
||||||
// OpenCode
|
// OpenCode - conditional bash denial
|
||||||
permission: { bash: "deny" }
|
const bashPermission = isPublicRepo ? "deny" : "allow";
|
||||||
|
{ permission: { edit: "allow", bash: bashPermission, ... } }
|
||||||
|
|
||||||
|
// Gemini - uses excludeTools in ~/.gemini/settings.json
|
||||||
|
newSettings.excludeTools = ["run_shell_command"];
|
||||||
|
|
||||||
|
// Codex - NO SDK mechanism to disable native shell
|
||||||
|
// Relies on instructions only (limitation)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For **private repos**, native bash is allowed for all agents.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing
|
## Testing (Public Repo Scenario)
|
||||||
|
|
||||||
Run the vulnerability test in Docker:
|
Run the vulnerability test in Docker to verify protection for public repos:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# from action/ directory
|
# from action/ directory
|
||||||
@@ -177,7 +211,26 @@ Expected output:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What This Does NOT Protect Against
|
## Platform Notes
|
||||||
|
|
||||||
|
| Environment | Repo | Our approach |
|
||||||
|
|-------------|------|--------------|
|
||||||
|
| GitHub Actions (Linux) | Public | filterEnv + unshare + disable native bash |
|
||||||
|
| GitHub Actions (Linux) | Private | Full env + native bash allowed |
|
||||||
|
| Local dev (any OS) | Any | No filtering (local dev assumed trusted) |
|
||||||
|
|
||||||
|
We check `process.env.CI === "true"` (set by GitHub Actions) combined with `ctx.repo.private` to determine the security posture:
|
||||||
|
- **CI + Public repo**: Full protection with PID namespace isolation
|
||||||
|
- **CI + Private repo**: No protection (trusted collaborators only)
|
||||||
|
- **Local**: No protection (developer's own machine)
|
||||||
|
|
||||||
|
GitHub Actions uses Ubuntu runners where `unshare` works without root.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What This Does NOT Protect Against (Public Repos)
|
||||||
|
|
||||||
|
Even with protections enabled, bash subprocesses can still:
|
||||||
|
|
||||||
- **Network exfiltration**: Child has full network access
|
- **Network exfiltration**: Child has full network access
|
||||||
- **File access**: Child can read any file the runner can (same UID)
|
- **File access**: Child can read any file the runner can (same UID)
|
||||||
@@ -185,31 +238,47 @@ Expected output:
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
For **private repos**, none of these protections apply since we trust collaborators.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Agent-Specific Notes
|
## Agent-Specific Notes
|
||||||
|
|
||||||
### Platform Environments
|
### Claude, Cursor, OpenCode (Public Repos)
|
||||||
|
|
||||||
| Environment | `CI` | Our approach |
|
These agents have their native Bash disabled via configuration. They use our `gh_pullfrog` MCP server's `bash` tool which implements `filterEnv()` + `unshare`.
|
||||||
|-------------|------|--------------|
|
|
||||||
| GitHub Actions (Linux) | `"true"` | filterEnv + unshare |
|
|
||||||
| Local dev (any OS) | unset | filterEnv only |
|
|
||||||
|
|
||||||
We check `CI=true` (set automatically by GitHub) rather than platform detection. This means:
|
For private repos, native bash is enabled for these agents.
|
||||||
- **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.
|
### Gemini (Public Repos)
|
||||||
|
|
||||||
### Agents Using MCP Bash (Claude, Cursor, OpenCode)
|
Gemini CLI supports `excludeTools` in its user-level settings file (`~/.gemini/settings.json`). For public repos, we exclude the native shell tool:
|
||||||
|
|
||||||
These agents have their native Bash disabled. They use our `gh_pullfrog` MCP server's `bash` tool which implements `filterEnv()` + `unshare`.
|
```typescript
|
||||||
|
// written to ~/.gemini/settings.json
|
||||||
|
newSettings.excludeTools = ["run_shell_command"];
|
||||||
|
```
|
||||||
|
|
||||||
### Gemini
|
This is a blocklist approach which explicitly excludes the shell tool while allowing all other tools.
|
||||||
|
|
||||||
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()`.
|
Additionally, Gemini has built-in CI detection that filters shell env when `GITHUB_SHA` is set.
|
||||||
|
|
||||||
### Codex
|
### Codex (Limitation)
|
||||||
|
|
||||||
Uses `shell_environment_policy` in config. Needs proper configuration or MCP bash fallback.
|
**⚠️ Codex SDK does not support disabling native shell commands.** The SDK only offers `sandboxMode` options which control filesystem access, not specific tool availability.
|
||||||
|
|
||||||
|
For public repos, we rely on:
|
||||||
|
1. **Instructions** telling the agent to use MCP bash instead of native shell
|
||||||
|
2. **MCP bash tool** being available as an alternative
|
||||||
|
|
||||||
|
This is a known limitation. Codex may still use native shell if it doesn't follow instructions.
|
||||||
|
|
||||||
|
### Summary by Agent
|
||||||
|
|
||||||
|
| Agent | Public Repo | Private Repo |
|
||||||
|
|-------|-------------|--------------|
|
||||||
|
| Claude | Native bash **disabled** | Native bash allowed |
|
||||||
|
| Cursor | Native shell **disabled** | Native shell allowed |
|
||||||
|
| OpenCode | Native bash **disabled** | Native bash allowed |
|
||||||
|
| Gemini | Native shell **disabled** (via excludeTools) | Native bash allowed |
|
||||||
|
| Codex | Instructions only (**⚠️ not enforced**) | Native bash allowed |
|
||||||
|
|||||||
@@ -100536,11 +100536,8 @@ function buildRuntimeContext(repo) {
|
|||||||
}
|
}
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
var addInstructions = ({
|
var addInstructions = ({ payload, repo }) => {
|
||||||
payload,
|
const useNativeBash = !repo.isPublic;
|
||||||
repo,
|
|
||||||
useNativeBash = false
|
|
||||||
}) => {
|
|
||||||
let encodedEvent = "";
|
let encodedEvent = "";
|
||||||
const eventKeys = Object.keys(payload.event);
|
const eventKeys = Object.keys(payload.event);
|
||||||
if (eventKeys.length === 1 && eventKeys[0] === "trigger") {
|
if (eventKeys.length === 1 && eventKeys[0] === "trigger") {
|
||||||
@@ -104754,6 +104751,7 @@ var claude = agent({
|
|||||||
delete process.env.ANTHROPIC_API_KEY;
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
const prompt = addInstructions({ payload, repo });
|
const prompt = addInstructions({ payload, repo });
|
||||||
log.group("Full prompt", () => log.info(prompt));
|
log.group("Full prompt", () => log.info(prompt));
|
||||||
|
const disallowedTools = repo.isPublic ? ["Bash"] : [];
|
||||||
const sandboxOptions = payload.sandbox ? {
|
const sandboxOptions = payload.sandbox ? {
|
||||||
permissionMode: "default",
|
permissionMode: "default",
|
||||||
disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"],
|
disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"],
|
||||||
@@ -104764,7 +104762,7 @@ var claude = agent({
|
|||||||
}
|
}
|
||||||
} : {
|
} : {
|
||||||
permissionMode: "bypassPermissions",
|
permissionMode: "bypassPermissions",
|
||||||
disallowedTools: ["Bash"]
|
disallowedTools
|
||||||
};
|
};
|
||||||
if (payload.sandbox) {
|
if (payload.sandbox) {
|
||||||
log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations");
|
log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations");
|
||||||
@@ -105229,6 +105227,11 @@ var codex = agent({
|
|||||||
if (payload.sandbox) {
|
if (payload.sandbox) {
|
||||||
log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations");
|
log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations");
|
||||||
}
|
}
|
||||||
|
if (repo.isPublic) {
|
||||||
|
log.info(
|
||||||
|
"\u{1F512} public repo: instructions direct to MCP bash (native shell NOT disabled in SDK)"
|
||||||
|
);
|
||||||
|
}
|
||||||
const codex2 = new Codex(codexOptions);
|
const codex2 = new Codex(codexOptions);
|
||||||
const thread = codex2.startThread(
|
const thread = codex2.startThread(
|
||||||
payload.sandbox ? {
|
payload.sandbox ? {
|
||||||
@@ -105243,7 +105246,7 @@ var codex = agent({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo, useNativeBash: true }));
|
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo }));
|
||||||
let finalOutput2 = "";
|
let finalOutput2 = "";
|
||||||
for await (const event of streamedTurn.events) {
|
for await (const event of streamedTurn.events) {
|
||||||
const handler2 = messageHandlers2[event.type];
|
const handler2 = messageHandlers2[event.type];
|
||||||
@@ -105387,7 +105390,7 @@ var cursor = agent({
|
|||||||
},
|
},
|
||||||
run: async ({ payload, apiKey, cliPath, mcpServers, repo }) => {
|
run: async ({ payload, apiKey, cliPath, mcpServers, repo }) => {
|
||||||
configureCursorMcpServers({ mcpServers, cliPath });
|
configureCursorMcpServers({ mcpServers, cliPath });
|
||||||
configureCursorSandbox({ sandbox: payload.sandbox ?? false });
|
configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic });
|
||||||
const loggedModelCallIds = /* @__PURE__ */ new Set();
|
const loggedModelCallIds = /* @__PURE__ */ new Set();
|
||||||
const messageHandlers5 = {
|
const messageHandlers5 = {
|
||||||
system: (_event) => {
|
system: (_event) => {
|
||||||
@@ -105552,24 +105555,30 @@ function configureCursorMcpServers({ mcpServers }) {
|
|||||||
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: cursorMcpServers }, null, 2), "utf-8");
|
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: cursorMcpServers }, null, 2), "utf-8");
|
||||||
log.info(`\xBB MCP config written to ${mcpConfigPath}`);
|
log.info(`\xBB MCP config written to ${mcpConfigPath}`);
|
||||||
}
|
}
|
||||||
function configureCursorSandbox({ sandbox }) {
|
function configureCursorSandbox({
|
||||||
|
sandbox,
|
||||||
|
isPublicRepo
|
||||||
|
}) {
|
||||||
const realHome = homedir2();
|
const realHome = homedir2();
|
||||||
const cursorConfigDir = join7(realHome, ".config", "cursor");
|
const cursorConfigDir = join7(realHome, ".config", "cursor");
|
||||||
const cliConfigPath = join7(cursorConfigDir, "cli-config.json");
|
const cliConfigPath = join7(cursorConfigDir, "cli-config.json");
|
||||||
mkdirSync4(cursorConfigDir, { recursive: true });
|
mkdirSync4(cursorConfigDir, { recursive: true });
|
||||||
|
const denyShell = isPublicRepo ? ["Shell(*)"] : [];
|
||||||
const config4 = sandbox ? {
|
const config4 = sandbox ? {
|
||||||
permissions: {
|
permissions: {
|
||||||
allow: ["Read(**)"],
|
allow: ["Read(**)"],
|
||||||
deny: ["Write(**)", "Shell(*)"]
|
deny: ["Write(**)", ...denyShell]
|
||||||
}
|
}
|
||||||
} : {
|
} : {
|
||||||
permissions: {
|
permissions: {
|
||||||
allow: ["Read(**)", "Write(**)"],
|
allow: ["Read(**)", "Write(**)"],
|
||||||
deny: ["Shell(*)"]
|
deny: denyShell
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
writeFileSync(cliConfigPath, JSON.stringify(config4, null, 2), "utf-8");
|
writeFileSync(cliConfigPath, JSON.stringify(config4, null, 2), "utf-8");
|
||||||
log.info(`\xBB CLI config written to ${cliConfigPath} (sandbox: ${sandbox})`);
|
log.info(
|
||||||
|
`\xBB CLI config written to ${cliConfigPath} (sandbox: ${sandbox}, isPublicRepo: ${isPublicRepo})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// agents/gemini.ts
|
// agents/gemini.ts
|
||||||
@@ -105737,21 +105746,29 @@ var gemini = agent({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
run: async ({ payload, apiKey, mcpServers, cliPath, repo }) => {
|
run: async ({ payload, apiKey, mcpServers, cliPath, repo }) => {
|
||||||
configureGeminiMcpServers({ mcpServers, cliPath });
|
configureGeminiMcpServers({ mcpServers, isPublicRepo: repo.isPublic });
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error("google_api_key or gemini_api_key is required for gemini agent");
|
throw new Error("google_api_key or gemini_api_key is required for gemini agent");
|
||||||
}
|
}
|
||||||
const sessionPrompt = addInstructions({ payload, repo, useNativeBash: true });
|
const sessionPrompt = addInstructions({ payload, repo });
|
||||||
log.group("Full prompt", () => log.info(sessionPrompt));
|
log.group("Full prompt", () => log.info(sessionPrompt));
|
||||||
const args3 = payload.sandbox ? [
|
let args3;
|
||||||
"--allowed-tools",
|
if (payload.sandbox) {
|
||||||
"read_file,list_directory,search_file_content,glob,save_memory,write_todos",
|
args3 = [
|
||||||
"--allowed-mcp-server-names",
|
"--allowed-tools",
|
||||||
"gh_pullfrog",
|
"read_file,list_directory,search_file_content,glob,save_memory,write_todos",
|
||||||
"--output-format=stream-json",
|
"--allowed-mcp-server-names",
|
||||||
"-p",
|
"gh_pullfrog",
|
||||||
sessionPrompt
|
"--output-format=stream-json",
|
||||||
] : ["--yolo", "--output-format=stream-json", "-p", sessionPrompt];
|
"-p",
|
||||||
|
sessionPrompt
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
args3 = ["--yolo", "--output-format=stream-json", "-p", sessionPrompt];
|
||||||
|
if (repo.isPublic) {
|
||||||
|
log.info("\u{1F512} public repo: native shell disabled via excludeTools, using MCP bash");
|
||||||
|
}
|
||||||
|
}
|
||||||
if (payload.sandbox) {
|
if (payload.sandbox) {
|
||||||
log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations");
|
log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations");
|
||||||
}
|
}
|
||||||
@@ -105818,7 +105835,7 @@ var gemini = agent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
function configureGeminiMcpServers({ mcpServers }) {
|
function configureGeminiMcpServers({ mcpServers, isPublicRepo }) {
|
||||||
const realHome = homedir3();
|
const realHome = homedir3();
|
||||||
const geminiConfigDir = join8(realHome, ".gemini");
|
const geminiConfigDir = join8(realHome, ".gemini");
|
||||||
const settingsPath = join8(geminiConfigDir, "settings.json");
|
const settingsPath = join8(geminiConfigDir, "settings.json");
|
||||||
@@ -105847,6 +105864,9 @@ function configureGeminiMcpServers({ mcpServers }) {
|
|||||||
...existingSettings,
|
...existingSettings,
|
||||||
mcpServers: geminiMcpServers
|
mcpServers: geminiMcpServers
|
||||||
};
|
};
|
||||||
|
if (isPublicRepo) {
|
||||||
|
newSettings.excludeTools = ["run_shell_command"];
|
||||||
|
}
|
||||||
writeFileSync2(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
|
writeFileSync2(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
|
||||||
log.info(`\xBB MCP config written to ${settingsPath}`);
|
log.info(`\xBB MCP config written to ${settingsPath}`);
|
||||||
}
|
}
|
||||||
@@ -105868,7 +105888,11 @@ var opencode = agent({
|
|||||||
const tempHome = process.env.PULLFROG_TEMP_DIR;
|
const tempHome = process.env.PULLFROG_TEMP_DIR;
|
||||||
const configDir = join9(tempHome, ".config", "opencode");
|
const configDir = join9(tempHome, ".config", "opencode");
|
||||||
mkdirSync6(configDir, { recursive: true });
|
mkdirSync6(configDir, { recursive: true });
|
||||||
configureOpenCode({ mcpServers, sandbox: payload.sandbox ?? false });
|
configureOpenCode({
|
||||||
|
mcpServers,
|
||||||
|
sandbox: payload.sandbox ?? false,
|
||||||
|
isPublicRepo: repo.isPublic
|
||||||
|
});
|
||||||
const prompt = addInstructions({ payload, repo });
|
const prompt = addInstructions({ payload, repo });
|
||||||
log.group("Full prompt", () => log.info(prompt));
|
log.group("Full prompt", () => log.info(prompt));
|
||||||
const args3 = ["run", prompt, "--format", "json"];
|
const args3 = ["run", prompt, "--format", "json"];
|
||||||
@@ -105985,7 +106009,7 @@ var opencode = agent({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
function configureOpenCode({ mcpServers, sandbox }) {
|
function configureOpenCode({ mcpServers, sandbox, isPublicRepo }) {
|
||||||
const tempHome = process.env.PULLFROG_TEMP_DIR;
|
const tempHome = process.env.PULLFROG_TEMP_DIR;
|
||||||
const configDir = join9(tempHome, ".config", "opencode");
|
const configDir = join9(tempHome, ".config", "opencode");
|
||||||
mkdirSync6(configDir, { recursive: true });
|
mkdirSync6(configDir, { recursive: true });
|
||||||
@@ -106005,7 +106029,20 @@ function configureOpenCode({ mcpServers, sandbox }) {
|
|||||||
url: serverConfig.url
|
url: serverConfig.url
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const permission = sandbox ? { edit: "deny", bash: "deny", webfetch: "deny", doom_loop: "allow", external_directory: "allow" } : { edit: "allow", bash: "deny", webfetch: "allow", doom_loop: "allow", external_directory: "allow" };
|
const bashPermission = isPublicRepo ? "deny" : "allow";
|
||||||
|
const permission = sandbox ? {
|
||||||
|
edit: "deny",
|
||||||
|
bash: "deny",
|
||||||
|
webfetch: "deny",
|
||||||
|
doom_loop: "allow",
|
||||||
|
external_directory: "allow"
|
||||||
|
} : {
|
||||||
|
edit: "allow",
|
||||||
|
bash: bashPermission,
|
||||||
|
webfetch: "allow",
|
||||||
|
doom_loop: "allow",
|
||||||
|
external_directory: "allow"
|
||||||
|
};
|
||||||
const config4 = {
|
const config4 = {
|
||||||
mcp: opencodeMcpServers,
|
mcp: opencodeMcpServers,
|
||||||
permission
|
permission
|
||||||
@@ -137976,38 +138013,24 @@ var BashParams = type({
|
|||||||
"timeout?": "number",
|
"timeout?": "number",
|
||||||
"working_directory?": "string"
|
"working_directory?": "string"
|
||||||
});
|
});
|
||||||
var SENSITIVE_PATTERNS = [
|
var SENSITIVE_PATTERNS = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i];
|
||||||
/_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) {
|
function isSensitive(key) {
|
||||||
return SENSITIVE_PATTERNS.some((p) => p.test(key));
|
return SENSITIVE_PATTERNS.some((p) => p.test(key));
|
||||||
}
|
}
|
||||||
function filterEnv() {
|
function filterEnv(isPublicRepo) {
|
||||||
const filtered = {};
|
const filtered = {};
|
||||||
for (const [key, value2] of Object.entries(process.env)) {
|
for (const [key, value2] of Object.entries(process.env)) {
|
||||||
if (value2 !== void 0 && !isSensitive(key)) filtered[key] = value2;
|
if (value2 === void 0) continue;
|
||||||
|
if (isPublicRepo && isSensitive(key)) continue;
|
||||||
|
filtered[key] = value2;
|
||||||
}
|
}
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
function spawnSandboxed(command, options) {
|
function spawnSandboxed(command, options) {
|
||||||
const stdio = ["ignore", "pipe", "pipe"];
|
const stdio = ["ignore", "pipe", "pipe"];
|
||||||
const spawnOpts = { env: options.env, cwd: options.cwd, stdio, detached: true };
|
const spawnOpts = { env: options.env, cwd: options.cwd, stdio, detached: true };
|
||||||
return process.env.CI === "true" ? spawn5("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts) : spawn5("bash", ["-c", command], spawnOpts);
|
const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo;
|
||||||
|
return useNamespaceIsolation ? spawn5("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts) : spawn5("bash", ["-c", command], spawnOpts);
|
||||||
}
|
}
|
||||||
async function killProcessGroup(proc) {
|
async function killProcessGroup(proc) {
|
||||||
if (!proc.pid) return;
|
if (!proc.pid) return;
|
||||||
@@ -138022,21 +138045,27 @@ async function killProcessGroup(proc) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function BashTool(_ctx) {
|
function BashTool(ctx) {
|
||||||
|
const isPublicRepo = !ctx.repo.private;
|
||||||
return tool({
|
return tool({
|
||||||
name: "bash",
|
name: "bash",
|
||||||
description: `Execute shell commands securely. Environment is filtered to remove API keys and secrets.
|
description: `Execute shell commands securely.${isPublicRepo ? " Environment is filtered to remove API keys and secrets." : ""}
|
||||||
|
|
||||||
Use this tool to:
|
Use this tool to:
|
||||||
- Run shell commands (ls, cat, grep, find, etc.)
|
- Run shell commands (ls, cat, grep, find, etc.)
|
||||||
- Execute build tools (npm, pnpm, cargo, make, etc.)
|
- Execute build tools (npm, pnpm, cargo, make, etc.)
|
||||||
- Run tests and linters
|
- Run tests and linters
|
||||||
- Perform git operations`,
|
- Perform git operations
|
||||||
|
${isPublicRepo ? "\nThe command runs in a bash shell with a filtered environment that excludes sensitive variables like API keys and tokens." : ""}`,
|
||||||
parameters: BashParams,
|
parameters: BashParams,
|
||||||
execute: execute(async (params) => {
|
execute: execute(async (params) => {
|
||||||
const timeout = Math.min(params.timeout ?? 12e4, 6e5);
|
const timeout = Math.min(params.timeout ?? 12e4, 6e5);
|
||||||
const cwd2 = params.working_directory ?? process.cwd();
|
const cwd2 = params.working_directory ?? process.cwd();
|
||||||
const proc = spawnSandboxed(params.command, { env: filterEnv(), cwd: cwd2 });
|
const proc = spawnSandboxed(params.command, {
|
||||||
|
env: filterEnv(isPublicRepo),
|
||||||
|
cwd: cwd2,
|
||||||
|
isPublicRepo
|
||||||
|
});
|
||||||
let stdout = "", stderr = "", timedOut = false, exited = false;
|
let stdout = "", stderr = "", timedOut = false, exited = false;
|
||||||
proc.stdout?.on("data", (chunk) => {
|
proc.stdout?.on("data", (chunk) => {
|
||||||
stdout += chunk.toString();
|
stdout += chunk.toString();
|
||||||
@@ -138609,7 +138638,8 @@ ${encode(eventWithoutContext)}`;
|
|||||||
repo: {
|
repo: {
|
||||||
owner: ctx.owner,
|
owner: ctx.owner,
|
||||||
name: ctx.name,
|
name: ctx.name,
|
||||||
defaultBranch: ctx.repo.default_branch
|
defaultBranch: ctx.repo.default_branch,
|
||||||
|
isPublic: !ctx.repo.private
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { mkdtemp } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { flatMorph } from "@ark/util";
|
import { flatMorph } from "@ark/util";
|
||||||
import { Octokit } from "@octokit/rest";
|
import type { Octokit } from "@octokit/rest";
|
||||||
import { encode as toonEncode } from "@toon-format/toon";
|
import { encode as toonEncode } from "@toon-format/toon";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { type Agent, agents } from "./agents/index.ts";
|
import { type Agent, agents } from "./agents/index.ts";
|
||||||
@@ -513,6 +513,7 @@ async function runAgent(ctx: AgentContext): Promise<AgentResult> {
|
|||||||
owner: ctx.owner,
|
owner: ctx.owner,
|
||||||
name: ctx.name,
|
name: ctx.name,
|
||||||
defaultBranch: ctx.repo.default_branch,
|
defaultBranch: ctx.repo.default_branch,
|
||||||
|
isPublic: !ctx.repo.private,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-28
@@ -11,49 +11,37 @@ export const BashParams = type({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// patterns for sensitive env vars: suffixes (_KEY, _SECRET, _TOKEN) plus AI provider prefixes
|
// patterns for sensitive env vars: suffixes (_KEY, _SECRET, _TOKEN) plus AI provider prefixes
|
||||||
const SENSITIVE_PATTERNS = [
|
const SENSITIVE_PATTERNS = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i];
|
||||||
/_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 {
|
function isSensitive(key: string): boolean {
|
||||||
return SENSITIVE_PATTERNS.some((p) => p.test(key));
|
return SENSITIVE_PATTERNS.some((p) => p.test(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** filter env vars, removing sensitive values */
|
/** filter env vars, removing sensitive values (only for public repos) */
|
||||||
function filterEnv(): Record<string, string> {
|
function filterEnv(isPublicRepo: boolean): Record<string, string> {
|
||||||
const filtered: Record<string, string> = {};
|
const filtered: Record<string, string> = {};
|
||||||
for (const [key, value] of Object.entries(process.env)) {
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
if (value !== undefined && !isSensitive(key)) filtered[key] = value;
|
if (value === undefined) continue;
|
||||||
|
// only filter sensitive vars for public repos
|
||||||
|
if (isPublicRepo && isSensitive(key)) continue;
|
||||||
|
filtered[key] = value;
|
||||||
}
|
}
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* spawn command with filtered env. in CI, also use PID namespace isolation
|
* spawn command with filtered env. in CI, also use PID namespace isolation
|
||||||
* to prevent child from reading /proc/$PPID/environ
|
* to prevent child from reading /proc/$PPID/environ (only for public repos)
|
||||||
*/
|
*/
|
||||||
function spawnSandboxed(
|
function spawnSandboxed(
|
||||||
command: string,
|
command: string,
|
||||||
options: { env: Record<string, string>; cwd: string }
|
options: { env: Record<string, string>; cwd: string; isPublicRepo: boolean }
|
||||||
): ChildProcess {
|
): ChildProcess {
|
||||||
const stdio: ["ignore", "pipe", "pipe"] = ["ignore", "pipe", "pipe"];
|
const stdio: ["ignore", "pipe", "pipe"] = ["ignore", "pipe", "pipe"];
|
||||||
const spawnOpts = { env: options.env, cwd: options.cwd, stdio, detached: true };
|
const spawnOpts = { env: options.env, cwd: options.cwd, stdio, detached: true };
|
||||||
return process.env.CI === "true" // requires linux runner or this will fail.
|
// only use PID namespace isolation for public repos in CI
|
||||||
|
const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo;
|
||||||
|
return useNamespaceIsolation
|
||||||
? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts)
|
? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts)
|
||||||
: spawn("bash", ["-c", command], spawnOpts);
|
: spawn("bash", ["-c", command], spawnOpts);
|
||||||
}
|
}
|
||||||
@@ -74,21 +62,28 @@ async function killProcessGroup(proc: ChildProcess): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BashTool(_ctx: ToolContext) {
|
export function BashTool(ctx: ToolContext) {
|
||||||
|
const isPublicRepo = !ctx.repo.private;
|
||||||
|
|
||||||
return tool({
|
return tool({
|
||||||
name: "bash",
|
name: "bash",
|
||||||
description: `Execute shell commands securely. Environment is filtered to remove API keys and secrets.
|
description: `Execute shell commands securely.${isPublicRepo ? " Environment is filtered to remove API keys and secrets." : ""}
|
||||||
|
|
||||||
Use this tool to:
|
Use this tool to:
|
||||||
- Run shell commands (ls, cat, grep, find, etc.)
|
- Run shell commands (ls, cat, grep, find, etc.)
|
||||||
- Execute build tools (npm, pnpm, cargo, make, etc.)
|
- Execute build tools (npm, pnpm, cargo, make, etc.)
|
||||||
- Run tests and linters
|
- Run tests and linters
|
||||||
- Perform git operations`,
|
- Perform git operations
|
||||||
|
${isPublicRepo ? "\nThe command runs in a bash shell with a filtered environment that excludes sensitive variables like API keys and tokens." : ""}`,
|
||||||
parameters: BashParams,
|
parameters: BashParams,
|
||||||
execute: execute(async (params) => {
|
execute: execute(async (params) => {
|
||||||
const timeout = Math.min(params.timeout ?? 120000, 600000);
|
const timeout = Math.min(params.timeout ?? 120000, 600000);
|
||||||
const cwd = params.working_directory ?? process.cwd();
|
const cwd = params.working_directory ?? process.cwd();
|
||||||
const proc = spawnSandboxed(params.command, { env: filterEnv(), cwd });
|
const proc = spawnSandboxed(params.command, {
|
||||||
|
env: filterEnv(isPublicRepo),
|
||||||
|
cwd,
|
||||||
|
isPublicRepo,
|
||||||
|
});
|
||||||
|
|
||||||
let stdout = "",
|
let stdout = "",
|
||||||
stderr = "",
|
stderr = "",
|
||||||
|
|||||||
Reference in New Issue
Block a user