@tianhai/pi-workflow-kit 0.7.1 → 0.8.0

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
@@ -13,7 +13,7 @@ brainstorm → plan → execute → finalize
13
13
  **1 extension** that enforces the rules:
14
14
 
15
15
  - During brainstorming and planning, `write` and `edit` are **hard-blocked** outside `docs/plans/`. The agent can only read code and discuss the design with you — it literally cannot modify source files.
16
- - `bash` stays available for investigation (`grep`, `find`, `git log`, etc.).
16
+ - `bash` is **restricted to read-only commands** — file writes, installs, git mutations, and editors are blocked. Safe commands like `grep`, `find`, `git status`, `cat`, `curl` remain available.
17
17
 
18
18
  No configuration required. Skills and extensions activate automatically after install.
19
19
 
@@ -34,7 +34,7 @@ You control each phase explicitly by invoking the skill:
34
34
  | **Execute** | `/skill:executing-tasks` | Implement the plan task-by-task with TDD discipline and optional checkpoint review gates |
35
35
  | **Finalize** | `/skill:finalizing` | Archive plan docs, update README/CHANGELOG, create PR |
36
36
 
37
- During brainstorm and plan, the extension blocks `write`/`edit` outside `docs/plans/`. During execute and finalize, all tools are available.
37
+ During brainstorm and plan, the extension blocks `write`/`edit` outside `docs/plans/` and restricts `bash` to read-only commands. During execute and finalize, all tools are available.
38
38
 
39
39
  ### Skills
40
40
 
