@dreki-gg/pi-plan-mode 0.5.0 → 0.5.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`8e9aa09`](https://github.com/dreki-gg/pi-extensions/commit/8e9aa0963fe81286e9c5972f6a9d666645807f1a) Thanks [@jalbarrang](https://github.com/jalbarrang)! - fix(plan-mode): allow safe bash commands that were incorrectly blocked in plan mode
8
+
9
+ Three fixes to `isSafeCommand`:
10
+
11
+ - Allow `mkdir -p .plans/` since the planner needs to create plan directories
12
+ - Fix redirect pattern to not false-positive on stderr redirects like `2>/dev/null`
13
+ - Split piped commands and validate each segment independently, so `curl ... | grep ... | head` works correctly
14
+
3
15
  ## 0.5.0
4
16
 
5
17
  ### Minor Changes
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { isSafeCommand } from './utils.js';
3
+
4
+ describe('isSafeCommand', () => {
5
+ // ── Commands that SHOULD be allowed ──────────────────────────────────────
6
+ describe('allowed commands', () => {
7
+ test('mkdir -p .plans/<name>', () => {
8
+ expect(isSafeCommand('mkdir -p .plans/monorepo-src-migration')).toBe(true);
9
+ });
10
+
11
+ test('mkdir .plans/<name>', () => {
12
+ expect(isSafeCommand('mkdir .plans/my-plan')).toBe(true);
13
+ });
14
+
15
+ test('curl with 2>/dev/null pipe chain', () => {
16
+ expect(
17
+ isSafeCommand(
18
+ `curl -sL "https://effect.website/docs/platform/introduction/" 2>/dev/null | grep -oP '(?<=href=")[^"]*' | grep -iE "(http|server|rpc)" | head -20`,
19
+ ),
20
+ ).toBe(true);
21
+ });
22
+
23
+ test('curl with 2>/dev/null simple', () => {
24
+ expect(
25
+ isSafeCommand(
26
+ `curl -s "https://raw.githubusercontent.com/Effect-TS/effect/main/packages/platform/README.md" 2>/dev/null | head -100`,
27
+ ),
28
+ ).toBe(true);
29
+ });
30
+
31
+ test('simple ls', () => {
32
+ expect(isSafeCommand('ls -la')).toBe(true);
33
+ });
34
+
35
+ test('git status', () => {
36
+ expect(isSafeCommand('git status')).toBe(true);
37
+ });
38
+
39
+ test('git log', () => {
40
+ expect(isSafeCommand('git log --oneline -10')).toBe(true);
41
+ });
42
+
43
+ test('find piped to grep', () => {
44
+ expect(isSafeCommand('find . -name "*.ts" | grep -v node_modules')).toBe(true);
45
+ });
46
+
47
+ test('cat piped to head', () => {
48
+ expect(isSafeCommand('cat README.md | head -50')).toBe(true);
49
+ });
50
+
51
+ test('rg (ripgrep)', () => {
52
+ expect(isSafeCommand('rg "pattern" src/')).toBe(true);
53
+ });
54
+
55
+ test('grep with context', () => {
56
+ expect(isSafeCommand('grep -rn "export" src/ | head -20')).toBe(true);
57
+ });
58
+
59
+ test('npm list', () => {
60
+ expect(isSafeCommand('npm list --depth=0')).toBe(true);
61
+ });
62
+
63
+ test('curl piped to jq', () => {
64
+ expect(isSafeCommand('curl -s https://api.example.com | jq .name')).toBe(true);
65
+ });
66
+ });
67
+
68
+ // ── Commands that SHOULD be blocked ──────────────────────────────────────
69
+ describe('blocked commands', () => {
70
+ test('rm -rf', () => {
71
+ expect(isSafeCommand('rm -rf node_modules')).toBe(false);
72
+ });
73
+
74
+ test('mkdir outside .plans/', () => {
75
+ expect(isSafeCommand('mkdir -p src/new-dir')).toBe(false);
76
+ });
77
+
78
+ test('stdout redirect to file', () => {
79
+ expect(isSafeCommand('echo "hello" > file.txt')).toBe(false);
80
+ });
81
+
82
+ test('append redirect', () => {
83
+ expect(isSafeCommand('echo "hello" >> file.txt')).toBe(false);
84
+ });
85
+
86
+ test('git commit', () => {
87
+ expect(isSafeCommand('git commit -m "test"')).toBe(false);
88
+ });
89
+
90
+ test('npm install', () => {
91
+ expect(isSafeCommand('npm install lodash')).toBe(false);
92
+ });
93
+
94
+ test('mv file', () => {
95
+ expect(isSafeCommand('mv old.ts new.ts')).toBe(false);
96
+ });
97
+
98
+ test('sudo anything', () => {
99
+ expect(isSafeCommand('sudo ls')).toBe(false);
100
+ });
101
+
102
+ test('cp file', () => {
103
+ expect(isSafeCommand('cp a.ts b.ts')).toBe(false);
104
+ });
105
+
106
+ test('touch file', () => {
107
+ expect(isSafeCommand('touch newfile.ts')).toBe(false);
108
+ });
109
+ });
110
+ });
@@ -24,7 +24,8 @@ const DESTRUCTIVE_PATTERNS = [
24
24
  /\btruncate\b/i,
25
25
  /\bdd\b/i,
26
26
  /\bshred\b/i,
27
- /(^|[^<])>(?!>)/,
27
+ // stdout redirect — but NOT stderr redirects like 2>/dev/null or 2>&1
28
+ /(?<!\d)>(?!>|&)/,
28
29
  />>/,
29
30
  /\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
30
31
  /\byarn\s+(add|remove|install|publish)/i,
@@ -117,10 +118,70 @@ const SAFE_PATTERNS = [
117
118
  /^\s*tasklist\b/,
118
119
  ];
119
120
 
121
+ /**
122
+ * Check if a command is safe for plan mode.
123
+ *
124
+ * For simple commands, checks that the command starts with a safe pattern and
125
+ * doesn't contain destructive patterns. For piped commands, each segment is
126
+ * checked individually. Special exceptions allow `mkdir -p` for `.plans/` paths.
127
+ */
120
128
  export function isSafeCommand(command: string): boolean {
121
- const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
122
- const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
123
- return !isDestructive && isSafe;
129
+ // Special case: allow mkdir for .plans/ directory (planner needs this)
130
+ if (/^\s*mkdir\s+(-p\s+)?\.plans(\/|\\|\s|$)/.test(command)) {
131
+ return true;
132
+ }
133
+
134
+ // Split piped commands and check each segment independently
135
+ const segments = splitPipeSegments(command);
136
+ return segments.every((seg) => {
137
+ const trimmed = seg.trim();
138
+ const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(trimmed));
139
+ const isSafe = SAFE_PATTERNS.some((p) => p.test(trimmed));
140
+ return !isDestructive && isSafe;
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Split a command on unquoted pipe (`|`) characters.
146
+ * Respects single/double quotes and escaped characters.
147
+ */
148
+ function splitPipeSegments(command: string): string[] {
149
+ const segments: string[] = [];
150
+ let current = '';
151
+ let inSingle = false;
152
+ let inDouble = false;
153
+ let escaped = false;
154
+
155
+ for (const ch of command) {
156
+ if (escaped) {
157
+ current += ch;
158
+ escaped = false;
159
+ continue;
160
+ }
161
+ if (ch === '\\') {
162
+ current += ch;
163
+ escaped = true;
164
+ continue;
165
+ }
166
+ if (ch === "'" && !inDouble) {
167
+ inSingle = !inSingle;
168
+ current += ch;
169
+ continue;
170
+ }
171
+ if (ch === '"' && !inSingle) {
172
+ inDouble = !inDouble;
173
+ current += ch;
174
+ continue;
175
+ }
176
+ if (ch === '|' && !inSingle && !inDouble) {
177
+ segments.push(current);
178
+ current = '';
179
+ continue;
180
+ }
181
+ current += ch;
182
+ }
183
+ if (current) segments.push(current);
184
+ return segments;
124
185
  }
125
186
 
126
187
  // ── Plan extraction ─────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-plan-mode",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Two-phase planning workflow for pi — plan with claude-opus-4-6:medium, execute with gpt-5.5:low, with .plans/ file-based handoff",
5
5
  "keywords": [
6
6
  "pi-package"