xtrm-tools 2.4.2 → 2.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/dist/index.cjs +958 -916
- package/cli/dist/index.cjs.map +1 -1
- package/cli/package.json +1 -1
- package/config/pi/extensions/beads.ts +1 -1
- package/config/pi/extensions/core/guard-rules.ts +34 -2
- package/config/pi/extensions/custom-footer.ts +5 -5
- package/config/pi/extensions/plan-mode/index.ts +91 -14
- package/config/pi/extensions/plan-mode/package.json +12 -0
- package/config/pi/extensions/plan-mode/utils.ts +158 -2
- package/config/pi/extensions/session-flow.ts +18 -4
- package/hooks/beads-claim-sync.mjs +64 -118
- package/hooks/beads-commit-gate.mjs +19 -4
- package/hooks/beads-compact-restore.mjs +2 -24
- package/hooks/beads-compact-save.mjs +1 -27
- package/hooks/beads-edit-gate.mjs +9 -2
- package/hooks/beads-gate-core.mjs +13 -9
- package/hooks/beads-gate-messages.mjs +5 -23
- package/hooks/beads-gate-utils.mjs +65 -1
- package/hooks/beads-memory-gate.mjs +23 -16
- package/hooks/beads-stop-gate.mjs +1 -52
- package/hooks/branch-state.mjs +2 -1
- package/hooks/guard-rules.mjs +33 -1
- package/hooks/hooks.json +22 -42
- package/hooks/main-guard.mjs +36 -35
- package/package.json +1 -1
- package/config/pi/extensions/main-guard-post-push.ts +0 -44
- package/config/pi/extensions/main-guard.ts +0 -122
- package/hooks/session-state.mjs +0 -138
|
@@ -11,7 +11,7 @@ import { execSync } from 'node:child_process';
|
|
|
11
11
|
import { existsSync, unlinkSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { readHookInput } from './beads-gate-core.mjs';
|
|
14
|
-
import { resolveCwd, isBeadsProject, getSessionClaim } from './beads-gate-utils.mjs';
|
|
14
|
+
import { resolveCwd, resolveSessionId, isBeadsProject, getSessionClaim, clearSessionClaim } from './beads-gate-utils.mjs';
|
|
15
15
|
import { memoryPromptMessage } from './beads-gate-messages.mjs';
|
|
16
16
|
|
|
17
17
|
const input = readHookInput();
|
|
@@ -20,35 +20,42 @@ if (!input) process.exit(0);
|
|
|
20
20
|
const cwd = resolveCwd(input);
|
|
21
21
|
if (!cwd || !isBeadsProject(cwd)) process.exit(0);
|
|
22
22
|
|
|
23
|
+
const sessionId = resolveSessionId(input);
|
|
24
|
+
|
|
23
25
|
// Agent signals evaluation complete by touching this marker, then stops again
|
|
24
26
|
const marker = join(cwd, '.beads', '.memory-gate-done');
|
|
25
27
|
if (existsSync(marker)) {
|
|
26
28
|
try { unlinkSync(marker); } catch { /* ignore */ }
|
|
29
|
+
// Clear the claim and closed-this-session marker
|
|
30
|
+
clearSessionClaim(sessionId, cwd);
|
|
31
|
+
try {
|
|
32
|
+
execSync(`bd kv clear "closed-this-session:${sessionId}"`, {
|
|
33
|
+
cwd,
|
|
34
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
35
|
+
timeout: 5000,
|
|
36
|
+
});
|
|
37
|
+
} catch { /* ignore */ }
|
|
27
38
|
process.exit(0);
|
|
28
39
|
}
|
|
29
40
|
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
if (!sessionId) process.exit(0);
|
|
33
|
-
|
|
34
|
-
const claimId = getSessionClaim(sessionId, cwd);
|
|
35
|
-
if (!claimId) process.exit(0); // no claim this session → no work to persist
|
|
36
|
-
|
|
37
|
-
// Check if the claimed issue was closed this session
|
|
38
|
-
let claimClosed = false;
|
|
41
|
+
// Check if an issue was closed this session (set by beads-claim-sync on bd close)
|
|
42
|
+
let closedIssueId = null;
|
|
39
43
|
try {
|
|
40
|
-
|
|
44
|
+
closedIssueId = execSync(`bd kv get "closed-this-session:${sessionId}"`, {
|
|
41
45
|
encoding: 'utf8',
|
|
42
46
|
cwd,
|
|
43
47
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
44
|
-
timeout:
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
timeout: 5000,
|
|
49
|
+
}).trim();
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (err.status === 1) {
|
|
52
|
+
// No closed-this-session marker → nothing to prompt about
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
48
55
|
process.exit(0); // fail open
|
|
49
56
|
}
|
|
50
57
|
|
|
51
|
-
if (!
|
|
58
|
+
if (!closedIssueId) process.exit(0);
|
|
52
59
|
|
|
53
60
|
process.stderr.write(memoryPromptMessage());
|
|
54
61
|
process.exit(2);
|
|
@@ -11,52 +11,11 @@ import {
|
|
|
11
11
|
decideStopGate,
|
|
12
12
|
} from './beads-gate-core.mjs';
|
|
13
13
|
import { withSafeBdContext } from './beads-gate-utils.mjs';
|
|
14
|
-
import {
|
|
15
|
-
stopBlockMessage,
|
|
16
|
-
stopBlockWaitingMergeMessage,
|
|
17
|
-
stopBlockConflictingMessage,
|
|
18
|
-
stopWarnActiveWorktreeMessage,
|
|
19
|
-
} from './beads-gate-messages.mjs';
|
|
20
|
-
import { readSessionState } from './session-state.mjs';
|
|
14
|
+
import { stopBlockMessage } from './beads-gate-messages.mjs';
|
|
21
15
|
|
|
22
16
|
const input = readHookInput();
|
|
23
17
|
if (!input) process.exit(0);
|
|
24
18
|
|
|
25
|
-
function evaluateSessionState(cwd) {
|
|
26
|
-
const state = readSessionState(cwd);
|
|
27
|
-
if (!state) return { allow: true };
|
|
28
|
-
|
|
29
|
-
if (state.phase === 'cleanup-done' || state.phase === 'merged') {
|
|
30
|
-
return { allow: true, state };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
if (state.phase === 'waiting-merge' || state.phase === 'pending-cleanup') {
|
|
34
|
-
return {
|
|
35
|
-
allow: false,
|
|
36
|
-
state,
|
|
37
|
-
message: stopBlockWaitingMergeMessage(state),
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (state.phase === 'conflicting') {
|
|
42
|
-
return {
|
|
43
|
-
allow: false,
|
|
44
|
-
state,
|
|
45
|
-
message: stopBlockConflictingMessage(state),
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (state.phase === 'claimed' || state.phase === 'phase1-done') {
|
|
50
|
-
return {
|
|
51
|
-
allow: true,
|
|
52
|
-
state,
|
|
53
|
-
warning: stopWarnActiveWorktreeMessage(state),
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return { allow: true, state };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
19
|
withSafeBdContext(() => {
|
|
61
20
|
const ctx = resolveSessionContext(input);
|
|
62
21
|
if (!ctx || !ctx.isBeadsProject) process.exit(0);
|
|
@@ -69,15 +28,5 @@ withSafeBdContext(() => {
|
|
|
69
28
|
process.exit(2);
|
|
70
29
|
}
|
|
71
30
|
|
|
72
|
-
const sessionDecision = evaluateSessionState(ctx.cwd);
|
|
73
|
-
if (!sessionDecision.allow) {
|
|
74
|
-
process.stderr.write(sessionDecision.message);
|
|
75
|
-
process.exit(2);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (sessionDecision.warning) {
|
|
79
|
-
process.stderr.write(sessionDecision.warning);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
31
|
process.exit(0);
|
|
83
32
|
});
|
package/hooks/branch-state.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { execSync } from 'node:child_process';
|
|
8
8
|
import { readFileSync, existsSync } from 'node:fs';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
+
import { resolveSessionId } from './beads-gate-utils.mjs';
|
|
10
11
|
|
|
11
12
|
function readInput() {
|
|
12
13
|
try { return JSON.parse(readFileSync(0, 'utf-8')); } catch { return null; }
|
|
@@ -35,7 +36,7 @@ const input = readInput();
|
|
|
35
36
|
if (!input) process.exit(0);
|
|
36
37
|
|
|
37
38
|
const cwd = input.cwd || process.cwd();
|
|
38
|
-
const sessionId = input
|
|
39
|
+
const sessionId = resolveSessionId(input);
|
|
39
40
|
const branch = getBranch(cwd);
|
|
40
41
|
const isBeads = existsSync(join(cwd, '.beads'));
|
|
41
42
|
const claim = isBeads && sessionId ? getSessionClaim(sessionId, cwd) : null;
|
package/hooks/guard-rules.mjs
CHANGED
|
@@ -46,6 +46,7 @@ export const DANGEROUS_BASH_PATTERNS = [
|
|
|
46
46
|
];
|
|
47
47
|
|
|
48
48
|
export const SAFE_BASH_PREFIXES = [
|
|
49
|
+
// Git read-only
|
|
49
50
|
'git status',
|
|
50
51
|
'git log',
|
|
51
52
|
'git diff',
|
|
@@ -60,10 +61,41 @@ export const SAFE_BASH_PREFIXES = [
|
|
|
60
61
|
'git worktree',
|
|
61
62
|
'git checkout -b',
|
|
62
63
|
'git switch -c',
|
|
64
|
+
// Tools
|
|
63
65
|
'gh',
|
|
64
66
|
'bd',
|
|
65
|
-
'touch .beads/',
|
|
66
67
|
'npx gitnexus',
|
|
68
|
+
'xtrm finish',
|
|
69
|
+
// Read-only filesystem
|
|
70
|
+
'cat',
|
|
71
|
+
'ls',
|
|
72
|
+
'head',
|
|
73
|
+
'tail',
|
|
74
|
+
'pwd',
|
|
75
|
+
'which',
|
|
76
|
+
'type',
|
|
77
|
+
'env',
|
|
78
|
+
'printenv',
|
|
79
|
+
'find',
|
|
80
|
+
'grep',
|
|
81
|
+
'rg',
|
|
82
|
+
'fd',
|
|
83
|
+
'wc',
|
|
84
|
+
'sort',
|
|
85
|
+
'uniq',
|
|
86
|
+
'cut',
|
|
87
|
+
'awk',
|
|
88
|
+
'jq',
|
|
89
|
+
'yq',
|
|
90
|
+
'bat',
|
|
91
|
+
'less',
|
|
92
|
+
'more',
|
|
93
|
+
'file',
|
|
94
|
+
'stat',
|
|
95
|
+
'du',
|
|
96
|
+
'tree',
|
|
97
|
+
// Allowed writes (specific paths)
|
|
98
|
+
'touch .beads/',
|
|
67
99
|
];
|
|
68
100
|
|
|
69
101
|
export const NATIVE_TEAM_TOOLS = [
|
package/hooks/hooks.json
CHANGED
|
@@ -1,48 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"hooks": {
|
|
3
|
-
"PreToolUse": [
|
|
4
|
-
{
|
|
5
|
-
"matcher": "Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
|
|
6
|
-
"hooks": [
|
|
7
|
-
{
|
|
8
|
-
"type": "command",
|
|
9
|
-
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/main-guard.mjs",
|
|
10
|
-
"timeout": 5000
|
|
11
|
-
},
|
|
12
|
-
{
|
|
13
|
-
"type": "command",
|
|
14
|
-
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/beads-edit-gate.mjs",
|
|
15
|
-
"timeout": 5000
|
|
16
|
-
}
|
|
17
|
-
]
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
"matcher": "Bash",
|
|
21
|
-
"hooks": [
|
|
22
|
-
{
|
|
23
|
-
"type": "command",
|
|
24
|
-
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/main-guard.mjs",
|
|
25
|
-
"timeout": 5000
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
"type": "command",
|
|
29
|
-
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/beads-commit-gate.mjs",
|
|
30
|
-
"timeout": 5000
|
|
31
|
-
}
|
|
32
|
-
]
|
|
33
|
-
}
|
|
34
|
-
],
|
|
35
3
|
"PostToolUse": [
|
|
36
|
-
{
|
|
37
|
-
"matcher": "Bash",
|
|
38
|
-
"hooks": [
|
|
39
|
-
{
|
|
40
|
-
"type": "command",
|
|
41
|
-
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/main-guard-post-push.mjs",
|
|
42
|
-
"timeout": 5000
|
|
43
|
-
}
|
|
44
|
-
]
|
|
45
|
-
},
|
|
46
4
|
{
|
|
47
5
|
"matcher": "Bash|execute_shell_command|bash",
|
|
48
6
|
"hooks": [
|
|
@@ -110,6 +68,28 @@
|
|
|
110
68
|
]
|
|
111
69
|
}
|
|
112
70
|
],
|
|
71
|
+
"PreToolUse": [
|
|
72
|
+
{
|
|
73
|
+
"matcher": "Edit|Write|MultiEdit|NotebookEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol",
|
|
74
|
+
"hooks": [
|
|
75
|
+
{
|
|
76
|
+
"type": "command",
|
|
77
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/beads-edit-gate.mjs",
|
|
78
|
+
"timeout": 5000
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"matcher": "Bash",
|
|
84
|
+
"hooks": [
|
|
85
|
+
{
|
|
86
|
+
"type": "command",
|
|
87
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/beads-commit-gate.mjs",
|
|
88
|
+
"timeout": 5000
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
],
|
|
113
93
|
"PreCompact": [
|
|
114
94
|
{
|
|
115
95
|
"hooks": [
|
package/hooks/main-guard.mjs
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// Installed by: xtrm install
|
|
6
6
|
|
|
7
7
|
import { execSync } from 'node:child_process';
|
|
8
|
-
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
9
10
|
import { WRITE_TOOLS, SAFE_BASH_PREFIXES } from './guard-rules.mjs';
|
|
10
11
|
|
|
11
12
|
let branch = '';
|
|
@@ -16,12 +17,10 @@ try {
|
|
|
16
17
|
}).trim();
|
|
17
18
|
} catch {}
|
|
18
19
|
|
|
19
|
-
// Determine protected branches — env var override for tests and custom setups
|
|
20
20
|
const protectedBranches = process.env.MAIN_GUARD_PROTECTED_BRANCHES
|
|
21
21
|
? process.env.MAIN_GUARD_PROTECTED_BRANCHES.split(',').map(b => b.trim()).filter(Boolean)
|
|
22
22
|
: ['main', 'master'];
|
|
23
23
|
|
|
24
|
-
// Not in a git repo or not on a protected branch — allow
|
|
25
24
|
if (!branch || !protectedBranches.includes(branch)) {
|
|
26
25
|
process.exit(0);
|
|
27
26
|
}
|
|
@@ -34,70 +33,70 @@ try {
|
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
const tool = input.tool_name ?? '';
|
|
37
|
-
const
|
|
36
|
+
const cwd = input.cwd || process.cwd();
|
|
38
37
|
|
|
39
38
|
function deny(reason) {
|
|
40
|
-
process.stdout.write(JSON.stringify({
|
|
41
|
-
decision: 'block',
|
|
42
|
-
reason,
|
|
43
|
-
}));
|
|
39
|
+
process.stdout.write(JSON.stringify({ decision: 'block', reason }));
|
|
44
40
|
process.stdout.write('\n');
|
|
45
41
|
process.exit(0);
|
|
46
42
|
}
|
|
47
43
|
|
|
44
|
+
function getSessionState(cwd) {
|
|
45
|
+
const statePath = join(cwd, '.xtrm-session-state.json');
|
|
46
|
+
if (!existsSync(statePath)) return null;
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(readFileSync(statePath, 'utf8'));
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeGitCCommand(cmd) {
|
|
55
|
+
const match = cmd.match(/^git\s+-C\s+(?:"[^"]+"|'[^']+'|\S+)\s+(.+)$/);
|
|
56
|
+
if (match?.[1]) return `git ${match[1]}`;
|
|
57
|
+
return cmd;
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
if (WRITE_TOOLS.includes(tool)) {
|
|
49
61
|
deny(`⛔ On '${branch}' — start on a feature branch and claim an issue.\n`
|
|
50
|
-
+ ' git checkout -b feature/<name
|
|
62
|
+
+ ' git checkout -b feature/<name> (or: git switch -c feature/<name>)\n'
|
|
51
63
|
+ ' bd update <id> --claim\n');
|
|
52
64
|
}
|
|
53
65
|
|
|
54
|
-
const WORKFLOW =
|
|
55
|
-
' 1. git checkout -b feature/<name>\n'
|
|
56
|
-
+ ' 2. bd create + bd update in_progress\n'
|
|
57
|
-
+ ' 3. bd close <id> && git add && git commit\n'
|
|
58
|
-
+ ' 4. git push -u origin feature/<name>\n'
|
|
59
|
-
+ ' 5. gh pr create --fill && gh pr merge --squash\n';
|
|
60
|
-
|
|
61
66
|
if (tool === 'Bash') {
|
|
62
67
|
const cmd = (input.tool_input?.command ?? '').trim().replace(/\s+/g, ' ');
|
|
68
|
+
const normalizedCmd = normalizeGitCCommand(cmd);
|
|
69
|
+
const state = getSessionState(cwd);
|
|
63
70
|
|
|
64
|
-
// Emergency override — escape hatch for power users
|
|
65
71
|
if (process.env.MAIN_GUARD_ALLOW_BASH === '1') {
|
|
66
72
|
process.exit(0);
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
// Enforce squash-only PR merges for linear history
|
|
70
|
-
// Must check BEFORE the gh allowlist pattern
|
|
71
75
|
if (/^gh\s+pr\s+merge\b/.test(cmd)) {
|
|
72
76
|
if (!/--squash\b/.test(cmd)) {
|
|
73
77
|
deny('⛔ Squash only: gh pr merge --squash\n'
|
|
74
78
|
+ ' (override: MAIN_GUARD_ALLOW_BASH=1 gh pr merge --merge)\n');
|
|
75
79
|
}
|
|
76
|
-
// --squash present — allow
|
|
77
80
|
process.exit(0);
|
|
78
81
|
}
|
|
79
82
|
|
|
80
|
-
// Safe allowlist — non-mutating commands + explicit branch-exit paths.
|
|
81
|
-
// Important: do not allow generic checkout/switch forms, which include
|
|
82
|
-
// mutating variants such as `git checkout -- <path>`.
|
|
83
83
|
const SAFE_BASH_PATTERNS = [
|
|
84
84
|
...SAFE_BASH_PREFIXES.map(prefix => new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`)),
|
|
85
|
-
// Allow post-merge sync to protected branch only (not arbitrary origin refs)
|
|
86
85
|
...protectedBranches.map(b => new RegExp(`^git\\s+reset\\s+--hard\\s+origin/${b}\\b`)),
|
|
87
86
|
];
|
|
88
87
|
|
|
89
|
-
if (SAFE_BASH_PATTERNS.some(p => p.test(cmd))) {
|
|
88
|
+
if (SAFE_BASH_PATTERNS.some(p => p.test(cmd) || p.test(normalizedCmd))) {
|
|
90
89
|
process.exit(0);
|
|
91
90
|
}
|
|
92
91
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
+ '
|
|
92
|
+
if (/\bgit\s+commit\b/.test(normalizedCmd)) {
|
|
93
|
+
deny(`⛔ No commits on '${branch}' — use a feature branch/worktree.\n`
|
|
94
|
+
+ ' git checkout -b feature/<name>\n'
|
|
95
|
+
+ ' bd update <id> --claim\n');
|
|
97
96
|
}
|
|
98
97
|
|
|
99
|
-
if (
|
|
100
|
-
const tokens =
|
|
98
|
+
if (/\bgit\s+push\b/.test(normalizedCmd)) {
|
|
99
|
+
const tokens = normalizedCmd.split(' ');
|
|
101
100
|
const lastToken = tokens[tokens.length - 1];
|
|
102
101
|
const explicitProtected = protectedBranches.some(b => lastToken === b || lastToken.endsWith(`:${b}`));
|
|
103
102
|
const impliedProtected = tokens.length <= 3 && protectedBranches.includes(branch);
|
|
@@ -105,13 +104,15 @@ if (tool === 'Bash') {
|
|
|
105
104
|
deny(`⛔ No direct push to '${branch}' — push a feature branch and open a PR.\n`
|
|
106
105
|
+ ' git push -u origin <feature-branch> && gh pr create --fill\n');
|
|
107
106
|
}
|
|
108
|
-
// Pushing to a feature branch — allow
|
|
109
107
|
process.exit(0);
|
|
110
108
|
}
|
|
111
109
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
110
|
+
const handoff = state?.worktreePath
|
|
111
|
+
? ` Active worktree session recorded: ${state.worktreePath}\n (Current workaround) use feature branch flow until worktree bug is fixed.\n`
|
|
112
|
+
: ' Exit: git checkout -b feature/<name> (or: git switch -c feature/<name>)\n Then: bd update <id> --claim\n';
|
|
113
|
+
|
|
114
|
+
deny(`⛔ Bash restricted on '${branch}'. Allowed: read-only commands, gh, bd, git checkout -b, git switch -c.\n`
|
|
115
|
+
+ handoff
|
|
115
116
|
+ ' Override: MAIN_GUARD_ALLOW_BASH=1 <cmd>\n');
|
|
116
117
|
}
|
|
117
118
|
|
package/package.json
CHANGED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ToolResultEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import { isBashToolResult } from "@mariozechner/pi-coding-agent";
|
|
3
|
-
import { SubprocessRunner, Logger } from "./core/lib";
|
|
4
|
-
|
|
5
|
-
const logger = new Logger({ namespace: "main-guard-post-push" });
|
|
6
|
-
|
|
7
|
-
export default function (pi: ExtensionAPI) {
|
|
8
|
-
const getProtectedBranches = (): string[] => {
|
|
9
|
-
const env = process.env.MAIN_GUARD_PROTECTED_BRANCHES;
|
|
10
|
-
if (env) return env.split(",").map(b => b.trim()).filter(Boolean);
|
|
11
|
-
return ["main", "master"];
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
pi.on("tool_result", async (event, ctx) => {
|
|
15
|
-
const cwd = ctx.cwd || process.cwd();
|
|
16
|
-
if (!isBashToolResult(event) || event.isError) return undefined;
|
|
17
|
-
|
|
18
|
-
const cmd = event.input.command.trim();
|
|
19
|
-
if (!/\bgit\s+push\b/.test(cmd)) return undefined;
|
|
20
|
-
|
|
21
|
-
// Check if we pushed to a protected branch
|
|
22
|
-
const protectedBranches = getProtectedBranches();
|
|
23
|
-
const tokens = cmd.split(/\s+/);
|
|
24
|
-
const lastToken = tokens[tokens.length - 1];
|
|
25
|
-
if (protectedBranches.some(b => lastToken === b || lastToken.endsWith(`:${b}`))) {
|
|
26
|
-
return undefined;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Success! Suggest PR workflow
|
|
30
|
-
const reminder = "\n\n**Main-Guard**: Push successful. Next steps:\n" +
|
|
31
|
-
" 1. `gh pr create --fill` (if not already open)\n" +
|
|
32
|
-
" 2. `gh pr merge --squash` (once approved)\n" +
|
|
33
|
-
" 3. `git checkout main && git reset --hard origin/main` (sync local)";
|
|
34
|
-
|
|
35
|
-
const newContent = [...event.content];
|
|
36
|
-
newContent.push({ type: "text", text: reminder });
|
|
37
|
-
|
|
38
|
-
if (ctx.hasUI) {
|
|
39
|
-
ctx.ui.notify("Main-Guard: Suggesting PR workflow", "info");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return { content: newContent };
|
|
43
|
-
});
|
|
44
|
-
}
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ToolCallEvent } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
|
|
3
|
-
import { SubprocessRunner, EventAdapter, Logger } from "./core/lib";
|
|
4
|
-
import { SAFE_BASH_PREFIXES, DANGEROUS_BASH_PATTERNS } from "./core/guard-rules";
|
|
5
|
-
|
|
6
|
-
const logger = new Logger({ namespace: "main-guard" });
|
|
7
|
-
|
|
8
|
-
export default function (pi: ExtensionAPI) {
|
|
9
|
-
const getProtectedBranches = (): string[] => {
|
|
10
|
-
const env = process.env.MAIN_GUARD_PROTECTED_BRANCHES;
|
|
11
|
-
if (env) return env.split(",").map(b => b.trim()).filter(Boolean);
|
|
12
|
-
return ["main", "master"];
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
const getCurrentBranch = async (cwd: string): Promise<string | null> => {
|
|
16
|
-
const result = await SubprocessRunner.run("git", ["branch", "--show-current"], { cwd });
|
|
17
|
-
if (result.code === 0) return result.stdout;
|
|
18
|
-
return null;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const protectedPaths = [".env", ".git/", "node_modules/"];
|
|
22
|
-
|
|
23
|
-
pi.on("tool_call", async (event, ctx) => {
|
|
24
|
-
const cwd = ctx.cwd || process.cwd();
|
|
25
|
-
|
|
26
|
-
// 1. Safety Check: Protected Paths (Global)
|
|
27
|
-
if (EventAdapter.isMutatingFileTool(event)) {
|
|
28
|
-
const path = EventAdapter.extractPathFromToolInput(event, cwd);
|
|
29
|
-
if (path && protectedPaths.some((p) => path.includes(p))) {
|
|
30
|
-
const reason = `Path "${path}" is protected. Edits to sensitive system files are restricted.`;
|
|
31
|
-
if (ctx.hasUI) {
|
|
32
|
-
ctx.ui.notify(`Safety: Blocked edit to protected path`, "error");
|
|
33
|
-
}
|
|
34
|
-
return { block: true, reason };
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// 2. Safety Check: Dangerous Commands (Global)
|
|
39
|
-
if (isToolCallEventType("bash", event)) {
|
|
40
|
-
const cmd = event.input.command.trim();
|
|
41
|
-
const dangerousRegexes = DANGEROUS_BASH_PATTERNS.map((pattern) => new RegExp(pattern));
|
|
42
|
-
const dangerousMatch = dangerousRegexes.some((rx) => rx.test(cmd));
|
|
43
|
-
if (dangerousMatch && !cmd.includes("--help")) {
|
|
44
|
-
if (ctx.hasUI) {
|
|
45
|
-
const ok = await ctx.ui.confirm("Dangerous Command", `Allow execution of: ${cmd}?`);
|
|
46
|
-
if (!ok) return { block: true, reason: "Blocked by user confirmation" };
|
|
47
|
-
} else {
|
|
48
|
-
return { block: true, reason: "Dangerous command blocked in non-interactive mode" };
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// 3. Main-Guard: Branch Protection
|
|
54
|
-
const protectedBranches = getProtectedBranches();
|
|
55
|
-
const branch = await getCurrentBranch(cwd);
|
|
56
|
-
|
|
57
|
-
if (branch && protectedBranches.includes(branch)) {
|
|
58
|
-
// A. Mutating File Tools on Main
|
|
59
|
-
if (EventAdapter.isMutatingFileTool(event)) {
|
|
60
|
-
const reason = `On protected branch '${branch}' — start on a feature branch and claim an issue.\n git checkout -b feature/<name>\n bd update <id> --claim\n`;
|
|
61
|
-
if (ctx.hasUI) {
|
|
62
|
-
ctx.ui.notify(`Main-Guard: Blocked edit on ${branch}`, "error");
|
|
63
|
-
}
|
|
64
|
-
return { block: true, reason };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// B. Bash Commands on Main
|
|
68
|
-
if (isToolCallEventType("bash", event)) {
|
|
69
|
-
const cmd = event.input.command.trim();
|
|
70
|
-
|
|
71
|
-
// Emergency override
|
|
72
|
-
if (process.env.MAIN_GUARD_ALLOW_BASH === "1") return undefined;
|
|
73
|
-
|
|
74
|
-
// Enforce squash-only PR merges
|
|
75
|
-
if (/^gh\s+pr\s+merge\b/.test(cmd)) {
|
|
76
|
-
if (!/--squash\b/.test(cmd)) {
|
|
77
|
-
const reason = "Squash only: use `gh pr merge --squash` (or MAIN_GUARD_ALLOW_BASH=1)";
|
|
78
|
-
return { block: true, reason };
|
|
79
|
-
}
|
|
80
|
-
return undefined;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Safe allowlist
|
|
84
|
-
const safePrefixRegexes = SAFE_BASH_PREFIXES.map((prefix) =>
|
|
85
|
-
new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`),
|
|
86
|
-
);
|
|
87
|
-
const safeResetRegexes = protectedBranches.map((b) => new RegExp(`^git\\s+reset\\s+--hard\\s+origin/${b}\\b`));
|
|
88
|
-
const SAFE_BASH_PATTERNS = [...safePrefixRegexes, ...safeResetRegexes];
|
|
89
|
-
|
|
90
|
-
if (SAFE_BASH_PATTERNS.some(p => p.test(cmd))) {
|
|
91
|
-
return undefined;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Specific blocks
|
|
95
|
-
if (/\bgit\s+commit\b/.test(cmd)) {
|
|
96
|
-
return { block: true, reason: `No commits on '${branch}' — use a feature branch.\n git checkout -b feature/<name>\n bd update <id> --claim\n` };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (/\bgit\s+push\b/.test(cmd)) {
|
|
100
|
-
const tokens = cmd.split(/\s+/);
|
|
101
|
-
const lastToken = tokens[tokens.length - 1];
|
|
102
|
-
const explicitProtected = protectedBranches.some(b => lastToken === b || lastToken.endsWith(`:${b}`));
|
|
103
|
-
const impliedProtected = tokens.length <= 3 && protectedBranches.includes(branch);
|
|
104
|
-
|
|
105
|
-
if (explicitProtected || impliedProtected) {
|
|
106
|
-
return { block: true, reason: `No direct push to '${branch}' — push a feature branch and open a PR.` };
|
|
107
|
-
}
|
|
108
|
-
return undefined;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Default deny
|
|
112
|
-
const reason = `Bash restricted on '${branch}'. Allowed: git status/log/diff/pull/stash, gh, bd.\n Exit: git checkout -b feature/<name>\n Then: bd update <id> --claim\n Override: MAIN_GUARD_ALLOW_BASH=1 <cmd>\n`;
|
|
113
|
-
if (ctx.hasUI) {
|
|
114
|
-
ctx.ui.notify("Main-Guard: Command blocked", "error");
|
|
115
|
-
}
|
|
116
|
-
return { block: true, reason };
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return undefined;
|
|
121
|
-
});
|
|
122
|
-
}
|