@senomas/pi-git-hat 0.2.2 → 0.2.5
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/README.md +1 -0
- package/git-hat.ts +474 -13
- package/lib/tool-enforcement.ts +91 -0
- package/package.json +1 -1
- package/roles/roles.json +95 -5
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ No other dependencies needed. On first startup, role prompts and a default `role
|
|
|
16
16
|
|
|
17
17
|
- **Role-based branch switching** — `/hat` TUI to pick branches grouped by role
|
|
18
18
|
- **Permission enforcement** — each role can only write to certain paths
|
|
19
|
+
- **Tool enforcement** — per-role allow/block rules for commands ([docs](docs/tool-enforcement.md))
|
|
19
20
|
- **System prompt injection** — role instructions injected automatically per session
|
|
20
21
|
- **Ancestry checking** — `/hatt` validates branch parent chain before work
|
|
21
22
|
- **Auto-seeding** — role prompts copied from bundled `roles/` to project `.pi/` on first use
|
package/git-hat.ts
CHANGED
|
@@ -45,13 +45,30 @@ import {
|
|
|
45
45
|
Text,
|
|
46
46
|
} from "@earendil-works/pi-tui";
|
|
47
47
|
import { SearchableSelectList } from "./lib/searchable-select-list.js";
|
|
48
|
+
import {
|
|
49
|
+
type RegexObj,
|
|
50
|
+
type ToolRule,
|
|
51
|
+
testRegex,
|
|
52
|
+
evaluateToolRules,
|
|
53
|
+
} from "./lib/tool-enforcement.js";
|
|
48
54
|
|
|
49
55
|
// -- Types ---------------------------------------------------------
|
|
50
56
|
|
|
57
|
+
interface WritablePathEntry {
|
|
58
|
+
/** Directory or file path (relative to project root). Empty string = project root. */
|
|
59
|
+
path: string;
|
|
60
|
+
/** Optional file extension filter, e.g. "md" — if set, only files with this extension are writable. */
|
|
61
|
+
extension?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
interface RoleDef {
|
|
52
65
|
pattern: string;
|
|
53
66
|
description?: string;
|
|
54
67
|
ancestorRoles?: string[];
|
|
68
|
+
tool?: ToolRule[];
|
|
69
|
+
/** Tool rules evaluated after default post-tool rules (snake_case key "post-tool" in JSON). */
|
|
70
|
+
postTool?: ToolRule[];
|
|
71
|
+
writablePaths?: WritablePathEntry[];
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
interface RolesConfig {
|
|
@@ -61,6 +78,13 @@ interface RolesConfig {
|
|
|
61
78
|
fileDir?: string;
|
|
62
79
|
caseInsensitive?: boolean;
|
|
63
80
|
|
|
81
|
+
default?: {
|
|
82
|
+
/** Tool rules evaluated before role-specific rules (snake_case key "pre-tool" in JSON). */
|
|
83
|
+
preTool?: ToolRule[];
|
|
84
|
+
/** Tool rules evaluated after role-specific rules (snake_case key "post-tool" in JSON). */
|
|
85
|
+
postTool?: ToolRule[];
|
|
86
|
+
};
|
|
87
|
+
|
|
64
88
|
postSwitchLog?: boolean;
|
|
65
89
|
}
|
|
66
90
|
|
|
@@ -69,6 +93,11 @@ interface MergedConfig {
|
|
|
69
93
|
fileDir: string;
|
|
70
94
|
caseInsensitive: boolean;
|
|
71
95
|
|
|
96
|
+
/** Default pre-tool rules loaded from roles.json default.pre-tool */
|
|
97
|
+
preTool?: ToolRule[];
|
|
98
|
+
/** Default post-tool rules loaded from roles.json default.post-tool */
|
|
99
|
+
postTool?: ToolRule[];
|
|
100
|
+
|
|
72
101
|
postSwitchLog: boolean;
|
|
73
102
|
configFile: string;
|
|
74
103
|
}
|
|
@@ -254,6 +283,10 @@ function loadConfig(): MergedConfig {
|
|
|
254
283
|
if (raw.caseInsensitive !== undefined) merged.caseInsensitive = raw.caseInsensitive;
|
|
255
284
|
|
|
256
285
|
if (raw.postSwitchLog !== undefined) merged.postSwitchLog = raw.postSwitchLog;
|
|
286
|
+
if (raw.default) {
|
|
287
|
+
if (raw.default["pre-tool"] !== undefined) merged.preTool = raw.default["pre-tool"];
|
|
288
|
+
if (raw.default["post-tool"] !== undefined) merged.postTool = raw.default["post-tool"];
|
|
289
|
+
}
|
|
257
290
|
} catch {
|
|
258
291
|
// ignore parse errors
|
|
259
292
|
}
|
|
@@ -341,11 +374,66 @@ function normalisePath(raw: string, cwd: string, cwdAbsolute: string): string {
|
|
|
341
374
|
return path;
|
|
342
375
|
}
|
|
343
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Strip a leading `cd <dir> && ` prefix from a bash command if <dir> resolves
|
|
379
|
+
* to a path inside the project. Supports relative paths (resolved against cwd).
|
|
380
|
+
*
|
|
381
|
+
* Examples:
|
|
382
|
+
* "cd docs && ls -la" → "ls -la"
|
|
383
|
+
* "cd ../outside && ls" → "cd ../outside && ls" (unchanged — outside project)
|
|
384
|
+
* "ls -la" → "ls -la" (unchanged — no cd prefix)
|
|
385
|
+
*/
|
|
386
|
+
function stripCdPrefix(cmd: string, cwd: string, cwdAbsolute: string): string {
|
|
387
|
+
const match = cmd.match(/^cd\s+(\S+)\s*&&\s*/);
|
|
388
|
+
if (!match) return cmd;
|
|
389
|
+
const dir = match[1];
|
|
390
|
+
const rest = cmd.slice(match[0].length);
|
|
391
|
+
// Resolve the directory: absolute path, relative path, or ~
|
|
392
|
+
let resolved: string;
|
|
393
|
+
if (dir.startsWith("/")) {
|
|
394
|
+
resolved = dir;
|
|
395
|
+
} else if (dir.startsWith("~")) {
|
|
396
|
+
return cmd; // home dir — can't guarantee it's inside project, keep as-is
|
|
397
|
+
} else {
|
|
398
|
+
resolved = resolve(cwd, dir);
|
|
399
|
+
}
|
|
400
|
+
// Check if resolved path is inside the project
|
|
401
|
+
if (resolved.startsWith(cwdAbsolute)) {
|
|
402
|
+
return rest;
|
|
403
|
+
}
|
|
404
|
+
return cmd;
|
|
405
|
+
}
|
|
406
|
+
|
|
344
407
|
/** Check if a path is inside one of the given directories. */
|
|
345
408
|
function isInside(path: string, dirs: string[]): boolean {
|
|
346
409
|
return dirs.some((d) => path === d || path.startsWith(d + "/"));
|
|
347
410
|
}
|
|
348
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Check if a path matches one of the given writable path entries.
|
|
414
|
+
*
|
|
415
|
+
* - If `entry.path` is empty string, only root-level files match.
|
|
416
|
+
* - If `entry.extension` is set, the path must end with that extension.
|
|
417
|
+
* - A trailing slash is appended automatically when matching directories.
|
|
418
|
+
*/
|
|
419
|
+
function isWritablePath(path: string, writablePaths: WritablePathEntry[]): boolean {
|
|
420
|
+
return writablePaths.some((entry) => {
|
|
421
|
+
const dir = entry.path;
|
|
422
|
+
const ext = entry.extension;
|
|
423
|
+
// Check directory match
|
|
424
|
+
const inDir =
|
|
425
|
+
dir === ""
|
|
426
|
+
? !path.includes("/") // root level only
|
|
427
|
+
: path === dir || path.startsWith(dir + "/");
|
|
428
|
+
if (!inDir) return false;
|
|
429
|
+
// Check extension filter (if set)
|
|
430
|
+
if (ext) {
|
|
431
|
+
return path.endsWith("." + ext);
|
|
432
|
+
}
|
|
433
|
+
return true; // no extension filter = allow any file in this dir
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
349
437
|
/**
|
|
350
438
|
* Load a role's .md file from the project's fileDir directory.
|
|
351
439
|
* Case-insensitive lookup if config.caseInsensitive is true.
|
|
@@ -712,9 +800,17 @@ async function handleTodo(
|
|
|
712
800
|
ctx.ui.notify("\uD83D\uDCED No pending todos found", "info");
|
|
713
801
|
return;
|
|
714
802
|
}
|
|
803
|
+
|
|
804
|
+
const remaining = result.totalPending - result.totalCovered;
|
|
805
|
+
|
|
806
|
+
if (remaining === 0) {
|
|
807
|
+
ctx.ui.notify("\u2714\uFE0F No more task in todo", "info");
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
715
811
|
ctx.ui.notify(result.summary, "info");
|
|
716
812
|
|
|
717
|
-
if (
|
|
813
|
+
if (remaining > 0) {
|
|
718
814
|
const detail = result.files
|
|
719
815
|
.map((f) => {
|
|
720
816
|
const items = f.pending
|
|
@@ -978,10 +1074,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
978
1074
|
? `${roleIcon(currentRole)} ${currentRole}${usingFile}`
|
|
979
1075
|
: "\u2753 No matching role (read-only mode)";
|
|
980
1076
|
|
|
1077
|
+
const defaultInfo = [];
|
|
1078
|
+
if (config.preTool) defaultInfo.push(`pre-tool: ${config.preTool.length} rule(s)`);
|
|
1079
|
+
if (config.postTool) defaultInfo.push(`post-tool: ${config.postTool.length} rule(s)`);
|
|
1080
|
+
const defaultLine = defaultInfo.length > 0 ? `default: ${defaultInfo.join(", ")}\n` : "";
|
|
1081
|
+
|
|
981
1082
|
ctx.ui.notify(
|
|
982
1083
|
`${roleDisplay}\n` +
|
|
983
1084
|
`branch: ${currentBranch ?? "not a git repo"}\n` +
|
|
984
1085
|
`${desc ? `desc: ${desc}\n` : ""}` +
|
|
1086
|
+
`${defaultLine}` +
|
|
985
1087
|
`roles: ${config.configFile}`,
|
|
986
1088
|
"info",
|
|
987
1089
|
);
|
|
@@ -1141,6 +1243,188 @@ export default function (pi: ExtensionAPI) {
|
|
|
1141
1243
|
},
|
|
1142
1244
|
});
|
|
1143
1245
|
|
|
1246
|
+
// -- /hatr command: TUI branch selector for rebase ------------------
|
|
1247
|
+
|
|
1248
|
+
pi.registerCommand("hatr", {
|
|
1249
|
+
description: "TUI branch selector — pick a role-matched branch to rebase onto",
|
|
1250
|
+
handler: async (_args, ctx) => {
|
|
1251
|
+
// Edge case: detached HEAD or non-git directory
|
|
1252
|
+
const branch = await detectBranch();
|
|
1253
|
+
if (!branch) {
|
|
1254
|
+
ctx.ui.notify("Not on a branch (detached HEAD or not a git repo). Aborting.", "warning");
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
const currentBranch = branch;
|
|
1258
|
+
|
|
1259
|
+
// List all local branches
|
|
1260
|
+
let allBranches: string[] = [];
|
|
1261
|
+
try {
|
|
1262
|
+
const result = await pi.exec("git", ["branch", "--format", "%(refname:short)"]);
|
|
1263
|
+
allBranches = result.stdout.trim().split("\n").filter(Boolean);
|
|
1264
|
+
} catch {
|
|
1265
|
+
ctx.ui.notify("Failed to list git branches.", "error");
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Filter to role-matched branches, excluding current
|
|
1270
|
+
const candidates: string[] = [];
|
|
1271
|
+
for (const b of allBranches) {
|
|
1272
|
+
if (b === currentBranch) continue;
|
|
1273
|
+
if (detectRole(b, config)) candidates.push(b);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// Edge case: no other role-matched branches
|
|
1277
|
+
if (candidates.length === 0) {
|
|
1278
|
+
ctx.ui.notify("No other role-matched branches to rebase onto.", "info");
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Get latest commit info for each candidate
|
|
1283
|
+
interface CandidateInfo {
|
|
1284
|
+
branch: string;
|
|
1285
|
+
timestamp: number;
|
|
1286
|
+
abbrev: string;
|
|
1287
|
+
subject: string;
|
|
1288
|
+
}
|
|
1289
|
+
const infos: CandidateInfo[] = [];
|
|
1290
|
+
for (const b of candidates) {
|
|
1291
|
+
try {
|
|
1292
|
+
const result = await pi.exec("git", [
|
|
1293
|
+
"log", "--format=%ct %h %s", "-1", b,
|
|
1294
|
+
]);
|
|
1295
|
+
const line = result.stdout.trim();
|
|
1296
|
+
if (!line) continue;
|
|
1297
|
+
const space1 = line.indexOf(" ");
|
|
1298
|
+
const space2 = line.indexOf(" ", space1 + 1);
|
|
1299
|
+
if (space1 === -1 || space2 === -1) continue;
|
|
1300
|
+
const timestamp = parseInt(line.slice(0, space1), 10);
|
|
1301
|
+
const abbrev = line.slice(space1 + 1, space2);
|
|
1302
|
+
const subject = line.slice(space2 + 1);
|
|
1303
|
+
infos.push({ branch: b, timestamp, abbrev, subject });
|
|
1304
|
+
} catch {
|
|
1305
|
+
// skip branches we can't read
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
if (infos.length === 0) {
|
|
1310
|
+
ctx.ui.notify("Could not determine latest commits for candidates.", "error");
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Sort by commit timestamp descending (most recent first)
|
|
1315
|
+
infos.sort((a, b) => b.timestamp - a.timestamp);
|
|
1316
|
+
|
|
1317
|
+
// Require TUI mode
|
|
1318
|
+
if (ctx.mode !== "tui") {
|
|
1319
|
+
ctx.ui.notify("/hatr requires TUI mode", "warning");
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Build select items with: "branchname <abbrev> subject"
|
|
1324
|
+
const selectItems: SelectItem[] = infos.map((c) => {
|
|
1325
|
+
const maxSubjectLen = 50;
|
|
1326
|
+
const subject =
|
|
1327
|
+
c.subject.length > maxSubjectLen
|
|
1328
|
+
? c.subject.slice(0, maxSubjectLen) + "…"
|
|
1329
|
+
: c.subject;
|
|
1330
|
+
return {
|
|
1331
|
+
value: c.branch,
|
|
1332
|
+
label: `${c.branch} \x1b[90m${c.abbrev} ${subject}\x1b[0m`,
|
|
1333
|
+
};
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
const result = await ctx.ui.custom<string | null>(
|
|
1337
|
+
(tui, theme, _kb, done) => {
|
|
1338
|
+
const container = new Container();
|
|
1339
|
+
|
|
1340
|
+
container.addChild(
|
|
1341
|
+
new DynamicBorder((s: string) => theme.fg("accent", s)),
|
|
1342
|
+
);
|
|
1343
|
+
|
|
1344
|
+
container.addChild(
|
|
1345
|
+
new Text(theme.fg("accent", theme.bold("Rebase Branch")), 1, 0),
|
|
1346
|
+
);
|
|
1347
|
+
|
|
1348
|
+
const selectList = new SearchableSelectList(selectItems, Math.min(selectItems.length, 20), {
|
|
1349
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
1350
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
1351
|
+
description: (t) => theme.fg("muted", t),
|
|
1352
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
1353
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
selectList.onSelect = (item) => {
|
|
1357
|
+
if (item.value.startsWith("__header_")) return;
|
|
1358
|
+
done(item.value);
|
|
1359
|
+
};
|
|
1360
|
+
selectList.onCancel = () => done(null);
|
|
1361
|
+
|
|
1362
|
+
container.addChild(selectList);
|
|
1363
|
+
|
|
1364
|
+
container.addChild(
|
|
1365
|
+
new Text(
|
|
1366
|
+
theme.fg("dim", "\u2191\u2193 navigate \u2022 enter select \u2022 esc cancel \u2022 type to search"),
|
|
1367
|
+
1,
|
|
1368
|
+
0,
|
|
1369
|
+
),
|
|
1370
|
+
);
|
|
1371
|
+
|
|
1372
|
+
container.addChild(
|
|
1373
|
+
new DynamicBorder((s: string) => theme.fg("accent", s)),
|
|
1374
|
+
);
|
|
1375
|
+
|
|
1376
|
+
return {
|
|
1377
|
+
render: (w) => container.render(w),
|
|
1378
|
+
invalidate: () => container.invalidate(),
|
|
1379
|
+
handleInput: (data) => {
|
|
1380
|
+
selectList.handleInput(data);
|
|
1381
|
+
tui.requestRender();
|
|
1382
|
+
},
|
|
1383
|
+
};
|
|
1384
|
+
},
|
|
1385
|
+
{ overlay: true },
|
|
1386
|
+
);
|
|
1387
|
+
|
|
1388
|
+
if (!result) {
|
|
1389
|
+
ctx.ui.notify("Rebase cancelled.", "info");
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
const selectedInfo = infos.find((c) => c.branch === result)!;
|
|
1394
|
+
|
|
1395
|
+
// Confirm before rebasing
|
|
1396
|
+
const confirmed = await ctx.ui.confirm(
|
|
1397
|
+
`Rebase ${currentBranch} onto ${result}? (${selectedInfo.abbrev} ${selectedInfo.subject})`,
|
|
1398
|
+
);
|
|
1399
|
+
if (!confirmed) {
|
|
1400
|
+
ctx.ui.notify("Rebase cancelled.", "info");
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Run the rebase
|
|
1405
|
+
try {
|
|
1406
|
+
const rebaseResult = await pi.exec("git", ["rebase", result]);
|
|
1407
|
+
if (rebaseResult.code === 0) {
|
|
1408
|
+
ctx.ui.notify(
|
|
1409
|
+
`Rebase onto ${result} succeeded. Updated commit tree:`,
|
|
1410
|
+
"info",
|
|
1411
|
+
);
|
|
1412
|
+
await showGitLog(ctx, 10);
|
|
1413
|
+
} else {
|
|
1414
|
+
ctx.ui.notify(
|
|
1415
|
+
`Rebase onto ${result} failed:\n${rebaseResult.stderr}\n\nResolve conflicts manually, then run \`git rebase --continue\`.`,
|
|
1416
|
+
"error",
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
} catch (e) {
|
|
1420
|
+
ctx.ui.notify(
|
|
1421
|
+
`Rebase onto ${result} failed: ${(e as Error).message}\n\nResolve conflicts manually, then run \`git rebase --continue\`.`,
|
|
1422
|
+
"error",
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
},
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1144
1428
|
// -- Session lifecycle ----------------------------------------
|
|
1145
1429
|
|
|
1146
1430
|
pi.on("session_start", async (event, ctx) => {
|
|
@@ -1207,10 +1491,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1207
1491
|
? `${roleIcon(currentRole)} ${currentRole}${usingFile}`
|
|
1208
1492
|
: "\u2753 No matching role (read-only mode)";
|
|
1209
1493
|
|
|
1494
|
+
const defaultInfo = [];
|
|
1495
|
+
if (config.preTool) defaultInfo.push(`pre-tool: ${config.preTool.length} rule(s)`);
|
|
1496
|
+
if (config.postTool) defaultInfo.push(`post-tool: ${config.postTool.length} rule(s)`);
|
|
1497
|
+
const defaultLine = defaultInfo.length > 0 ? `default: ${defaultInfo.join(", ")}\n` : "";
|
|
1498
|
+
|
|
1210
1499
|
ctx.ui.notify(
|
|
1211
1500
|
`${roleDisplay}\n` +
|
|
1212
1501
|
`branch: ${currentBranch ?? "not a git repo"}\n` +
|
|
1213
1502
|
`${desc ? `desc: ${desc}\n` : ""}` +
|
|
1503
|
+
`${defaultLine}` +
|
|
1214
1504
|
`roles: ${config.configFile}`,
|
|
1215
1505
|
"info",
|
|
1216
1506
|
);
|
|
@@ -1370,6 +1660,154 @@ If the user asks you to make changes, tell them to switch to an appropriate bran
|
|
|
1370
1660
|
}
|
|
1371
1661
|
}
|
|
1372
1662
|
|
|
1663
|
+
// Config-driven tool rules: evaluate bash commands
|
|
1664
|
+
if (isBash) {
|
|
1665
|
+
const lowerRole = currentRole.toLowerCase();
|
|
1666
|
+
const roleDef = config.roles[lowerRole] ?? config.roles[currentRole];
|
|
1667
|
+
const rawCmd = ((event.input as { command?: string }).command ?? "").trim();
|
|
1668
|
+
const cmd = stripCdPrefix(rawCmd, ctx.cwd, cwdAbsolute);
|
|
1669
|
+
|
|
1670
|
+
// -- Hardcoded safety guards (not overridable by roles.json) --
|
|
1671
|
+
|
|
1672
|
+
// Block pipe to any shell interpreter (arbitrary code execution vector)
|
|
1673
|
+
if (/\|\s*(sh|bash|zsh|fish|dash|ksh|tcsh|csh)\b/.test(cmd)) {
|
|
1674
|
+
return {
|
|
1675
|
+
block: true,
|
|
1676
|
+
reason:
|
|
1677
|
+
"Piping to a shell interpreter is blocked for security. " +
|
|
1678
|
+
"Use the `write` or `edit` tool to create or modify files.",
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// Check output redirect to files via > or >> (write operation via bash)
|
|
1683
|
+
const redirectMatch = cmd.match(/[\s>](>|>>)\s*([\w\/~.$].*)$/);
|
|
1684
|
+
if (redirectMatch) {
|
|
1685
|
+
const target = redirectMatch[2];
|
|
1686
|
+
// Allow redirects to /tmp/ with confirmation (scratch space)
|
|
1687
|
+
if (/^\/tmp\//.test(target)) {
|
|
1688
|
+
const confirmed = await ctx.ui.confirm(
|
|
1689
|
+
`Write to \`${target}\` via redirect? Confirm?`,
|
|
1690
|
+
);
|
|
1691
|
+
if (!confirmed) {
|
|
1692
|
+
return { block: true, reason: "Redirect to /tmp cancelled." };
|
|
1693
|
+
}
|
|
1694
|
+
} else {
|
|
1695
|
+
// All other redirect targets are blocked unconditionally
|
|
1696
|
+
return {
|
|
1697
|
+
block: true,
|
|
1698
|
+
reason:
|
|
1699
|
+
`Output redirect to \`${target}\` is blocked for security. ` +
|
|
1700
|
+
`Use the \`write\` or \`edit\` tool instead. ` +
|
|
1701
|
+
`Redirects to \`/tmp/...\` are allowed with confirmation.`,
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// 1. Default pre-tool rules (evaluated before role-specific rules)
|
|
1707
|
+
let matchedAny = false;
|
|
1708
|
+
if (config.preTool) {
|
|
1709
|
+
const result = evaluateToolRules(config.preTool, cmd);
|
|
1710
|
+
if (result.matched) {
|
|
1711
|
+
matchedAny = true;
|
|
1712
|
+
if (!result.allowed) {
|
|
1713
|
+
return {
|
|
1714
|
+
block: true,
|
|
1715
|
+
reason: result.reason ?? `Command \`${cmd}\` blocked by default pre-tool rule.`,
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
if (result.confirm) {
|
|
1719
|
+
const confirmed = await ctx.ui.confirm(
|
|
1720
|
+
result.reason ?? `Run bash command \`${cmd}\` (default pre-tool check)?`,
|
|
1721
|
+
);
|
|
1722
|
+
if (!confirmed) {
|
|
1723
|
+
return { block: true, reason: "Command cancelled by user." };
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// 2. Role-specific tool rules
|
|
1730
|
+
if (roleDef?.tool !== undefined) {
|
|
1731
|
+
const result = evaluateToolRules(roleDef.tool, cmd);
|
|
1732
|
+
if (result.matched) {
|
|
1733
|
+
matchedAny = true;
|
|
1734
|
+
if (!result.allowed) {
|
|
1735
|
+
return {
|
|
1736
|
+
block: true,
|
|
1737
|
+
reason: result.reason ?? `Command \`${cmd}\` blocked by tool rule for ${currentRole}.`,
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
if (result.confirm) {
|
|
1741
|
+
const confirmed = await ctx.ui.confirm(
|
|
1742
|
+
result.reason ?? `Run bash command \`${cmd}\` as ${currentRole}?`,
|
|
1743
|
+
);
|
|
1744
|
+
if (!confirmed) {
|
|
1745
|
+
return { block: true, reason: "Command cancelled by user." };
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// 3. Default post-tool rules (evaluated after role-specific rules pass)
|
|
1752
|
+
if (config.postTool) {
|
|
1753
|
+
for (const rule of config.postTool) {
|
|
1754
|
+
if (!testRegex(rule.regex, cmd)) continue;
|
|
1755
|
+
matchedAny = true;
|
|
1756
|
+
if (rule.type === "block") {
|
|
1757
|
+
return {
|
|
1758
|
+
block: true,
|
|
1759
|
+
reason: rule.reason ?? `Command \`${cmd}\` blocked by default post-tool rule.`,
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
if (rule.type === "confirm") {
|
|
1763
|
+
const confirmed = await ctx.ui.confirm(
|
|
1764
|
+
rule.reason ?? `Run bash command \`${cmd}\` (default post-tool check)?`,
|
|
1765
|
+
);
|
|
1766
|
+
if (!confirmed) {
|
|
1767
|
+
return { block: true, reason: "Command cancelled by user." };
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
// "allow" → no-op, continue past post-tool
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// 4. Role-specific post-tool rules (evaluated after default post-tool)
|
|
1775
|
+
if (roleDef?.postTool) {
|
|
1776
|
+
for (const rule of roleDef.postTool) {
|
|
1777
|
+
if (!testRegex(rule.regex, cmd)) continue;
|
|
1778
|
+
matchedAny = true;
|
|
1779
|
+
if (rule.type === "block") {
|
|
1780
|
+
return {
|
|
1781
|
+
block: true,
|
|
1782
|
+
reason: rule.reason ?? `Command \`${cmd}\` blocked by role post-tool rule for ${currentRole}.`,
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
if (rule.type === "confirm") {
|
|
1786
|
+
const confirmed = await ctx.ui.confirm(
|
|
1787
|
+
rule.reason ?? `Run bash command \`${cmd}\` (${currentRole} post-tool check)?`,
|
|
1788
|
+
);
|
|
1789
|
+
if (!confirmed) {
|
|
1790
|
+
return { block: true, reason: "Command cancelled by user." };
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
// "allow" → no-op, continue
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// 5. Safe default: if no rule matched in any stage, require confirmation
|
|
1798
|
+
if (!matchedAny) {
|
|
1799
|
+
const confirmed = await ctx.ui.confirm(
|
|
1800
|
+
`Command \`${cmd}\` not covered by any tool rule. Confirm execution?`,
|
|
1801
|
+
);
|
|
1802
|
+
if (!confirmed) {
|
|
1803
|
+
return { block: true, reason: "Command cancelled by user." };
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// Tool rules passed (or no tool rules defined): allow bash
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1373
1811
|
// Known roles: only intercept edit/write
|
|
1374
1812
|
if (!isWrite) return;
|
|
1375
1813
|
|
|
@@ -1377,21 +1815,44 @@ If the user asks you to make changes, tell them to switch to an appropriate bran
|
|
|
1377
1815
|
const path = normalisePath(rawPath, ctx.cwd, cwdAbsolute);
|
|
1378
1816
|
|
|
1379
1817
|
const lowerRole = currentRole.toLowerCase();
|
|
1818
|
+
const roleDef = config.roles[lowerRole] ?? config.roles[currentRole];
|
|
1819
|
+
|
|
1820
|
+
// Note: edit/write skip tool rules entirely — they use writablePaths instead.
|
|
1821
|
+
|
|
1822
|
+
// -- Config-driven writable path enforcement ---------------------
|
|
1823
|
+
|
|
1824
|
+
// Planner NN sequence validation (architectural guard — always applies)
|
|
1825
|
+
if (lowerRole === "planner" && path.startsWith("todo/")) {
|
|
1826
|
+
const nn = extractNN(path.replace("todo/", ""));
|
|
1827
|
+
if (nn !== null && nn <= (await findMaxNN(ctx.cwd))) {
|
|
1828
|
+
return {
|
|
1829
|
+
block: true,
|
|
1830
|
+
reason: `\uD83D\uDCCB Planner: NN must be > max in todo/ and report/. You used ${String(nn).padStart(2, "0")}. Use ${String((await findMaxNN(ctx.cwd)) + 1).padStart(2, "0")} or higher.`,
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// Config-driven writablePaths: if defined and non-empty, use it
|
|
1836
|
+
if (roleDef?.writablePaths !== undefined && roleDef.writablePaths.length > 0) {
|
|
1837
|
+
if (isWritablePath(path, roleDef.writablePaths)) return;
|
|
1838
|
+
const allowed = roleDef.writablePaths
|
|
1839
|
+
.map((e) =>
|
|
1840
|
+
e.path === ""
|
|
1841
|
+
? `*.${e.extension}`
|
|
1842
|
+
: `${e.path}/${e.extension ? `*.${e.extension}` : "*"}`,
|
|
1843
|
+
)
|
|
1844
|
+
.join(", ");
|
|
1845
|
+
return {
|
|
1846
|
+
block: true,
|
|
1847
|
+
reason: `\uD83D\uDD12 ${currentRole}: can only write to ${allowed}. Blocked: ${rawPath}`,
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// Fallback: hardcoded per-role path restrictions
|
|
1380
1852
|
|
|
1381
1853
|
// Planner: only todo/, plan/, docs/*.md
|
|
1382
1854
|
if (lowerRole === "planner") {
|
|
1383
|
-
if (isInside(path, ["todo", "plan", "docs"]))
|
|
1384
|
-
if (path.startsWith("todo/")) {
|
|
1385
|
-
const nn = extractNN(path.replace("todo/", ""));
|
|
1386
|
-
if (nn !== null && nn <= (await findMaxNN(ctx.cwd))) {
|
|
1387
|
-
return {
|
|
1388
|
-
block: true,
|
|
1389
|
-
reason: `\uD83D\uDCCB Planner: NN must be > max in todo/ and report/. You used ${String(nn).padStart(2, "0")}. Use ${String((await findMaxNN(ctx.cwd)) + 1).padStart(2, "0")} or higher.`,
|
|
1390
|
-
};
|
|
1391
|
-
}
|
|
1392
|
-
}
|
|
1393
|
-
return;
|
|
1394
|
-
}
|
|
1855
|
+
if (isInside(path, ["todo", "plan", "docs"])) return;
|
|
1395
1856
|
return {
|
|
1396
1857
|
block: true,
|
|
1397
1858
|
reason: `\uD83D\uDCCB Planner: can only write to todo/, plan/, and docs/*.md. Blocked: ${rawPath}`,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool enforcement engine — reusable by extensions and packages.
|
|
3
|
+
*
|
|
4
|
+
* Provides types and functions for config-driven tool rule evaluation.
|
|
5
|
+
*
|
|
6
|
+
* Exports:
|
|
7
|
+
* - RegexObj, ToolRule (types)
|
|
8
|
+
* - testRegex() — recursive regex matching (string / any / all)
|
|
9
|
+
* - evaluateToolRules() — first-match-wins rule evaluation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// -- Types ---------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export interface RegexObj {
|
|
15
|
+
type: "any" | "all";
|
|
16
|
+
regex: (string | RegexObj)[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ToolRule {
|
|
20
|
+
type: "allow" | "block" | "confirm";
|
|
21
|
+
regex: string | RegexObj;
|
|
22
|
+
reason?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// -- Regex matching ------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Recursively test a command against a regex pattern.
|
|
29
|
+
*
|
|
30
|
+
* - **string**: direct `new RegExp(regex).test(command)`, invalid patterns skipped
|
|
31
|
+
* - **`{ type: "any", regex: [...] }`**: true if **any** sub-regex matches (OR)
|
|
32
|
+
* - **`{ type: "all", regex: [...] }`**: true if **all** sub-regexes match (AND)
|
|
33
|
+
*
|
|
34
|
+
* Sub-regexes can themselves be strings or nested `RegexObj` values.
|
|
35
|
+
*/
|
|
36
|
+
export function testRegex(regex: string | RegexObj, command: string): boolean {
|
|
37
|
+
if (typeof regex === "string") {
|
|
38
|
+
try {
|
|
39
|
+
return new RegExp(regex).test(command);
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// RegexObj
|
|
45
|
+
if (regex.type === "any") {
|
|
46
|
+
return regex.regex.some((r) => testRegex(r, command));
|
|
47
|
+
}
|
|
48
|
+
// type === "all"
|
|
49
|
+
return regex.regex.every((r) => testRegex(r, command));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// -- Rule evaluation -----------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Evaluate a command against an ordered list of tool rules.
|
|
56
|
+
*
|
|
57
|
+
* **First matching rule wins**: if a rule matches and its type is `"allow"`,
|
|
58
|
+
* the command is permitted. If `"block"`, the command is denied with an
|
|
59
|
+
* optional reason. If `"confirm"`, the command requires user confirmation.
|
|
60
|
+
* If no rule matches, the command also requires user confirmation (safe default).
|
|
61
|
+
*
|
|
62
|
+
* @param rules - Array of `ToolRule` objects, or `undefined` (treated as "not configured")
|
|
63
|
+
* @param command - The string to test (bash command, tool name, etc.)
|
|
64
|
+
* @returns
|
|
65
|
+
* - `{ allowed: true }` — command is permitted unconditionally
|
|
66
|
+
* - `{ allowed: false, reason }` — command is denied
|
|
67
|
+
* - `{ allowed: true, confirm: true, reason }` — command requires user confirmation
|
|
68
|
+
*/
|
|
69
|
+
export function evaluateToolRules(
|
|
70
|
+
rules: ToolRule[] | undefined,
|
|
71
|
+
command: string,
|
|
72
|
+
): { allowed: boolean; confirm?: boolean; reason?: string; matched: boolean } {
|
|
73
|
+
if (!rules || rules.length === 0) {
|
|
74
|
+
// No rules defined or empty array = caller should fall back to hardcoded behaviour
|
|
75
|
+
return { allowed: true, matched: false };
|
|
76
|
+
}
|
|
77
|
+
for (const rule of rules) {
|
|
78
|
+
if (testRegex(rule.regex, command)) {
|
|
79
|
+
if (rule.type === "allow") {
|
|
80
|
+
return { allowed: true, matched: true };
|
|
81
|
+
}
|
|
82
|
+
if (rule.type === "confirm") {
|
|
83
|
+
return { allowed: true, confirm: true, reason: rule.reason, matched: true };
|
|
84
|
+
}
|
|
85
|
+
// block
|
|
86
|
+
return { allowed: false, reason: rule.reason, matched: true };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// No rule matched — caller should continue to next stage
|
|
90
|
+
return { allowed: true, matched: false };
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@senomas/pi-git-hat",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Pi extension for role-based Git branch workflows — wear different hats by switching branches",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": ["pi-package", "git", "workflow", "branching", "roles"],
|
package/roles/roles.json
CHANGED
|
@@ -1,9 +1,99 @@
|
|
|
1
1
|
{
|
|
2
|
+
"default": {
|
|
3
|
+
"pre-tool": [
|
|
4
|
+
{ "type": "allow", "regex": "^ls ", "reason": "List directory contents" },
|
|
5
|
+
{ "type": "allow", "regex": "^find ", "reason": "Search for files" },
|
|
6
|
+
{ "type": "allow", "regex": "^grep ", "reason": "Search text patterns" },
|
|
7
|
+
{ "type": "allow", "regex": "^rg ", "reason": "Recursive text search" },
|
|
8
|
+
{ "type": "allow", "regex": "^cat ", "reason": "Concatenate/read files" },
|
|
9
|
+
{ "type": "allow", "regex": "^head ", "reason": "Output first lines" },
|
|
10
|
+
{ "type": "allow", "regex": "^tail ", "reason": "Output last lines" },
|
|
11
|
+
{ "type": "allow", "regex": "^wc ", "reason": "Word/line/byte count" },
|
|
12
|
+
{ "type": "allow", "regex": "^sort ", "reason": "Sort lines" },
|
|
13
|
+
{ "type": "allow", "regex": "^uniq ", "reason": "Filter repeated lines" },
|
|
14
|
+
{ "type": "allow", "regex": "^jq ", "reason": "JSON query" },
|
|
15
|
+
{ "type": "allow", "regex": "^dirname ", "reason": "Strip path components" },
|
|
16
|
+
{ "type": "allow", "regex": "^basename ", "reason": "Strip directory from path" },
|
|
17
|
+
{ "type": "allow", "regex": "^pwd$", "reason": "Print working directory" },
|
|
18
|
+
{ "type": "allow", "regex": "^echo ", "reason": "Print text" },
|
|
19
|
+
{ "type": "allow", "regex": "^printf ", "reason": "Formatted print" },
|
|
20
|
+
{ "type": "allow", "regex": "^which ", "reason": "Locate a command" },
|
|
21
|
+
{ "type": "allow", "regex": "^whoami$", "reason": "Print current user" },
|
|
22
|
+
{ "type": "allow", "regex": "^uname ", "reason": "Print system info" },
|
|
23
|
+
{ "type": "allow", "regex": "^hostname$", "reason": "Print host name" },
|
|
24
|
+
{ "type": "allow", "regex": "^date ", "reason": "Show date/time" },
|
|
25
|
+
{ "type": "allow", "regex": "^env$", "reason": "Print environment" },
|
|
26
|
+
{ "type": "allow", "regex": "^true$", "reason": "No-op success" },
|
|
27
|
+
{ "type": "allow", "regex": "^false$", "reason": "No-op failure" },
|
|
28
|
+
{ "type": "allow", "regex": "^yes ", "reason": "Repeat output" },
|
|
29
|
+
{ "type": "allow", "regex": "^time ", "reason": "Time a command" },
|
|
30
|
+
{ "type": "allow", "regex": "^seq ", "reason": "Print number sequence" },
|
|
31
|
+
{ "type": "allow", "regex": "^sleep ", "reason": "Pause execution" },
|
|
32
|
+
{ "type": "allow", "regex": "^read ", "reason": "Read file contents" },
|
|
33
|
+
{ "type": "allow", "regex": "^diff ", "reason": "Compare files" }
|
|
34
|
+
],
|
|
35
|
+
"post-tool": [
|
|
36
|
+
{ "type": "confirm", "regex": "^git push\\b", "reason": "Push to remote?" },
|
|
37
|
+
{ "type": "confirm", "regex": "^git checkout\\b", "reason": "Switch branches?" },
|
|
38
|
+
{ "type": "confirm", "regex": "^git switch\\b", "reason": "Switch branches?" },
|
|
39
|
+
{ "type": "allow", "regex": "^git\\b", "reason": "Git commands" }
|
|
40
|
+
]
|
|
41
|
+
},
|
|
2
42
|
"roles": {
|
|
3
|
-
"planner":
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
43
|
+
"planner": {
|
|
44
|
+
"pattern": "^plan(-|/|$)",
|
|
45
|
+
"description": "Plan work by creating todo/plan files",
|
|
46
|
+
"tool": [
|
|
47
|
+
{ "type": "allow", "regex": "^web_", "reason": "Web search" },
|
|
48
|
+
{ "type": "block", "regex": ".*", "reason": "Planner: block." }
|
|
49
|
+
],
|
|
50
|
+
"writablePaths": [
|
|
51
|
+
{ "path": "todo", "extension": "md" },
|
|
52
|
+
{ "path": "plan", "extension": "md" },
|
|
53
|
+
{ "path": "docs", "extension": "md" }
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
"implementor": {
|
|
57
|
+
"pattern": "^(implementor$|feature|feat|impl$|work$)(-|/|$)",
|
|
58
|
+
"description": "Feature/impl/work branches",
|
|
59
|
+
"tool": [
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
"reviewer": {
|
|
63
|
+
"pattern": "^review(-|/|$)",
|
|
64
|
+
"description": "Review implementations against todos",
|
|
65
|
+
"tool": [
|
|
66
|
+
],
|
|
67
|
+
"post-tool": [
|
|
68
|
+
{ "type": "block", "regex": ".*", "reason": "Reviewer: block." }
|
|
69
|
+
],
|
|
70
|
+
"writablePaths": [
|
|
71
|
+
{ "path": "todo" }
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
"admin": {
|
|
75
|
+
"pattern": "^(main|master)$",
|
|
76
|
+
"description": "Project configuration and docs",
|
|
77
|
+
"tool": [
|
|
78
|
+
],
|
|
79
|
+
"writablePaths": [
|
|
80
|
+
{ "path": ".pi" },
|
|
81
|
+
{ "path": "", "extension": "md" }
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
"researcher": {
|
|
85
|
+
"pattern": "^research(-|/|$)",
|
|
86
|
+
"description": "Research topics and document findings",
|
|
87
|
+
"tool": [
|
|
88
|
+
{ "type": "allow", "regex": "^web_", "reason": "Web research" }
|
|
89
|
+
],
|
|
90
|
+
"post-tool": [
|
|
91
|
+
{ "type": "block", "regex": ".*", "reason": "Reviewer: block." }
|
|
92
|
+
],
|
|
93
|
+
"writablePaths": [
|
|
94
|
+
{ "path": "docs", "extension": "md" },
|
|
95
|
+
{ "path": "", "extension": "md" }
|
|
96
|
+
]
|
|
97
|
+
}
|
|
8
98
|
}
|
|
9
99
|
}
|