@senomas/pi-git-hat 0.2.3 → 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 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 (result.totalPending - result.totalCovered > 0) {
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,10 +1243,10 @@ export default function (pi: ExtensionAPI) {
1141
1243
  },
1142
1244
  });
1143
1245
 
1144
- // -- /hatr command: rebase onto most recent role-matched branch --
1246
+ // -- /hatr command: TUI branch selector for rebase ------------------
1145
1247
 
1146
1248
  pi.registerCommand("hatr", {
1147
- description: "Rebase current branch onto the most recent role-matched branch",
1249
+ description: "TUI branch selector pick a role-matched branch to rebase onto",
1148
1250
  handler: async (_args, ctx) => {
1149
1251
  // Edge case: detached HEAD or non-git directory
1150
1252
  const branch = await detectBranch();
@@ -1209,13 +1311,90 @@ export default function (pi: ExtensionAPI) {
1209
1311
  return;
1210
1312
  }
1211
1313
 
1212
- // Pick the most recent
1314
+ // Sort by commit timestamp descending (most recent first)
1213
1315
  infos.sort((a, b) => b.timestamp - a.timestamp);
1214
- const target = infos[0];
1215
1316
 
1216
- // Show and confirm
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
1217
1396
  const confirmed = await ctx.ui.confirm(
1218
- `Rebase ${currentBranch} onto ${target.branch}? (latest commit: ${target.abbrev} ${target.subject})`,
1397
+ `Rebase ${currentBranch} onto ${result}? (${selectedInfo.abbrev} ${selectedInfo.subject})`,
1219
1398
  );
1220
1399
  if (!confirmed) {
1221
1400
  ctx.ui.notify("Rebase cancelled.", "info");
@@ -1224,22 +1403,22 @@ export default function (pi: ExtensionAPI) {
1224
1403
 
1225
1404
  // Run the rebase
1226
1405
  try {
1227
- const result = await pi.exec("git", ["rebase", target.branch]);
1228
- if (result.code === 0) {
1406
+ const rebaseResult = await pi.exec("git", ["rebase", result]);
1407
+ if (rebaseResult.code === 0) {
1229
1408
  ctx.ui.notify(
1230
- `Rebase onto ${target.branch} succeeded. Updated commit tree:`,
1409
+ `Rebase onto ${result} succeeded. Updated commit tree:`,
1231
1410
  "info",
1232
1411
  );
1233
1412
  await showGitLog(ctx, 10);
1234
1413
  } else {
1235
1414
  ctx.ui.notify(
1236
- `Rebase onto ${target.branch} failed:\n${result.stderr}\n\nResolve conflicts manually, then run \`git rebase --continue\`.`,
1415
+ `Rebase onto ${result} failed:\n${rebaseResult.stderr}\n\nResolve conflicts manually, then run \`git rebase --continue\`.`,
1237
1416
  "error",
1238
1417
  );
1239
1418
  }
1240
1419
  } catch (e) {
1241
1420
  ctx.ui.notify(
1242
- `Rebase onto ${target.branch} failed: ${(e as Error).message}\n\nResolve conflicts manually, then run \`git rebase --continue\`.`,
1421
+ `Rebase onto ${result} failed: ${(e as Error).message}\n\nResolve conflicts manually, then run \`git rebase --continue\`.`,
1243
1422
  "error",
1244
1423
  );
1245
1424
  }
@@ -1248,7 +1427,7 @@ export default function (pi: ExtensionAPI) {
1248
1427
 
1249
1428
  // -- Session lifecycle ----------------------------------------
1250
1429
 
1251
- pi.on("session_start", async (event, ctx) => {"}]}
1430
+ pi.on("session_start", async (event, ctx) => {
1252
1431
  cwdAbsolute = ctx.cwd;
1253
1432
 
1254
1433
  // Seed bundled roles/ files to project .pi/ before loading config
@@ -1312,10 +1491,16 @@ export default function (pi: ExtensionAPI) {
1312
1491
  ? `${roleIcon(currentRole)} ${currentRole}${usingFile}`
1313
1492
  : "\u2753 No matching role (read-only mode)";
1314
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
+
1315
1499
  ctx.ui.notify(
1316
1500
  `${roleDisplay}\n` +
1317
1501
  `branch: ${currentBranch ?? "not a git repo"}\n` +
1318
1502
  `${desc ? `desc: ${desc}\n` : ""}` +
1503
+ `${defaultLine}` +
1319
1504
  `roles: ${config.configFile}`,
1320
1505
  "info",
1321
1506
  );
@@ -1475,6 +1660,154 @@ If the user asks you to make changes, tell them to switch to an appropriate bran
1475
1660
  }
1476
1661
  }
1477
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
+
1478
1811
  // Known roles: only intercept edit/write
1479
1812
  if (!isWrite) return;
1480
1813
 
@@ -1482,21 +1815,44 @@ If the user asks you to make changes, tell them to switch to an appropriate bran
1482
1815
  const path = normalisePath(rawPath, ctx.cwd, cwdAbsolute);
1483
1816
 
1484
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
1485
1852
 
1486
1853
  // Planner: only todo/, plan/, docs/*.md
1487
1854
  if (lowerRole === "planner") {
1488
- if (isInside(path, ["todo", "plan", "docs"])) {
1489
- if (path.startsWith("todo/")) {
1490
- const nn = extractNN(path.replace("todo/", ""));
1491
- if (nn !== null && nn <= (await findMaxNN(ctx.cwd))) {
1492
- return {
1493
- block: true,
1494
- 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.`,
1495
- };
1496
- }
1497
- }
1498
- return;
1499
- }
1855
+ if (isInside(path, ["todo", "plan", "docs"])) return;
1500
1856
  return {
1501
1857
  block: true,
1502
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",
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": { "pattern": "^plan(-|/|$)", "description": "Plan work by creating todo/plan files" },
4
- "implementor": { "pattern": "^(implementor$|feature|feat|impl$|work$)(-|/|$)", "description": "Feature/impl/work branches" },
5
- "reviewer": { "pattern": "^review(-|/|$)", "description": "Review implementations against todos" },
6
- "admin": { "pattern": "^(main|master)$", "description": "Project configuration and docs" },
7
- "researcher": { "pattern": "^research(-|/|$)", "description": "Research topics and document findings" }
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
  }