@@ -0,0 +1,39 @@
1
+ # Bash Guard for Workflow Guard Extension
2
+
3
+ ## Problem
4
+
5
+ `workflow-guard.ts` blocks `write`/`edit` outside `docs/plans/` during brainstorm and plan phases, but ignores `bash` entirely. The LLM can mutate files via bash (e.g. `echo "..." > file.ts`, `sed -i`, `tee`, heredocs).
6
+
7
+ ## Design
8
+
9
+ Adopt the `isSafeCommand()` approach from [pi-mono plan-mode example](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/examples/extensions/plan-mode/utils.ts).
10
+
11
+ ### Approach
12
+
13
+ **No changes to `pi.setActiveTools()`** — `write`/`edit` remain active so the existing path-based `tool_call` guard continues to allow writes to `docs/plans/`.
14
+
15
+ **Add a bash guard** in the existing `tool_call` handler using `isSafeCommand()`:
16
+ - Copy SAFE_PATTERNS and DESTRUCTIVE_PATTERNS from the example's `utils.ts` as-is
17
+ - Copy `isSafeCommand()` logic as-is
18
+ - In the `tool_call` handler, when phase is set and tool is `bash`, check `isSafeCommand(event.input.command)`
19
+ - Block with reason if not safe
20
+ - Notify via `ctx.ui.notify()` when blocking
21
+
22
+ ### Changes
23
+
24
+ 1. **`extensions/workflow-guard.ts`**
25
+ - Add `isSafeCommand()` function (copied from example, with DESTRUCTIVE_PATTERNS and SAFE_PATTERNS)
26
+ - Extend `tool_call` handler to also intercept `bash` when phase is set
27
+ - Check `event.input.command` against `isSafeCommand()`
28
+ - Block and notify if unsafe
29
+
30
+ 2. **`tests/workflow-guard.test.ts`**
31
+ - Add tests for bash guard: safe commands pass, destructive commands blocked, redirects blocked
32
+ - Import and test `isSafeCommand` directly (it's a pure function, easy to unit test)
33
+
34
+ ### What stays the same
35
+
36
+ - Phase detection via `/skill:` commands (no change)
37
+ - `write`/`edit` path-based blocking for `docs/plans/` (no change)
38
+ - `docs/plans/` write exception (no change)
39
+ - No `pi.setActiveTools()` usage
@@ -0,0 +1,229 @@
1
+ # Bash Guard Implementation Plan
2
+
3
+ Based on `docs/plans/2026-04-15-bash-guard-design.md`.
4
+
5
+ ## Task 1 — Add `isSafeCommand()` to workflow-guard.ts [new feature, checkpoint: test]
6
+
7
+ **File:** `extensions/workflow-guard.ts`
8
+
9
+ - Add `isSafeCommand` as a named export (so tests can import it directly)
10
+ - Copy `DESTRUCTIVE_PATTERNS` and `SAFE_PATTERNS` from [plan-mode utils.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/examples/extensions/plan-mode/utils.ts) as-is
11
+ - Copy the `isSafeCommand()` logic as-is
12
+
13
+ ```ts
14
+ // Destructive commands blocked in brainstorm/plan phases
15
+ const DESTRUCTIVE_PATTERNS = [
16
+ /\brm\b/i,
17
+ /\brmdir\b/i,
18
+ /\bmv\b/i,
19
+ /\bcp\b/i,
20
+ /\bmkdir\b/i,
21
+ /\btouch\b/i,
22
+ /\bchmod\b/i,
23
+ /\bchown\b/i,
24
+ /\bchgrp\b/i,
25
+ /\bln\b/i,
26
+ /\btee\b/i,
27
+ /\btruncate\b/i,
28
+ /\bdd\b/i,
29
+ /\bshred\b/i,
30
+ /(^|[^<])>(?!>)/,
31
+ />>/,
32
+ /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
33
+ /\byarn\s+(add|remove|install|publish)/i,
34
+ /\bpnpm\s+(add|remove|install|publish)/i,
35
+ /\bpip\s+(install|uninstall)/i,
36
+ /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
37
+ /\bbrew\s+(install|uninstall|upgrade)/i,
38
+ /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
39
+ /\bsudo\b/i,
40
+ /\bsu\b/i,
41
+ /\bkill\b/i,
42
+ /\bpkill\b/i,
43
+ /\bkillall\b/i,
44
+ /\breboot\b/i,
45
+ /\bshutdown\b/i,
46
+ /\bsystemctl\s+(start|stop|restart|enable|disable)/i,
47
+ /\bservice\s+\S+\s+(start|stop|restart)/i,
48
+ /\b(vim?|nano|emacs|code|subl)\b/i,
49
+ ];
50
+
51
+ const SAFE_PATTERNS = [
52
+ /^\s*cat\b/,
53
+ /^\s*head\b/,
54
+ /^\s*tail\b/,
55
+ /^\s*less\b/,
56
+ /^\s*more\b/,
57
+ /^\s*grep\b/,
58
+ /^\s*find\b/,
59
+ /^\s*ls\b/,
60
+ /^\s*pwd\b/,
61
+ /^\s*echo\b/,
62
+ /^\s*printf\b/,
63
+ /^\s*wc\b/,
64
+ /^\s*sort\b/,
65
+ /^\s*uniq\b/,
66
+ /^\s*diff\b/,
67
+ /^\s*file\b/,
68
+ /^\s*stat\b/,
69
+ /^\s*du\b/,
70
+ /^\s*df\b/,
71
+ /^\s*tree\b/,
72
+ /^\s*which\b/,
73
+ /^\s*whereis\b/,
74
+ /^\s*type\b/,
75
+ /^\s*env\b/,
76
+ /^\s*printenv\b/,
77
+ /^\s*uname\b/,
78
+ /^\s*whoami\b/,
79
+ /^\s*id\b/,
80
+ /^\s*date\b/,
81
+ /^\s*cal\b/,
82
+ /^\s*uptime\b/,
83
+ /^\s*ps\b/,
84
+ /^\s*top\b/,
85
+ /^\s*htop\b/,
86
+ /^\s*free\b/,
87
+ /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
88
+ /^\s*git\s+ls-/i,
89
+ /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
90
+ /^\s*yarn\s+(list|info|why|audit)/i,
91
+ /^\s*node\s+--version/i,
92
+ /^\s*python\s+--version/i,
93
+ /^\s*curl\s/i,
94
+ /^\s*wget\s+-O\s*-/i,
95
+ /^\s*jq\b/,
96
+ /^\s*sed\s+-n/i,
97
+ /^\s*awk\b/,
98
+ /^\s*rg\b/,
99
+ /^\s*fd\b/,
100
+ /^\s*bat\b/,
101
+ /^\s*eza\b/,
102
+ ];
103
+
104
+ export function isSafeCommand(command: string): boolean {
105
+ const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
106
+ const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
107
+ return !isDestructive && isSafe;
108
+ }
109
+ ```
110
+
111
+ - Write failing tests in `tests/workflow-guard.test.ts` for `isSafeCommand`:
112
+
113
+ ```ts
114
+ import { isSafeCommand } from "../extensions/workflow-guard";
115
+
116
+ describe("isSafeCommand", () => {
117
+ it("allows safe read-only commands", () => {
118
+ expect(isSafeCommand("cat file.ts")).toBe(true);
119
+ expect(isSafeCommand("grep -r 'foo' src/")).toBe(true);
120
+ expect(isSafeCommand("git status")).toBe(true);
121
+ expect(isSafeCommand("git log --oneline -5")).toBe(true);
122
+ expect(isSafeCommand("npm list")).toBe(true);
123
+ expect(isSafeCommand("ls -la")).toBe(true);
124
+ expect(isSafeCommand("curl https://example.com")).toBe(true);
125
+ });
126
+
127
+ it("blocks destructive commands", () => {
128
+ expect(isSafeCommand("rm -rf node_modules")).toBe(false);
129
+ expect(isSafeCommand("touch newfile.ts")).toBe(false);
130
+ expect(isSafeCommand("mv old.ts new.ts")).toBe(false);
131
+ expect(isSafeCommand("mkdir src/components")).toBe(false);
132
+ });
133
+
134
+ it("blocks file-writing bash patterns", () => {
135
+ expect(isSafeCommand("echo 'hello' > file.ts")).toBe(false);
136
+ expect(isSafeCommand("cat config > backup.txt")).toBe(false);
137
+ expect(isSafeCommand("echo 'log' >> output.log")).toBe(false);
138
+ expect(isSafeCommand("tee output.txt")).toBe(false);
139
+ expect(isSafeCommand("sed -i 's/old/new/g' file.ts")).toBe(false);
140
+ });
141
+
142
+ it("blocks git mutations but allows read-only git", () => {
143
+ expect(isSafeCommand("git add .")).toBe(false);
144
+ expect(isSafeCommand("git commit -m 'msg'")).toBe(false);
145
+ expect(isSafeCommand("git push")).toBe(false);
146
+ expect(isSafeCommand("git checkout -b feature")).toBe(false);
147
+ expect(isSafeCommand("git status")).toBe(true);
148
+ expect(isSafeCommand("git log --oneline")).toBe(true);
149
+ expect(isSafeCommand("git diff")).toBe(true);
150
+ });
151
+
152
+ it("blocks editors", () => {
153
+ expect(isSafeCommand("vim file.ts")).toBe(false);
154
+ expect(isSafeCommand("nano file.ts")).toBe(false);
155
+ expect(isSafeCommand("code .")).toBe(false);
156
+ });
157
+
158
+ it("blocks sudo", () => {
159
+ expect(isSafeCommand("sudo apt install foo")).toBe(false);
160
+ });
161
+
162
+ it("blocks npm installs but allows read-only npm", () => {
163
+ expect(isSafeCommand("npm install lodash")).toBe(false);
164
+ expect(isSafeCommand("npm list")).toBe(true);
165
+ expect(isSafeCommand("npm audit")).toBe(true);
166
+ });
167
+ });
168
+ ```
169
+
170
+ - Run: `npx vitest run tests/workflow-guard.test.ts`
171
+ - Verify: all new `isSafeCommand` tests fail (function doesn't exist yet)
172
+ - Implement `isSafeCommand()` in `extensions/workflow-guard.ts`
173
+ - Run: `npx vitest run tests/workflow-guard.test.ts`
174
+ - Verify: all tests pass
175
+ - `git add -A && git commit -m "feat: add isSafeCommand bash guard to workflow-guard"`
176
+
177
+ ## Task 2 — Wire bash guard into tool_call handler [modifying tested code]
178
+
179
+ **File:** `extensions/workflow-guard.ts`
180
+
181
+ Replace the tool_call guard from:
182
+ ```ts
183
+ if (event.toolName !== "write" && event.toolName !== "edit") return;
184
+ ```
185
+
186
+ To:
187
+ ```ts
188
+ if (event.toolName === "bash") {
189
+ const command = (event.input as { command?: string }).command ?? "";
190
+ if (!isSafeCommand(command)) {
191
+ if (ctx.hasUI) {
192
+ ctx.ui.notify(
193
+ `Blocked bash command during ${phase} phase: ${command}`,
194
+ "warning",
195
+ );
196
+ }
197
+ return {
198
+ block: true,
199
+ reason: `⚠️ ${phase.toUpperCase()} PHASE: Bash command blocked (not allowlisted). Only read-only commands are permitted during brainstorming and planning.\nCommand: ${command}`,
200
+ };
201
+ }
202
+ return;
203
+ }
204
+
205
+ if (event.toolName !== "write" && event.toolName !== "edit") return;
206
+ ```
207
+
208
+ - Run: `npx vitest run tests/workflow-guard.test.ts`
209
+ - Verify: existing tests pass (the "should allow bash" test needs updating — see Task 3)
210
+ - `git add -A && git commit -m "feat: wire bash guard into tool_call handler"`
211
+
212
+ ## Task 3 — Update existing test for new bash behavior [modifying tested code]
213
+
214
+ **File:** `tests/workflow-guard.test.ts`
215
+
216
+ The test `"should allow bash regardless of phase"` is now incorrect — bash with unsafe commands should be blocked. Update it:
217
+
218
+ ```ts
219
+ it("should block unsafe bash during brainstorm (safe bash tested via isSafeCommand)", () => {
220
+ // The tool_call handler now guards bash via isSafeCommand.
221
+ // Direct testing of safe/unsafe commands is in the isSafeCommand describe block.
222
+ // This just confirms bash is no longer blanket-allowed.
223
+ expect(isSafeCommand("rm -rf /")).toBe(false);
224
+ });
225
+ ```
226
+
227
+ - Run: `npx vitest run tests/workflow-guard.test.ts`
228
+ - Verify: all tests pass
229
+ - `git add -A && git commit -m "test: update bash guard test for new blocking behavior"`
@@ -4,13 +4,109 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
4
  /**
5
5
  * Workflow Guard extension.
6
6
  *
7
- * Blocks write/edit outside docs/plans/ during brainstorm and plan phases.
7
+ * Blocks write/edit outside docs/plans/ and unsafe bash during brainstorm and plan phases.
8
8
  * You control phases explicitly via /skill: commands — no auto-detection,
9
9
  * no state persistence, no prompts.
10
10
  */
11
11
 
12
12
  type Phase = "brainstorm" | "plan" | null;
13
13
 
14
+ // Destructive commands blocked in brainstorm/plan phases
15
+ const DESTRUCTIVE_PATTERNS = [
16
+ /\brm\b/i,
17
+ /\brmdir\b/i,
18
+ /\bmv\b/i,
19
+ /\bcp\b/i,
20
+ /\bmkdir\b/i,
21
+ /\btouch\b/i,
22
+ /\bchmod\b/i,
23
+ /\bchown\b/i,
24
+ /\bchgrp\b/i,
25
+ /\bln\b/i,
26
+ /\btee\b/i,
27
+ /\btruncate\b/i,
28
+ /\bdd\b/i,
29
+ /\bshred\b/i,
30
+ /(^|[^<])>(?!>)/,
31
+ />>/,
32
+ /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
33
+ /\byarn\s+(add|remove|install|publish)/i,
34
+ /\bpnpm\s+(add|remove|install|publish)/i,
35
+ /\bpip\s+(install|uninstall)/i,
36
+ /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
37
+ /\bbrew\s+(install|uninstall|upgrade)/i,
38
+ /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
39
+ /\bsudo\b/i,
40
+ /\bsu\b/i,
41
+ /\bkill\b/i,
42
+ /\bpkill\b/i,
43
+ /\bkillall\b/i,
44
+ /\breboot\b/i,
45
+ /\bshutdown\b/i,
46
+ /\bsystemctl\s+(start|stop|restart|enable|disable)/i,
47
+ /\bservice\s+\S+\s+(start|stop|restart)/i,
48
+ /\b(vim?|nano|emacs|code|subl)\b/i,
49
+ ];
50
+
51
+ const SAFE_PATTERNS = [
52
+ /^\s*cat\b/,
53
+ /^\s*head\b/,
54
+ /^\s*tail\b/,
55
+ /^\s*less\b/,
56
+ /^\s*more\b/,
57
+ /^\s*grep\b/,
58
+ /^\s*find\b/,
59
+ /^\s*ls\b/,
60
+ /^\s*pwd\b/,
61
+ /^\s*echo\b/,
62
+ /^\s*printf\b/,
63
+ /^\s*wc\b/,
64
+ /^\s*sort\b/,
65
+ /^\s*uniq\b/,
66
+ /^\s*diff\b/,
67
+ /^\s*file\b/,
68
+ /^\s*stat\b/,
69
+ /^\s*du\b/,
70
+ /^\s*df\b/,
71
+ /^\s*tree\b/,
72
+ /^\s*which\b/,
73
+ /^\s*whereis\b/,
74
+ /^\s*type\b/,
75
+ /^\s*env\b/,
76
+ /^\s*printenv\b/,
77
+ /^\s*uname\b/,
78
+ /^\s*whoami\b/,
79
+ /^\s*id\b/,
80
+ /^\s*date\b/,
81
+ /^\s*cal\b/,
82
+ /^\s*uptime\b/,
83
+ /^\s*ps\b/,
84
+ /^\s*top\b/,
85
+ /^\s*htop\b/,
86
+ /^\s*free\b/,
87
+ /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
88
+ /^\s*git\s+ls-/i,
89
+ /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
90
+ /^\s*yarn\s+(list|info|why|audit)/i,
91
+ /^\s*node\s+--version/i,
92
+ /^\s*python\s+--version/i,
93
+ /^\s*curl\s/i,
94
+ /^\s*wget\s+-O\s*-/i,
95
+ /^\s*jq\b/,
96
+ /^\s*sed\s+-n/i,
97
+ /^\s*awk\b/,
98
+ /^\s*rg\b/,
99
+ /^\s*fd\b/,
100
+ /^\s*bat\b/,
101
+ /^\s*eza\b/,
102
+ ];
103
+
104
+ export function isSafeCommand(command: string): boolean {
105
+ const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
106
+ const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
107
+ return !isDestructive && isSafe;
108
+ }
109
+
14
110
  const SKILL_TO_PHASE: Record<string, Phase> = {
15
111
  brainstorming: "brainstorm",
16
112
  "writing-plans": "plan",
@@ -48,6 +144,23 @@ export default function (pi: ExtensionAPI) {
48
144
  pi.on("tool_call", (event, ctx) => {
49
145
  if (!phase) return;
50
146
 
147
+ if (event.toolName === "bash") {
148
+ const command = (event.input as { command?: string }).command ?? "";
149
+ if (!isSafeCommand(command)) {
150
+ if (ctx.hasUI) {
151
+ ctx.ui.notify(
152
+ `Blocked bash command during ${phase} phase: ${command}`,
153
+ "warning",
154
+ );
155
+ }
156
+ return {
157
+ block: true,
158
+ reason: `⚠️ ${phase.toUpperCase()} PHASE: Bash command blocked (not allowlisted). Only read-only commands are permitted during brainstorming and planning.\nCommand: ${command}`,
159
+ };
160
+ }
161
+ return;
162
+ }
163
+
51
164
  if (event.toolName !== "write" && event.toolName !== "edit") return;
52
165
 
53
166
  const filePath = (event.input as { path?: string }).path ?? "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tianhai/pi-workflow-kit",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Workflow skills and enforcement extensions for pi",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -1,33 +0,0 @@
1
- # Finalizing: Merge Strategy Options
2
-
3
- ## Problem
4
-
5
- The finalizing skill hard-codes "Create PR" as the only shipping option. In practice, small features often don't need a PR — they can be merged directly back to the parent branch.
6
-
7
- ## Design
8
-
9
- Add a merge strategy step after updating documentation. The human chooses one of four options:
10
-
11
- 1. **Create PR** — push and open a PR for external review via `gh pr create`
12
- 2. **Rebase & merge** (recommended) — rebase onto parent, fast-forward merge, push parent, delete feature branch. Preserves per-task commit history linearly.
13
- 3. **Squash & merge** — squash all commits into one on parent, push parent, delete feature branch. Clean single-commit history.
14
- 4. **Merge commit** — merge with `--no-ff`, push parent, delete feature branch. Preserves all commits and branch topology.
15
-
16
- ### Flow for options 2–4 (local merge)
17
-
18
- 1. Detect parent branch (compare `main` vs `master`, fall back to `git show-branch`)
19
- 2. Switch to parent branch and pull latest
20
- 3. Execute the chosen merge strategy:
21
- - Rebase: `git rebase <parent>` on feature branch, then `git merge --ff-only <feature>` on parent
22
- - Squash: `git merge --squash <feature>` on parent, then `git commit`
23
- - Merge commit: `git merge --no-ff <feature>` on parent
24
- 4. Push parent to origin
25
- 5. Delete feature branch locally and remotely
26
-
27
- ### Prompting
28
-
29
- The skill should ask the human which option they prefer, presenting rebase & merge as the default recommendation.
30
-
31
- ## Changes
32
-
33
- - Update `skills/finalizing/SKILL.md` to replace the hard-coded PR step with the 4-option choice.