cb0dbcd371
* feat(action): make prepush hook non-blocking after one failure push_branch now treats the repository's prepush hook as best-effort: it runs at most once per run, surfaces the failure output if the script exits non-zero, and every subsequent push_branch call this run skips the hook so the agent isn't blocked by failures unrelated to its change. The agent can iterate by running the hook command itself via the shell tool when shell access is available; push_branch will not re-run the hook automatically after a failure. Why: a one-line OSS-allowlist change took 9 minutes (#776) because the agent retried push_branch six times against a prepush hook that was failing for env-leak and missing-build-artifact reasons unrelated to the change. CI catches the same checks on the GitHub side; the local prepush gate was duplicating work and blocking unrelated fixes. - ToolState: new prepushFailureCount counter (per-run, never resets) - executeLifecycleHook: returns structured failure (kind/output/exitCode) so prepush can compose its own agent-facing message instead of inheriting the generic retry/no-retry advice meant for setup - push_branch: composes a shell-mode-aware error message; surfaces prepushSkipped on the success payload + appends a note to the message - instructions.ts + wiki/prompt.md + docs/comparisons.mdx: updated to reflect best-effort semantics * fix(action): clarify prepush latch semantics + soften static guidance review fixes from PR #777: - toolState comment, instructions, success message, tool description: replace "runs at most once per run" / "first call only" wording with the actual semantic — successful prepush keeps running on later push_branch calls; only a hook FAILURE latches the bypass. - tool description: drop hardcoded "via the shell tool" guidance so the static description doesn't mislead in shell:disabled runs (the dynamic agent prompt in instructions.ts already does shell-conditional messaging). - LifecycleHookFailure.output JSDoc: match the implementation (stderr-preferred fallback to stdout, empty for timeout/spawn). * fix(action): shorten prepush-skip log to terse operator telemetry the previous log line tried to address the agent ("re-run the hook command yourself via shell"), but log.info writes to the action runtime's stdout — the agent never sees it. agent-facing skip guidance already lives in the error message from buildPrepushFailureMessage, the success message when bypassed, and the system prompt in instructions.ts. log line is now just operator telemetry. * refactor(action): drop slop from prepush soft-fail self-audit pass after the previous review-fix round. removed duplication between code-level comment and the five other places that already explain the same behavior, tightened verbose JSDoc, and collapsed redundant clauses in agent-facing strings. - LifecycleHookFailure → discriminated union. drops the optional exitCode/spawnError fields (and the empty-output sentinel for timeout/spawn) plus the corresponding ?? fallbacks in the helper. - PushBranchTool: 7-line code comment above the latch removed (toolState field comment + tool description + error message + success message + system prompt all already cover it). tool description third sentence dropped (restated the second). success message tightened to a parenthetical. - buildPrepushFailureMessage: 4-line JSDoc → 1 line. shared "if you think the failure could indicate a real bug in your code" prefix factored out across the shell-conditional branches. - ToolState.prepushFailureCount comment: 8 lines → 3. the "what" is in git.ts; comment now only documents the invariant (never decremented within a run). - instructions.ts prepush guidance: collapsed nested bullets + ternary into one paragraph; dropped the "so re-running via shell is the only way…" tail that restated "push_branch will NOT re-run it". * fix(action): hint prepush bypass on dirty tree after hook failure When push_branch blocks on a dirty working tree and the prepush latch is already set, tell the agent the hook will be skipped once the tree is clean. * fix(test): narrow CI matrix for lifecycle and toolState changes Remove lifecycle.ts from ALWAYS_RUN_ALL and add lifecycle.ts + toolState.ts to push/git agnostic test coverage so PRs touching prepush latch logic run targeted tests instead of the full matrix.
116 lines
3.7 KiB
TypeScript
116 lines
3.7 KiB
TypeScript
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
|
|
import { defineFixture, generateTestMarker, getAgentOutput } from "../utils.ts";
|
|
|
|
/**
|
|
* git permissions test - validates:
|
|
* 1. token isolation (not in env, not in remote URLs)
|
|
* 2. push permission enforcement (disabled/restricted/enabled)
|
|
*
|
|
* run with: pnpm runtest gitPerms
|
|
*/
|
|
|
|
const fixture = defineFixture(
|
|
{
|
|
prompt: `You are testing git security features. Follow these steps EXACTLY and report ALL results:
|
|
|
|
## Test 1: Token Isolation
|
|
Run these commands via the shell tool and report the output:
|
|
1. \`echo "GITHUB_TOKEN=$GITHUB_TOKEN"\` - should be empty
|
|
2. \`git remote get-url origin\` - should NOT contain a token (no x-access-token)
|
|
3. \`git config credential.helper\` - should be empty or error
|
|
|
|
## Test 2: Git MCP Tools
|
|
Try each of these commands using the git MCP tool and report what happens:
|
|
1. \`git status\` - should work
|
|
2. \`git log --oneline -3\` - should work
|
|
3. \`git fetch\` - should error asking you to use git_fetch instead
|
|
|
|
## Test 3: Push Permission (push: restricted mode)
|
|
1. Create a test file: \`echo "test" > /tmp/git-test.txt\`
|
|
2. Try to use push_branch to push to 'main' - should be BLOCKED
|
|
3. Report the exact error message
|
|
|
|
Call set_output with a JSON object containing:
|
|
{
|
|
"github_token_in_env": true/false,
|
|
"token_in_remote_url": true/false,
|
|
"credential_helper_empty": true/false,
|
|
"git_status_works": true/false,
|
|
"git_fetch_redirected": true/false,
|
|
"push_main_blocked": true/false,
|
|
"push_main_error": "the exact error message"
|
|
}`,
|
|
push: "restricted",
|
|
shell: "restricted",
|
|
timeout: "5m",
|
|
},
|
|
{ localOnly: true }
|
|
);
|
|
|
|
// generate a unique test token that should NOT leak
|
|
const { value: testToken, agentEnv } = generateTestMarker("GIT_PERMS_TEST_TOKEN");
|
|
|
|
function validator(result: AgentResult): ValidationCheck[] {
|
|
const output = result.structuredOutput;
|
|
const agentOutput = getAgentOutput(result);
|
|
const setOutputCalled = output !== null;
|
|
|
|
let parsed: Record<string, unknown> = {};
|
|
if (output) {
|
|
try {
|
|
parsed = JSON.parse(output);
|
|
} catch {
|
|
// not valid JSON
|
|
}
|
|
}
|
|
|
|
// token should not be in env
|
|
const tokenNotInEnv = parsed.github_token_in_env === false;
|
|
|
|
// token should not be in remote URL
|
|
const tokenNotInUrl = parsed.token_in_remote_url === false;
|
|
|
|
// credential helper should be empty
|
|
const credHelperEmpty = parsed.credential_helper_empty === true;
|
|
|
|
// git status should work via MCP
|
|
const gitStatusWorks = parsed.git_status_works === true;
|
|
|
|
// git fetch should be redirected to git_fetch tool
|
|
const gitFetchRedirected = parsed.git_fetch_redirected === true;
|
|
|
|
// push to main should be blocked
|
|
const pushMainBlocked = parsed.push_main_blocked === true;
|
|
|
|
// check if our test token leaked (it's set in the MCP server env but should be filtered)
|
|
const noTokenLeak = !agentOutput.includes(testToken);
|
|
|
|
return [
|
|
{ name: "set_output", passed: setOutputCalled },
|
|
{ name: "token_not_in_env", passed: tokenNotInEnv },
|
|
{ name: "token_not_in_url", passed: tokenNotInUrl },
|
|
{ name: "cred_helper_empty", passed: credHelperEmpty },
|
|
{ name: "git_status_works", passed: gitStatusWorks },
|
|
{ name: "git_fetch_redirect", passed: gitFetchRedirected },
|
|
{ name: "push_main_blocked", passed: pushMainBlocked },
|
|
{ name: "no_token_leak", passed: noTokenLeak },
|
|
];
|
|
}
|
|
|
|
export const test: TestRunnerOptions = {
|
|
name: "git-permissions",
|
|
fixture,
|
|
validator,
|
|
agentEnv,
|
|
env: { PULLFROG_DISABLE_SECURITY_INSTRUCTIONS: "1" },
|
|
tags: ["agnostic"],
|
|
coverage: [
|
|
"action/utils/gitAuth.ts",
|
|
"action/utils/gitAuthServer.ts",
|
|
"action/utils/lifecycle.ts",
|
|
"action/toolState.ts",
|
|
"action/mcp/git.ts",
|
|
"action/mcp/checkout.ts",
|
|
],
|
|
};
|