Skip to content

Commit 58762bd

Browse files
authored
Merge pull request #38 from AxmeAI/feat/axme-gate-20260407
feat: #!axme gate protocol for git commit/push
2 parents 426afd5 + c448168 commit 58762bd

3 files changed

Lines changed: 184 additions & 40 deletions

File tree

src/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ function buildInstructions(): string {
172172
}
173173
parts.push("TRUNCATED OUTPUT RULE: if ANY MCP tool output is truncated or saved to a file (you see 'Output too large' or 'saved to file'), you MUST use the Read tool to read the full file content into your context. Do not proceed with partial data.");
174174
parts.push("Save memories, decisions, and safety rules immediately when discovered during work.");
175+
parts.push("GIT COMMIT/PUSH GATE: every git commit and git push command MUST end with `#!axme pr=<NUMBER|none> repo=<OWNER/REPO>`. Example: `git commit -m \"fix bug\" #!axme pr=42 repo=AxmeAI/axme-code`. Use pr=none if no PR exists yet. Without this suffix the command will be blocked.");
175176
parts.push("SESSION CLOSE: when the user asks to close/end the session (any language), call axme_begin_close to get the close checklist. Follow it: extract memories/decisions/safety (choosing correct scope for each), prepare handoff data, then call axme_finalize_close with everything. After finalize, output to the user: storage summary (what saved where), then startup_text.");
176177
parts.push("DECISION CONFLICT RULE: if two active decisions contradict each other, treat the NEWER one (by date) as authoritative. The older one is a candidate for supersede at next audit.");
177178
parts.push(

src/storage/safety.ts

Lines changed: 61 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -320,56 +320,75 @@ export function checkBash(rules: SafetyRules, command: string): SafetyVerdict {
320320
* For `git push`, also parses branch from command tokens.
321321
*/
322322
/**
323-
* Detect the current git branch in the given directory.
324-
* For push commands, also parses branch from "git push origin <branch>".
323+
* Parse #!axme gate metadata from a command string.
324+
*
325+
* Format: `git commit -m "msg" #!axme pr=37 repo=AxmeAI/axme-code`
326+
* The `#!axme` marker is a bash comment (shell ignores it), but the hook
327+
* sees the full command before execution and parses the metadata.
328+
*
329+
* Returns null if no #!axme marker found.
325330
*/
326-
function detectBranch(command: string, cwd?: string): string | null {
327-
// For push commands, try parsing branch from tokens
328-
if (command.startsWith("git push")) {
329-
const tokens = command.split(/\s+/);
330-
for (let i = 2; i < tokens.length; i++) {
331-
if (tokens[i] === "origin" || tokens[i] === "upstream") {
332-
const candidate = tokens[i + 1];
333-
if (candidate && !candidate.startsWith("-") && !candidate.startsWith("+")) {
334-
return candidate;
335-
}
336-
}
337-
}
338-
}
331+
export function parseAxmeGate(command: string): { pr: string; repo: string } | null {
332+
const match = command.match(/#!axme\s+(.*)/);
333+
if (!match) return null;
334+
const pairs = match[1].trim();
335+
const prMatch = pairs.match(/\bpr=(\S+)/);
336+
const repoMatch = pairs.match(/\brepo=(\S+)/);
337+
if (!prMatch || !repoMatch) return null;
338+
return { pr: prMatch[1], repo: repoMatch[1] };
339+
}
339340

340-
// Run git branch --show-current in the resolved cwd
341-
try {
342-
return execSync("git branch --show-current", {
343-
encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
344-
...(cwd ? { cwd } : {}),
345-
}).trim() || null;
346-
} catch {
347-
return null;
348-
}
341+
const AXME_GATE_INSTRUCTION =
342+
'BLOCKED: git commit/push requires #!axme safety metadata. ' +
343+
'Retry with: `<your command> #!axme pr=<PR_NUMBER|none> repo=<OWNER/REPO>` ' +
344+
'(pr=none if no PR created yet).';
345+
346+
/**
347+
* Check if a PR is merged via gh CLI. Returns true if merged, false otherwise.
348+
* Throws on network/CLI errors (caller decides fail-open vs fail-closed).
349+
*/
350+
function isPrMerged(prNumber: string, repo: string): boolean {
351+
const result = execSync(
352+
`gh pr view ${prNumber} --repo "${repo}" --json state --jq .state`,
353+
{ encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] },
354+
).trim();
355+
return result === "MERGED";
349356
}
350357

351358
/**
352-
* Check if the current branch has a merged PR. Used by checkGit to block
353-
* commit/add/push on stale branches.
359+
* Verify #!axme gate on git commit/push commands.
360+
* - No gate marker -> BLOCK with format instruction
361+
* - pr=none -> ALLOW (new branch, no PR yet)
362+
* - pr=<number> -> check if merged, block if so
363+
* - gh check fails -> BLOCK (fail-closed)
354364
*/
355-
function checkMergedBranch(command: string, cwd?: string): SafetyVerdict | null {
365+
function checkAxmeGate(fullCommand: string): SafetyVerdict | null {
366+
const gate = parseAxmeGate(fullCommand);
367+
if (!gate) {
368+
return { allowed: false, reason: AXME_GATE_INSTRUCTION };
369+
}
370+
if (gate.pr === "none") {
371+
return null; // no PR yet, allowed
372+
}
373+
const prNum = parseInt(gate.pr, 10);
374+
if (isNaN(prNum)) {
375+
return { allowed: false, reason: `Invalid PR number "${gate.pr}". Use pr=<number> or pr=none.` };
376+
}
356377
try {
357-
const branch = detectBranch(command, cwd);
358-
if (!branch || branch === "main" || branch === "master") return null;
359-
const mergedCount = execSync(
360-
`gh pr list --head "${branch}" --state merged --json number --jq length`,
361-
{ encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] },
362-
).trim();
363-
if (mergedCount && parseInt(mergedCount, 10) > 0) {
378+
if (isPrMerged(gate.pr, gate.repo)) {
364379
return {
365380
allowed: false,
366-
reason: `Branch "${branch}" already has a merged PR. Create a new branch from main (D-041: reusing old branches prohibited).`,
381+
reason: `BLOCKED: PR #${gate.pr} in ${gate.repo} is already merged. Create a new branch from main.`,
367382
};
368383
}
369384
} catch {
370-
// gh CLI not available or network error - fail open
385+
// gh failed (network, CLI missing) - fail CLOSED for safety
386+
return {
387+
allowed: false,
388+
reason: `Cannot verify PR #${gate.pr} status (gh CLI error). Check manually: gh pr view ${gate.pr} --repo ${gate.repo} --json state`,
389+
};
371390
}
372-
return null;
391+
return null; // PR is open, allowed
373392
}
374393

375394
/**
@@ -423,10 +442,12 @@ export function checkGit(rules: SafetyRules, command: string, cwd?: string, skip
423442
}
424443
}
425444
}
426-
// Block commit/push to a branch that already has a merged PR.
445+
// Require #!axme gate metadata on git commit and git push.
446+
// The gate carries PR number and repo so the server can verify merge status
447+
// without guessing cwd, branch, or remote. See D-086.
427448
// Skip if the command chain includes git checkout (branch will change before commit).
428-
if (!skipMergedCheck && (stripped.startsWith("git push") || stripped.startsWith("git commit") || stripped.startsWith("git add"))) {
429-
const verdict = checkMergedBranch(stripped, cwd);
449+
if (!skipMergedCheck && (stripped.startsWith("git push") || stripped.startsWith("git commit"))) {
450+
const verdict = checkAxmeGate(command); // use original command (with #!axme comment)
430451
if (verdict && !verdict.allowed) return verdict;
431452
}
432453
if (stripped.includes("reset --hard")) {

test/axme-gate.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, it } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { parseAxmeGate, checkGit } from "../src/storage/safety.js";
4+
import type { SafetyRules } from "../src/storage/safety.js";
5+
6+
const defaultRules: SafetyRules = {
7+
git: { protectedBranches: ["main", "master"], allowForcePush: false, allowDirectPushToMain: false },
8+
bash: { deniedPrefixes: [], deniedCommands: [] },
9+
filesystem: { deniedPaths: [] },
10+
};
11+
12+
describe("parseAxmeGate", () => {
13+
it("parses valid gate metadata", () => {
14+
const result = parseAxmeGate('git commit -m "fix" #!axme pr=37 repo=AxmeAI/axme-code');
15+
assert.deepEqual(result, { pr: "37", repo: "AxmeAI/axme-code" });
16+
});
17+
18+
it("parses pr=none", () => {
19+
const result = parseAxmeGate('git commit -m "init" #!axme pr=none repo=AxmeAI/axme-code');
20+
assert.deepEqual(result, { pr: "none", repo: "AxmeAI/axme-code" });
21+
});
22+
23+
it("returns null for no gate marker", () => {
24+
assert.equal(parseAxmeGate('git commit -m "fix"'), null);
25+
});
26+
27+
it("returns null for incomplete gate (missing repo)", () => {
28+
assert.equal(parseAxmeGate('git commit -m "fix" #!axme pr=37'), null);
29+
});
30+
31+
it("returns null for incomplete gate (missing pr)", () => {
32+
assert.equal(parseAxmeGate('git commit -m "fix" #!axme repo=AxmeAI/axme-code'), null);
33+
});
34+
35+
it("works with git push", () => {
36+
const result = parseAxmeGate('git push -u origin feat/xxx #!axme pr=42 repo=AxmeAI/axme-cli');
37+
assert.deepEqual(result, { pr: "42", repo: "AxmeAI/axme-cli" });
38+
});
39+
40+
it("works with git -C path", () => {
41+
const result = parseAxmeGate('git -C /path/to/repo commit -m "msg" #!axme pr=10 repo=AxmeAI/test');
42+
assert.deepEqual(result, { pr: "10", repo: "AxmeAI/test" });
43+
});
44+
});
45+
46+
describe("checkGit with axme gate", () => {
47+
it("blocks git commit without #!axme gate", () => {
48+
const v = checkGit(defaultRules, 'git commit -m "test"');
49+
assert.equal(v.allowed, false);
50+
assert.ok(v.reason!.includes("#!axme"));
51+
assert.ok(v.reason!.includes("BLOCKED"));
52+
});
53+
54+
it("blocks git push without #!axme gate", () => {
55+
const v = checkGit(defaultRules, "git push origin feat/xxx");
56+
assert.equal(v.allowed, false);
57+
assert.ok(v.reason!.includes("#!axme"));
58+
});
59+
60+
it("allows git commit with pr=none", () => {
61+
const v = checkGit(defaultRules, 'git commit -m "init" #!axme pr=none repo=AxmeAI/axme-code');
62+
assert.equal(v.allowed, true);
63+
});
64+
65+
it("allows git push with pr=none", () => {
66+
const v = checkGit(defaultRules, 'git push -u origin feat/xxx #!axme pr=none repo=AxmeAI/axme-code');
67+
assert.equal(v.allowed, true);
68+
});
69+
70+
it("blocks invalid PR number", () => {
71+
const v = checkGit(defaultRules, 'git commit -m "x" #!axme pr=abc repo=AxmeAI/axme-code');
72+
assert.equal(v.allowed, false);
73+
assert.ok(v.reason!.includes("Invalid PR number"));
74+
});
75+
76+
it("does not require gate on git add", () => {
77+
const v = checkGit(defaultRules, "git add src/file.ts");
78+
assert.equal(v.allowed, true);
79+
});
80+
81+
it("does not require gate on git status", () => {
82+
const v = checkGit(defaultRules, "git status");
83+
assert.equal(v.allowed, true);
84+
});
85+
86+
it("does not require gate on git branch", () => {
87+
const v = checkGit(defaultRules, "git branch --show-current");
88+
assert.equal(v.allowed, true);
89+
});
90+
91+
it("does not require gate on git diff", () => {
92+
const v = checkGit(defaultRules, "git diff --stat");
93+
assert.equal(v.allowed, true);
94+
});
95+
96+
it("does not require gate on git log", () => {
97+
const v = checkGit(defaultRules, "git log --oneline -5");
98+
assert.equal(v.allowed, true);
99+
});
100+
101+
it("skips gate check when skipMergedCheck is true", () => {
102+
const v = checkGit(defaultRules, 'git commit -m "x"', undefined, true);
103+
assert.equal(v.allowed, true);
104+
});
105+
106+
it("still blocks force push even with valid gate", () => {
107+
const v = checkGit(defaultRules, 'git push --force origin main #!axme pr=none repo=AxmeAI/axme-code');
108+
assert.equal(v.allowed, false);
109+
assert.ok(v.reason!.includes("Force push"));
110+
});
111+
112+
it("still blocks direct push to main even with valid gate", () => {
113+
const v = checkGit(defaultRules, 'git push origin main #!axme pr=none repo=AxmeAI/axme-code');
114+
assert.equal(v.allowed, false);
115+
assert.ok(v.reason!.includes("Direct push to main"));
116+
});
117+
118+
it("works with git -C prefix", () => {
119+
const v = checkGit(defaultRules, 'git -C /home/user/repo commit -m "fix" #!axme pr=none repo=AxmeAI/test');
120+
assert.equal(v.allowed, true);
121+
});
122+
});

0 commit comments

Comments
 (0)