xtrm-tools 2.4.3 → 2.4.6

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.
@@ -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
- }
@@ -1,138 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { execSync } from 'node:child_process';
4
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
- import path from 'node:path';
6
-
7
- export const SESSION_STATE_FILE = '.xtrm-session-state.json';
8
-
9
- export const SESSION_PHASES = [
10
- 'claimed',
11
- 'phase1-done',
12
- 'waiting-merge',
13
- 'conflicting',
14
- 'pending-cleanup',
15
- 'merged',
16
- 'cleanup-done',
17
- ];
18
-
19
- const ALLOWED_TRANSITIONS = {
20
- claimed: ['phase1-done', 'waiting-merge', 'conflicting', 'pending-cleanup', 'cleanup-done'],
21
- 'phase1-done': ['waiting-merge', 'conflicting', 'pending-cleanup', 'cleanup-done'],
22
- 'waiting-merge': ['conflicting', 'pending-cleanup', 'merged', 'cleanup-done'],
23
- conflicting: ['waiting-merge', 'pending-cleanup', 'merged', 'cleanup-done'],
24
- 'pending-cleanup': ['waiting-merge', 'conflicting', 'merged', 'cleanup-done'],
25
- merged: ['cleanup-done'],
26
- 'cleanup-done': [],
27
- };
28
-
29
- function nowIso() {
30
- return new Date().toISOString();
31
- }
32
-
33
- function isValidPhase(phase) {
34
- return typeof phase === 'string' && SESSION_PHASES.includes(phase);
35
- }
36
-
37
- function normalizeState(state) {
38
- if (!state || typeof state !== 'object') throw new Error('Invalid session state payload');
39
- if (!state.issueId || !state.branch || !state.worktreePath) {
40
- throw new Error('Session state requires issueId, branch, and worktreePath');
41
- }
42
- if (!isValidPhase(state.phase)) throw new Error(`Invalid phase: ${String(state.phase)}`);
43
-
44
- return {
45
- issueId: String(state.issueId),
46
- branch: String(state.branch),
47
- worktreePath: String(state.worktreePath),
48
- prNumber: state.prNumber ?? null,
49
- prUrl: state.prUrl ?? null,
50
- phase: state.phase,
51
- conflictFiles: Array.isArray(state.conflictFiles) ? state.conflictFiles.map(String) : [],
52
- startedAt: state.startedAt || nowIso(),
53
- lastChecked: nowIso(),
54
- };
55
- }
56
-
57
- function canTransition(from, to) {
58
- if (!isValidPhase(from) || !isValidPhase(to)) return false;
59
- if (from === to) return true;
60
- return (ALLOWED_TRANSITIONS[from] || []).includes(to);
61
- }
62
-
63
- function findAncestorStateFile(startCwd) {
64
- let current = path.resolve(startCwd || process.cwd());
65
- for (;;) {
66
- const candidate = path.join(current, SESSION_STATE_FILE);
67
- if (existsSync(candidate)) return candidate;
68
- const parent = path.dirname(current);
69
- if (parent === current) return null;
70
- current = parent;
71
- }
72
- }
73
-
74
- function findRepoRoot(cwd) {
75
- try {
76
- return execSync('git rev-parse --show-toplevel', {
77
- encoding: 'utf8',
78
- cwd,
79
- stdio: ['pipe', 'pipe', 'pipe'],
80
- timeout: 5000,
81
- }).trim();
82
- } catch {
83
- return null;
84
- }
85
- }
86
-
87
- export function findSessionStateFile(startCwd) {
88
- return findAncestorStateFile(startCwd);
89
- }
90
-
91
- export function readSessionState(startCwd) {
92
- const filePath = findSessionStateFile(startCwd);
93
- if (!filePath) return null;
94
-
95
- try {
96
- const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
97
- const state = normalizeState(parsed);
98
- return { ...state, _filePath: filePath };
99
- } catch {
100
- return null;
101
- }
102
- }
103
-
104
- export function resolveSessionStatePath(cwd) {
105
- const existing = findSessionStateFile(cwd);
106
- if (existing) return existing;
107
-
108
- const repoRoot = findRepoRoot(cwd);
109
- if (repoRoot) return path.join(repoRoot, SESSION_STATE_FILE);
110
- return path.join(cwd, SESSION_STATE_FILE);
111
- }
112
-
113
- export function writeSessionState(state, opts = {}) {
114
- const cwd = opts.cwd || process.cwd();
115
- const filePath = opts.filePath || resolveSessionStatePath(cwd);
116
- const normalized = normalizeState(state);
117
- writeFileSync(filePath, JSON.stringify(normalized, null, 2) + '\n', 'utf8');
118
- return filePath;
119
- }
120
-
121
- export function updateSessionPhase(startCwd, nextPhase, patch = {}) {
122
- if (!isValidPhase(nextPhase)) throw new Error(`Invalid phase: ${String(nextPhase)}`);
123
- const existing = readSessionState(startCwd);
124
- if (!existing) throw new Error('Session state file not found');
125
- if (!canTransition(existing.phase, nextPhase)) {
126
- throw new Error(`Invalid phase transition: ${existing.phase} -> ${nextPhase}`);
127
- }
128
-
129
- const nextState = {
130
- ...existing,
131
- ...patch,
132
- phase: nextPhase,
133
- };
134
-
135
- delete nextState._filePath;
136
- const filePath = writeSessionState(nextState, { filePath: existing._filePath, cwd: startCwd });
137
- return { ...nextState, _filePath: filePath };
138
- }