@tianhai/pi-workflow-kit 0.8.1 → 0.8.3
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 -1
- package/docs/plans/2026-04-21-workflow-guard-safe-commands-design.md +172 -0
- package/docs/plans/2026-04-21-workflow-guard-safe-commands-implementation.md +168 -0
- package/docs/plans/completed/2026-04-21-workflow-guard-safe-commands-design.md +172 -0
- package/docs/plans/completed/2026-04-21-workflow-guard-safe-commands-implementation.md +168 -0
- package/docs/plans/completed/2026-04-22-go-readonly-safe-commands-implementation.md +54 -0
- package/extensions/workflow-guard.ts +37 -6
- package/package.json +2 -2
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` 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.
|
|
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`, `go doc`, `go list` remain available.
|
|
17
17
|
|
|
18
18
|
No configuration required. Skills and extensions activate automatically after install.
|
|
19
19
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Workflow Guard: Safe Commands Expansion
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-04-21
|
|
4
|
+
**Status:** Draft
|
|
5
|
+
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
The workflow guard blocks several read-only bash commands that are genuinely needed during brainstorm and plan phases. Two specific user reports:
|
|
9
|
+
|
|
10
|
+
1. `cd /path && git remote -v 2>/dev/null; echo "---"; ls` — blocked due to `cd` not being allowlisted and `2>/dev/null` caught by the stdout-redirect pattern.
|
|
11
|
+
2. `gh pr view 1564 --json ... 2>/dev/null || echo "gh failed"` — blocked because `gh` is not allowlisted at all.
|
|
12
|
+
|
|
13
|
+
Additionally, `git status --short` is blocked because the safe regex only allows `git status` without flags.
|
|
14
|
+
|
|
15
|
+
## Design
|
|
16
|
+
|
|
17
|
+
### 1. Harmless redirect stripping
|
|
18
|
+
|
|
19
|
+
Add a `stripHarmlessRedirects(cmd)` helper that removes `2>/dev/null` and `2>&1` before pattern matching. These are purely cosmetic (suppress stderr noise) and have no side effects.
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
function stripHarmlessRedirects(cmd: string): string {
|
|
23
|
+
return cmd.replace(/\s*2\s*>\s*(\/dev\/null|&1)\b/g, "");
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Apply it inside `isSafeCommand` on each sub-command before checking DESTRUCTIVE and SAFE patterns. This fixes `2>/dev/null` without loosening the redirect catch (which still blocks real writes).
|
|
28
|
+
|
|
29
|
+
### 2. New SAFE_PATTERNS entries
|
|
30
|
+
|
|
31
|
+
| Pattern | Rationale |
|
|
32
|
+
|---------|-----------|
|
|
33
|
+
| `/^\s*cd\b/` | Directory navigation — zero side effects |
|
|
34
|
+
| `/^\s*gh\s+pr\s+(view\|list\|diff\|checks\|status)\b/i` | Read-only PR inspection |
|
|
35
|
+
| `/^\s*gh\s+issue\s+(view\|list)\b/i` | Read-only issue inspection |
|
|
36
|
+
| `/^\s*gh\s+repo\s+(view\|fork\|list)\b/i` | Read-only repo metadata |
|
|
37
|
+
| `/^\s*gh\s+release\s+(view\|list\|download)\b/i` | Read-only release inspection |
|
|
38
|
+
| `/^\s*gh\s+run\s+(view\|list)\b/i` | Read-only CI run inspection |
|
|
39
|
+
| `/^\s*git\s+blame\b/` | Read-only file annotation |
|
|
40
|
+
| `/^\s*git\s+shortlog\b/` | Read-only commit summary |
|
|
41
|
+
| `/^\s*git\s+stash\s+list\b/i` | Read-only stash listing |
|
|
42
|
+
| `/^\s*git\s+tag\s+(-l\|--list)\b/i` | Read-only tag listing |
|
|
43
|
+
| `/^\s*git\s+describe\b/` | Read-only version info |
|
|
44
|
+
|
|
45
|
+
### 3. Fix: `git status` flag handling
|
|
46
|
+
|
|
47
|
+
Current regex `/^\s*git\s+(status|log|...)/i` doesn't allow common flags like `--short`, `--oneline`, `--format=...`. Refine all git safe patterns to optionally accept trailing flags and args:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
/^\s*git\s+status\b/i,
|
|
51
|
+
/^\s*git\s+log\b/i,
|
|
52
|
+
/^\s*git\s+diff\b/i,
|
|
53
|
+
/^\s*git\s+show\b/i,
|
|
54
|
+
/^\s*git\s+blame\b/i,
|
|
55
|
+
// etc.
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The existing patterns already anchor to `^\s*git\s+<subcommand>` — the issue was that `git status --short` didn't match because some patterns had more restrictive anchoring. Reviewing the code: the patterns use `\b` word boundaries which should allow flags. The actual issue with `git status --short && git log --oneline -5` is that the `git log --oneline -5` part is safe, but `git status --short` — let me verify: `/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i` — `git status` has a `\b` after the group? No, there's no trailing `\b`. So `git status --short` **should** match since the pattern doesn't require end-of-string. The real blocker for that compound command was the `&&` splitting — `git log --oneline -5` — `-5` shouldn't be an issue either.
|
|
59
|
+
|
|
60
|
+
**Conclusion on item 3:** The `git status --short` case was a false alarm caused by compound command parsing combined with the `2>/dev/null` redirect in the user's actual commands, not a pattern bug. No change needed here beyond the redirect fix.
|
|
61
|
+
|
|
62
|
+
### 4. What we're NOT adding (YAGNI)
|
|
63
|
+
|
|
64
|
+
- `sed -i` (in-place editing) — correctly destructive
|
|
65
|
+
- `gh pr create/merge/close` — write operations
|
|
66
|
+
- `curl -o file` (output to file) — the redirect catch blocks this
|
|
67
|
+
- `cut`, `tr`, `column`, `base64` — rarely needed; can add later on demand
|
|
68
|
+
- `gh api` — too broad; can be used for mutations. Require specific subcommands.
|
|
69
|
+
|
|
70
|
+
## Data flow
|
|
71
|
+
|
|
72
|
+
No new data flow. The changes are purely additive to the pattern-matching logic in `isSafeCommand`.
|
|
73
|
+
|
|
74
|
+
## Error handling
|
|
75
|
+
|
|
76
|
+
No new error paths. The existing block-and-warn behavior remains unchanged.
|
|
77
|
+
|
|
78
|
+
## Testing
|
|
79
|
+
|
|
80
|
+
Manual verification with the exact commands that were blocked:
|
|
81
|
+
|
|
82
|
+
1. `cd /some/path && git remote -v 2>/dev/null; echo "---"; ls` → allowed
|
|
83
|
+
2. `gh pr view 1564 --repo owner/repo --json title,body,files 2>/dev/null || echo "gh failed"` → allowed
|
|
84
|
+
3. `git stash list` → allowed
|
|
85
|
+
4. `git tag -l` → allowed
|
|
86
|
+
5. `rm -rf /` → still blocked ✓
|
|
87
|
+
6. `git push origin main` → still blocked ✓
|
|
88
|
+
|
|
89
|
+
## Tests
|
|
90
|
+
|
|
91
|
+
Add the following test cases to the existing `tests/workflow-guard.test.ts` `isSafeCommand` describe block:
|
|
92
|
+
|
|
93
|
+
### New: `cd` navigation
|
|
94
|
+
```ts
|
|
95
|
+
it("allows cd", () => {
|
|
96
|
+
expect(isSafeCommand("cd /some/path")).toBe(true);
|
|
97
|
+
expect(isSafeCommand("cd src && ls")).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### New: GitHub CLI read-only subcommands
|
|
102
|
+
```ts
|
|
103
|
+
it("allows gh read-only subcommands", () => {
|
|
104
|
+
expect(isSafeCommand("gh pr view 1564 --json title,body")).toBe(true);
|
|
105
|
+
expect(isSafeCommand("gh pr list --repo owner/repo")).toBe(true);
|
|
106
|
+
expect(isSafeCommand("gh pr diff 1564")).toBe(true);
|
|
107
|
+
expect(isSafeCommand("gh issue view 42")).toBe(true);
|
|
108
|
+
expect(isSafeCommand("gh issue list --label bug")).toBe(true);
|
|
109
|
+
expect(isSafeCommand("gh repo view owner/repo")).toBe(true);
|
|
110
|
+
expect(isSafeCommand("gh run view 12345")).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("blocks gh write subcommands", () => {
|
|
114
|
+
expect(isSafeCommand("gh pr create --title 'fix'")).toBe(false);
|
|
115
|
+
expect(isSafeCommand("gh pr merge 1564")).toBe(false);
|
|
116
|
+
expect(isSafeCommand("gh issue close 42")).toBe(false);
|
|
117
|
+
expect(isSafeCommand("gh release create v1.0")).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### New: Git read-only subcommands
|
|
122
|
+
```ts
|
|
123
|
+
it("allows git read-only subcommands (new additions)", () => {
|
|
124
|
+
expect(isSafeCommand("git blame src/index.ts")).toBe(true);
|
|
125
|
+
expect(isSafeCommand("git shortlog -sn")).toBe(true);
|
|
126
|
+
expect(isSafeCommand("git stash list")).toBe(true);
|
|
127
|
+
expect(isSafeCommand("git tag -l")).toBe(true);
|
|
128
|
+
expect(isSafeCommand("git tag --list 'v*'")).toBe(true);
|
|
129
|
+
expect(isSafeCommand("git describe --tags")).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("still blocks git stash mutations", () => {
|
|
133
|
+
expect(isSafeCommand("git stash push -m 'wip'")).toBe(false);
|
|
134
|
+
expect(isSafeCommand("git stash pop")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### New: Harmless stderr redirect stripping
|
|
139
|
+
```ts
|
|
140
|
+
it("allows 2>/dev/null on safe commands", () => {
|
|
141
|
+
expect(isSafeCommand("git remote -v 2>/dev/null")).toBe(true);
|
|
142
|
+
expect(isSafeCommand("gh pr view 1564 2>/dev/null")).toBe(true);
|
|
143
|
+
expect(isSafeCommand("npm list 2>/dev/null")).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("allows 2>&1 on safe commands", () => {
|
|
147
|
+
expect(isSafeCommand("git log 2>&1")).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("still blocks stdout redirects even with stderr redirect present", () => {
|
|
151
|
+
expect(isSafeCommand("echo 'hello' > file.ts 2>/dev/null")).toBe(false);
|
|
152
|
+
expect(isSafeCommand("cat config > backup.txt 2>/dev/null")).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### New: Compound commands from real user scenarios
|
|
157
|
+
```ts
|
|
158
|
+
it("allows the exact user-reported blocked commands", () => {
|
|
159
|
+
// Scenario 1: directory navigation + git remote + ls
|
|
160
|
+
expect(isSafeCommand("cd /Users/u/partying/pt-room && git remote -v 2>/dev/null; echo '---'; ls")).toBe(true);
|
|
161
|
+
// Scenario 2: gh pr view with fallback
|
|
162
|
+
expect(isSafeCommand("gh pr view 1564 --repo olachat/pt-partying --json title,body,files,additions,deletions 2>/dev/null || echo 'gh failed'")).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Summary
|
|
167
|
+
|
|
168
|
+
| Change | Location | Size |
|
|
169
|
+
|--------|----------|------|
|
|
170
|
+
| Add `stripHarmlessRedirects()` | Above `isSafeCommand` | ~3 lines |
|
|
171
|
+
| Call it in `isSafeCommand` loop body | Inside `isSafeCommand` | 1 line changed |
|
|
172
|
+
| Add 10 new SAFE_PATTERNS entries | `SAFE_PATTERNS` array | ~10 lines |
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Workflow Guard: Safe Commands Expansion — Implementation Plan
|
|
2
|
+
|
|
3
|
+
**Design:** `docs/plans/2026-04-21-workflow-guard-safe-commands-design.md`
|
|
4
|
+
**Date:** 2026-04-21
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Task 1: Add `stripHarmlessRedirects` helper and wire it into `isSafeCommand`
|
|
9
|
+
|
|
10
|
+
**Scenario:** Modifying tested code
|
|
11
|
+
**File:** `extensions/workflow-guard.ts`
|
|
12
|
+
|
|
13
|
+
1. Run existing tests to confirm baseline:
|
|
14
|
+
```bash
|
|
15
|
+
npx vitest run tests/workflow-guard.test.ts
|
|
16
|
+
```
|
|
17
|
+
Expected: all pass.
|
|
18
|
+
|
|
19
|
+
2. Add `stripHarmlessRedirects` function above `isSafeCommand`:
|
|
20
|
+
```ts
|
|
21
|
+
/** Strip stderr redirects that are purely cosmetic (no side effects). */
|
|
22
|
+
function stripHarmlessRedirects(cmd: string): string {
|
|
23
|
+
return cmd.replace(/\s*2\s*>\s*(\/dev\/null|&1)\b/g, "");
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
3. Wire it into `isSafeCommand` — apply `stripHarmlessRedirects` to each part before pattern matching:
|
|
28
|
+
```ts
|
|
29
|
+
export function isSafeCommand(command: string): boolean {
|
|
30
|
+
const parts = splitCompoundCommand(command);
|
|
31
|
+
return parts.every((part) => {
|
|
32
|
+
const cleaned = stripHarmlessRedirects(part);
|
|
33
|
+
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(cleaned));
|
|
34
|
+
const isSafe = SAFE_PATTERNS.some((p) => p.test(cleaned));
|
|
35
|
+
return !isDestructive && isSafe;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
4. Run tests:
|
|
41
|
+
```bash
|
|
42
|
+
npx vitest run tests/workflow-guard.test.ts
|
|
43
|
+
```
|
|
44
|
+
Expected: all existing tests still pass (no behavior change yet since no new SAFE_PATTERNS).
|
|
45
|
+
|
|
46
|
+
5. Commit:
|
|
47
|
+
```bash
|
|
48
|
+
git add extensions/workflow-guard.ts
|
|
49
|
+
git commit -m "feat(workflow-guard): add stripHarmlessRedirects helper"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Task 2: Add new SAFE_PATTERNS entries
|
|
55
|
+
|
|
56
|
+
**Scenario:** Modifying tested code
|
|
57
|
+
**File:** `extensions/workflow-guard.ts`
|
|
58
|
+
|
|
59
|
+
1. Add the following entries to the `SAFE_PATTERNS` array (after the existing `gh`-related area or at end):
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
/^\s*cd\b/,
|
|
63
|
+
/^\s*gh\s+pr\s+(view|list|diff|checks|status)\b/i,
|
|
64
|
+
/^\s*gh\s+issue\s+(view|list)\b/i,
|
|
65
|
+
/^\s*gh\s+repo\s+(view|fork|list)\b/i,
|
|
66
|
+
/^\s*gh\s+release\s+(view|list|download)\b/i,
|
|
67
|
+
/^\s*gh\s+run\s+(view|list)\b/i,
|
|
68
|
+
/^\s*git\s+blame\b/,
|
|
69
|
+
/^\s*git\s+shortlog\b/,
|
|
70
|
+
/^\s*git\s+stash\s+list\b/i,
|
|
71
|
+
/^\s*git\s+tag\s+(-l|--list)\b/i,
|
|
72
|
+
/^\s*git\s+describe\b/,
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
2. Run tests:
|
|
76
|
+
```bash
|
|
77
|
+
npx vitest run tests/workflow-guard.test.ts
|
|
78
|
+
```
|
|
79
|
+
Expected: all existing tests pass.
|
|
80
|
+
|
|
81
|
+
3. Commit:
|
|
82
|
+
```bash
|
|
83
|
+
git add extensions/workflow-guard.ts
|
|
84
|
+
git commit -m "feat(workflow-guard): add safe patterns for cd, gh, and git read-only subcommands"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Task 3: Add tests for `cd`, `gh`, git new subcommands, and redirect stripping
|
|
90
|
+
|
|
91
|
+
**Scenario:** New feature (test-first)
|
|
92
|
+
**File:** `tests/workflow-guard.test.ts`
|
|
93
|
+
|
|
94
|
+
**checkpoint: test** — pause after writing failing tests, before implementation.
|
|
95
|
+
|
|
96
|
+
> Note: Implementation was already done in Tasks 1–2. These tests should all pass immediately. The checkpoint label is kept for review purposes in case the user wants to verify test design.
|
|
97
|
+
|
|
98
|
+
1. Add the following test blocks inside the `describe("isSafeCommand", ...)` block, after the existing tests:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
it("allows cd", () => {
|
|
102
|
+
expect(isSafeCommand("cd /some/path")).toBe(true);
|
|
103
|
+
expect(isSafeCommand("cd src && ls")).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("allows gh read-only subcommands", () => {
|
|
107
|
+
expect(isSafeCommand("gh pr view 1564 --json title,body")).toBe(true);
|
|
108
|
+
expect(isSafeCommand("gh pr list --repo owner/repo")).toBe(true);
|
|
109
|
+
expect(isSafeCommand("gh pr diff 1564")).toBe(true);
|
|
110
|
+
expect(isSafeCommand("gh issue view 42")).toBe(true);
|
|
111
|
+
expect(isSafeCommand("gh issue list --label bug")).toBe(true);
|
|
112
|
+
expect(isSafeCommand("gh repo view owner/repo")).toBe(true);
|
|
113
|
+
expect(isSafeCommand("gh run view 12345")).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("blocks gh write subcommands", () => {
|
|
117
|
+
expect(isSafeCommand("gh pr create --title 'fix'")).toBe(false);
|
|
118
|
+
expect(isSafeCommand("gh pr merge 1564")).toBe(false);
|
|
119
|
+
expect(isSafeCommand("gh issue close 42")).toBe(false);
|
|
120
|
+
expect(isSafeCommand("gh release create v1.0")).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("allows git read-only subcommands (new additions)", () => {
|
|
124
|
+
expect(isSafeCommand("git blame src/index.ts")).toBe(true);
|
|
125
|
+
expect(isSafeCommand("git shortlog -sn")).toBe(true);
|
|
126
|
+
expect(isSafeCommand("git stash list")).toBe(true);
|
|
127
|
+
expect(isSafeCommand("git tag -l")).toBe(true);
|
|
128
|
+
expect(isSafeCommand("git tag --list 'v*'")).toBe(true);
|
|
129
|
+
expect(isSafeCommand("git describe --tags")).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("still blocks git stash mutations", () => {
|
|
133
|
+
expect(isSafeCommand("git stash push -m 'wip'")).toBe(false);
|
|
134
|
+
expect(isSafeCommand("git stash pop")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("allows 2>/dev/null on safe commands", () => {
|
|
138
|
+
expect(isSafeCommand("git remote -v 2>/dev/null")).toBe(true);
|
|
139
|
+
expect(isSafeCommand("gh pr view 1564 2>/dev/null")).toBe(true);
|
|
140
|
+
expect(isSafeCommand("npm list 2>/dev/null")).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("allows 2>&1 on safe commands", () => {
|
|
144
|
+
expect(isSafeCommand("git log 2>&1")).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("still blocks stdout redirects even with stderr redirect present", () => {
|
|
148
|
+
expect(isSafeCommand("echo 'hello' > file.ts 2>/dev/null")).toBe(false);
|
|
149
|
+
expect(isSafeCommand("cat config > backup.txt 2>/dev/null")).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("allows the exact user-reported blocked commands", () => {
|
|
153
|
+
expect(isSafeCommand("cd /Users/u/partying/pt-room && git remote -v 2>/dev/null; echo '---'; ls")).toBe(true);
|
|
154
|
+
expect(isSafeCommand("gh pr view 1564 --repo olachat/pt-partying --json title,body,files,additions,deletions 2>/dev/null || echo 'gh failed'")).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
2. Run tests:
|
|
159
|
+
```bash
|
|
160
|
+
npx vitest run tests/workflow-guard.test.ts
|
|
161
|
+
```
|
|
162
|
+
Expected: all tests pass (including new ones).
|
|
163
|
+
|
|
164
|
+
3. Commit:
|
|
165
|
+
```bash
|
|
166
|
+
git add tests/workflow-guard.test.ts
|
|
167
|
+
git commit -m "test(workflow-guard): add tests for cd, gh, git read-only subcommands, and redirect stripping"
|
|
168
|
+
```
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Workflow Guard: Safe Commands Expansion
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-04-21
|
|
4
|
+
**Status:** Draft
|
|
5
|
+
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
The workflow guard blocks several read-only bash commands that are genuinely needed during brainstorm and plan phases. Two specific user reports:
|
|
9
|
+
|
|
10
|
+
1. `cd /path && git remote -v 2>/dev/null; echo "---"; ls` — blocked due to `cd` not being allowlisted and `2>/dev/null` caught by the stdout-redirect pattern.
|
|
11
|
+
2. `gh pr view 1564 --json ... 2>/dev/null || echo "gh failed"` — blocked because `gh` is not allowlisted at all.
|
|
12
|
+
|
|
13
|
+
Additionally, `git status --short` is blocked because the safe regex only allows `git status` without flags.
|
|
14
|
+
|
|
15
|
+
## Design
|
|
16
|
+
|
|
17
|
+
### 1. Harmless redirect stripping
|
|
18
|
+
|
|
19
|
+
Add a `stripHarmlessRedirects(cmd)` helper that removes `2>/dev/null` and `2>&1` before pattern matching. These are purely cosmetic (suppress stderr noise) and have no side effects.
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
function stripHarmlessRedirects(cmd: string): string {
|
|
23
|
+
return cmd.replace(/\s*2\s*>\s*(\/dev\/null|&1)\b/g, "");
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Apply it inside `isSafeCommand` on each sub-command before checking DESTRUCTIVE and SAFE patterns. This fixes `2>/dev/null` without loosening the redirect catch (which still blocks real writes).
|
|
28
|
+
|
|
29
|
+
### 2. New SAFE_PATTERNS entries
|
|
30
|
+
|
|
31
|
+
| Pattern | Rationale |
|
|
32
|
+
|---------|-----------|
|
|
33
|
+
| `/^\s*cd\b/` | Directory navigation — zero side effects |
|
|
34
|
+
| `/^\s*gh\s+pr\s+(view\|list\|diff\|checks\|status)\b/i` | Read-only PR inspection |
|
|
35
|
+
| `/^\s*gh\s+issue\s+(view\|list)\b/i` | Read-only issue inspection |
|
|
36
|
+
| `/^\s*gh\s+repo\s+(view\|fork\|list)\b/i` | Read-only repo metadata |
|
|
37
|
+
| `/^\s*gh\s+release\s+(view\|list\|download)\b/i` | Read-only release inspection |
|
|
38
|
+
| `/^\s*gh\s+run\s+(view\|list)\b/i` | Read-only CI run inspection |
|
|
39
|
+
| `/^\s*git\s+blame\b/` | Read-only file annotation |
|
|
40
|
+
| `/^\s*git\s+shortlog\b/` | Read-only commit summary |
|
|
41
|
+
| `/^\s*git\s+stash\s+list\b/i` | Read-only stash listing |
|
|
42
|
+
| `/^\s*git\s+tag\s+(-l\|--list)\b/i` | Read-only tag listing |
|
|
43
|
+
| `/^\s*git\s+describe\b/` | Read-only version info |
|
|
44
|
+
|
|
45
|
+
### 3. Fix: `git status` flag handling
|
|
46
|
+
|
|
47
|
+
Current regex `/^\s*git\s+(status|log|...)/i` doesn't allow common flags like `--short`, `--oneline`, `--format=...`. Refine all git safe patterns to optionally accept trailing flags and args:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
/^\s*git\s+status\b/i,
|
|
51
|
+
/^\s*git\s+log\b/i,
|
|
52
|
+
/^\s*git\s+diff\b/i,
|
|
53
|
+
/^\s*git\s+show\b/i,
|
|
54
|
+
/^\s*git\s+blame\b/i,
|
|
55
|
+
// etc.
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The existing patterns already anchor to `^\s*git\s+<subcommand>` — the issue was that `git status --short` didn't match because some patterns had more restrictive anchoring. Reviewing the code: the patterns use `\b` word boundaries which should allow flags. The actual issue with `git status --short && git log --oneline -5` is that the `git log --oneline -5` part is safe, but `git status --short` — let me verify: `/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i` — `git status` has a `\b` after the group? No, there's no trailing `\b`. So `git status --short` **should** match since the pattern doesn't require end-of-string. The real blocker for that compound command was the `&&` splitting — `git log --oneline -5` — `-5` shouldn't be an issue either.
|
|
59
|
+
|
|
60
|
+
**Conclusion on item 3:** The `git status --short` case was a false alarm caused by compound command parsing combined with the `2>/dev/null` redirect in the user's actual commands, not a pattern bug. No change needed here beyond the redirect fix.
|
|
61
|
+
|
|
62
|
+
### 4. What we're NOT adding (YAGNI)
|
|
63
|
+
|
|
64
|
+
- `sed -i` (in-place editing) — correctly destructive
|
|
65
|
+
- `gh pr create/merge/close` — write operations
|
|
66
|
+
- `curl -o file` (output to file) — the redirect catch blocks this
|
|
67
|
+
- `cut`, `tr`, `column`, `base64` — rarely needed; can add later on demand
|
|
68
|
+
- `gh api` — too broad; can be used for mutations. Require specific subcommands.
|
|
69
|
+
|
|
70
|
+
## Data flow
|
|
71
|
+
|
|
72
|
+
No new data flow. The changes are purely additive to the pattern-matching logic in `isSafeCommand`.
|
|
73
|
+
|
|
74
|
+
## Error handling
|
|
75
|
+
|
|
76
|
+
No new error paths. The existing block-and-warn behavior remains unchanged.
|
|
77
|
+
|
|
78
|
+
## Testing
|
|
79
|
+
|
|
80
|
+
Manual verification with the exact commands that were blocked:
|
|
81
|
+
|
|
82
|
+
1. `cd /some/path && git remote -v 2>/dev/null; echo "---"; ls` → allowed
|
|
83
|
+
2. `gh pr view 1564 --repo owner/repo --json title,body,files 2>/dev/null || echo "gh failed"` → allowed
|
|
84
|
+
3. `git stash list` → allowed
|
|
85
|
+
4. `git tag -l` → allowed
|
|
86
|
+
5. `rm -rf /` → still blocked ✓
|
|
87
|
+
6. `git push origin main` → still blocked ✓
|
|
88
|
+
|
|
89
|
+
## Tests
|
|
90
|
+
|
|
91
|
+
Add the following test cases to the existing `tests/workflow-guard.test.ts` `isSafeCommand` describe block:
|
|
92
|
+
|
|
93
|
+
### New: `cd` navigation
|
|
94
|
+
```ts
|
|
95
|
+
it("allows cd", () => {
|
|
96
|
+
expect(isSafeCommand("cd /some/path")).toBe(true);
|
|
97
|
+
expect(isSafeCommand("cd src && ls")).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### New: GitHub CLI read-only subcommands
|
|
102
|
+
```ts
|
|
103
|
+
it("allows gh read-only subcommands", () => {
|
|
104
|
+
expect(isSafeCommand("gh pr view 1564 --json title,body")).toBe(true);
|
|
105
|
+
expect(isSafeCommand("gh pr list --repo owner/repo")).toBe(true);
|
|
106
|
+
expect(isSafeCommand("gh pr diff 1564")).toBe(true);
|
|
107
|
+
expect(isSafeCommand("gh issue view 42")).toBe(true);
|
|
108
|
+
expect(isSafeCommand("gh issue list --label bug")).toBe(true);
|
|
109
|
+
expect(isSafeCommand("gh repo view owner/repo")).toBe(true);
|
|
110
|
+
expect(isSafeCommand("gh run view 12345")).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("blocks gh write subcommands", () => {
|
|
114
|
+
expect(isSafeCommand("gh pr create --title 'fix'")).toBe(false);
|
|
115
|
+
expect(isSafeCommand("gh pr merge 1564")).toBe(false);
|
|
116
|
+
expect(isSafeCommand("gh issue close 42")).toBe(false);
|
|
117
|
+
expect(isSafeCommand("gh release create v1.0")).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### New: Git read-only subcommands
|
|
122
|
+
```ts
|
|
123
|
+
it("allows git read-only subcommands (new additions)", () => {
|
|
124
|
+
expect(isSafeCommand("git blame src/index.ts")).toBe(true);
|
|
125
|
+
expect(isSafeCommand("git shortlog -sn")).toBe(true);
|
|
126
|
+
expect(isSafeCommand("git stash list")).toBe(true);
|
|
127
|
+
expect(isSafeCommand("git tag -l")).toBe(true);
|
|
128
|
+
expect(isSafeCommand("git tag --list 'v*'")).toBe(true);
|
|
129
|
+
expect(isSafeCommand("git describe --tags")).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("still blocks git stash mutations", () => {
|
|
133
|
+
expect(isSafeCommand("git stash push -m 'wip'")).toBe(false);
|
|
134
|
+
expect(isSafeCommand("git stash pop")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### New: Harmless stderr redirect stripping
|
|
139
|
+
```ts
|
|
140
|
+
it("allows 2>/dev/null on safe commands", () => {
|
|
141
|
+
expect(isSafeCommand("git remote -v 2>/dev/null")).toBe(true);
|
|
142
|
+
expect(isSafeCommand("gh pr view 1564 2>/dev/null")).toBe(true);
|
|
143
|
+
expect(isSafeCommand("npm list 2>/dev/null")).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("allows 2>&1 on safe commands", () => {
|
|
147
|
+
expect(isSafeCommand("git log 2>&1")).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("still blocks stdout redirects even with stderr redirect present", () => {
|
|
151
|
+
expect(isSafeCommand("echo 'hello' > file.ts 2>/dev/null")).toBe(false);
|
|
152
|
+
expect(isSafeCommand("cat config > backup.txt 2>/dev/null")).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### New: Compound commands from real user scenarios
|
|
157
|
+
```ts
|
|
158
|
+
it("allows the exact user-reported blocked commands", () => {
|
|
159
|
+
// Scenario 1: directory navigation + git remote + ls
|
|
160
|
+
expect(isSafeCommand("cd /Users/u/partying/pt-room && git remote -v 2>/dev/null; echo '---'; ls")).toBe(true);
|
|
161
|
+
// Scenario 2: gh pr view with fallback
|
|
162
|
+
expect(isSafeCommand("gh pr view 1564 --repo olachat/pt-partying --json title,body,files,additions,deletions 2>/dev/null || echo 'gh failed'")).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Summary
|
|
167
|
+
|
|
168
|
+
| Change | Location | Size |
|
|
169
|
+
|--------|----------|------|
|
|
170
|
+
| Add `stripHarmlessRedirects()` | Above `isSafeCommand` | ~3 lines |
|
|
171
|
+
| Call it in `isSafeCommand` loop body | Inside `isSafeCommand` | 1 line changed |
|
|
172
|
+
| Add 10 new SAFE_PATTERNS entries | `SAFE_PATTERNS` array | ~10 lines |
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Workflow Guard: Safe Commands Expansion — Implementation Plan
|
|
2
|
+
|
|
3
|
+
**Design:** `docs/plans/2026-04-21-workflow-guard-safe-commands-design.md`
|
|
4
|
+
**Date:** 2026-04-21
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Task 1: Add `stripHarmlessRedirects` helper and wire it into `isSafeCommand`
|
|
9
|
+
|
|
10
|
+
**Scenario:** Modifying tested code
|
|
11
|
+
**File:** `extensions/workflow-guard.ts`
|
|
12
|
+
|
|
13
|
+
1. Run existing tests to confirm baseline:
|
|
14
|
+
```bash
|
|
15
|
+
npx vitest run tests/workflow-guard.test.ts
|
|
16
|
+
```
|
|
17
|
+
Expected: all pass.
|
|
18
|
+
|
|
19
|
+
2. Add `stripHarmlessRedirects` function above `isSafeCommand`:
|
|
20
|
+
```ts
|
|
21
|
+
/** Strip stderr redirects that are purely cosmetic (no side effects). */
|
|
22
|
+
function stripHarmlessRedirects(cmd: string): string {
|
|
23
|
+
return cmd.replace(/\s*2\s*>\s*(\/dev\/null|&1)\b/g, "");
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
3. Wire it into `isSafeCommand` — apply `stripHarmlessRedirects` to each part before pattern matching:
|
|
28
|
+
```ts
|
|
29
|
+
export function isSafeCommand(command: string): boolean {
|
|
30
|
+
const parts = splitCompoundCommand(command);
|
|
31
|
+
return parts.every((part) => {
|
|
32
|
+
const cleaned = stripHarmlessRedirects(part);
|
|
33
|
+
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(cleaned));
|
|
34
|
+
const isSafe = SAFE_PATTERNS.some((p) => p.test(cleaned));
|
|
35
|
+
return !isDestructive && isSafe;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
4. Run tests:
|
|
41
|
+
```bash
|
|
42
|
+
npx vitest run tests/workflow-guard.test.ts
|
|
43
|
+
```
|
|
44
|
+
Expected: all existing tests still pass (no behavior change yet since no new SAFE_PATTERNS).
|
|
45
|
+
|
|
46
|
+
5. Commit:
|
|
47
|
+
```bash
|
|
48
|
+
git add extensions/workflow-guard.ts
|
|
49
|
+
git commit -m "feat(workflow-guard): add stripHarmlessRedirects helper"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Task 2: Add new SAFE_PATTERNS entries
|
|
55
|
+
|
|
56
|
+
**Scenario:** Modifying tested code
|
|
57
|
+
**File:** `extensions/workflow-guard.ts`
|
|
58
|
+
|
|
59
|
+
1. Add the following entries to the `SAFE_PATTERNS` array (after the existing `gh`-related area or at end):
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
/^\s*cd\b/,
|
|
63
|
+
/^\s*gh\s+pr\s+(view|list|diff|checks|status)\b/i,
|
|
64
|
+
/^\s*gh\s+issue\s+(view|list)\b/i,
|
|
65
|
+
/^\s*gh\s+repo\s+(view|fork|list)\b/i,
|
|
66
|
+
/^\s*gh\s+release\s+(view|list|download)\b/i,
|
|
67
|
+
/^\s*gh\s+run\s+(view|list)\b/i,
|
|
68
|
+
/^\s*git\s+blame\b/,
|
|
69
|
+
/^\s*git\s+shortlog\b/,
|
|
70
|
+
/^\s*git\s+stash\s+list\b/i,
|
|
71
|
+
/^\s*git\s+tag\s+(-l|--list)\b/i,
|
|
72
|
+
/^\s*git\s+describe\b/,
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
2. Run tests:
|
|
76
|
+
```bash
|
|
77
|
+
npx vitest run tests/workflow-guard.test.ts
|
|
78
|
+
```
|
|
79
|
+
Expected: all existing tests pass.
|
|
80
|
+
|
|
81
|
+
3. Commit:
|
|
82
|
+
```bash
|
|
83
|
+
git add extensions/workflow-guard.ts
|
|
84
|
+
git commit -m "feat(workflow-guard): add safe patterns for cd, gh, and git read-only subcommands"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Task 3: Add tests for `cd`, `gh`, git new subcommands, and redirect stripping
|
|
90
|
+
|
|
91
|
+
**Scenario:** New feature (test-first)
|
|
92
|
+
**File:** `tests/workflow-guard.test.ts`
|
|
93
|
+
|
|
94
|
+
**checkpoint: test** — pause after writing failing tests, before implementation.
|
|
95
|
+
|
|
96
|
+
> Note: Implementation was already done in Tasks 1–2. These tests should all pass immediately. The checkpoint label is kept for review purposes in case the user wants to verify test design.
|
|
97
|
+
|
|
98
|
+
1. Add the following test blocks inside the `describe("isSafeCommand", ...)` block, after the existing tests:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
it("allows cd", () => {
|
|
102
|
+
expect(isSafeCommand("cd /some/path")).toBe(true);
|
|
103
|
+
expect(isSafeCommand("cd src && ls")).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("allows gh read-only subcommands", () => {
|
|
107
|
+
expect(isSafeCommand("gh pr view 1564 --json title,body")).toBe(true);
|
|
108
|
+
expect(isSafeCommand("gh pr list --repo owner/repo")).toBe(true);
|
|
109
|
+
expect(isSafeCommand("gh pr diff 1564")).toBe(true);
|
|
110
|
+
expect(isSafeCommand("gh issue view 42")).toBe(true);
|
|
111
|
+
expect(isSafeCommand("gh issue list --label bug")).toBe(true);
|
|
112
|
+
expect(isSafeCommand("gh repo view owner/repo")).toBe(true);
|
|
113
|
+
expect(isSafeCommand("gh run view 12345")).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("blocks gh write subcommands", () => {
|
|
117
|
+
expect(isSafeCommand("gh pr create --title 'fix'")).toBe(false);
|
|
118
|
+
expect(isSafeCommand("gh pr merge 1564")).toBe(false);
|
|
119
|
+
expect(isSafeCommand("gh issue close 42")).toBe(false);
|
|
120
|
+
expect(isSafeCommand("gh release create v1.0")).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("allows git read-only subcommands (new additions)", () => {
|
|
124
|
+
expect(isSafeCommand("git blame src/index.ts")).toBe(true);
|
|
125
|
+
expect(isSafeCommand("git shortlog -sn")).toBe(true);
|
|
126
|
+
expect(isSafeCommand("git stash list")).toBe(true);
|
|
127
|
+
expect(isSafeCommand("git tag -l")).toBe(true);
|
|
128
|
+
expect(isSafeCommand("git tag --list 'v*'")).toBe(true);
|
|
129
|
+
expect(isSafeCommand("git describe --tags")).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("still blocks git stash mutations", () => {
|
|
133
|
+
expect(isSafeCommand("git stash push -m 'wip'")).toBe(false);
|
|
134
|
+
expect(isSafeCommand("git stash pop")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("allows 2>/dev/null on safe commands", () => {
|
|
138
|
+
expect(isSafeCommand("git remote -v 2>/dev/null")).toBe(true);
|
|
139
|
+
expect(isSafeCommand("gh pr view 1564 2>/dev/null")).toBe(true);
|
|
140
|
+
expect(isSafeCommand("npm list 2>/dev/null")).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("allows 2>&1 on safe commands", () => {
|
|
144
|
+
expect(isSafeCommand("git log 2>&1")).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("still blocks stdout redirects even with stderr redirect present", () => {
|
|
148
|
+
expect(isSafeCommand("echo 'hello' > file.ts 2>/dev/null")).toBe(false);
|
|
149
|
+
expect(isSafeCommand("cat config > backup.txt 2>/dev/null")).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("allows the exact user-reported blocked commands", () => {
|
|
153
|
+
expect(isSafeCommand("cd /Users/u/partying/pt-room && git remote -v 2>/dev/null; echo '---'; ls")).toBe(true);
|
|
154
|
+
expect(isSafeCommand("gh pr view 1564 --repo olachat/pt-partying --json title,body,files,additions,deletions 2>/dev/null || echo 'gh failed'")).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
2. Run tests:
|
|
159
|
+
```bash
|
|
160
|
+
npx vitest run tests/workflow-guard.test.ts
|
|
161
|
+
```
|
|
162
|
+
Expected: all tests pass (including new ones).
|
|
163
|
+
|
|
164
|
+
3. Commit:
|
|
165
|
+
```bash
|
|
166
|
+
git add tests/workflow-guard.test.ts
|
|
167
|
+
git commit -m "test(workflow-guard): add tests for cd, gh, git read-only subcommands, and redirect stripping"
|
|
168
|
+
```
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Add Go read-only commands to workflow-guard safe list
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
Go toolchain read-only commands (`go doc`, `go list`, `go version`, `go env`) are blocked during brainstorm/plan phases because they're not in `SAFE_PATTERNS`. These are purely read-only with no side effects and are commonly needed during code exploration.
|
|
6
|
+
|
|
7
|
+
## Tasks
|
|
8
|
+
|
|
9
|
+
### 1 — Add Go safe patterns [Modifying tested code]
|
|
10
|
+
|
|
11
|
+
**File:** `extensions/workflow-guard.ts`
|
|
12
|
+
|
|
13
|
+
Add four entries to `SAFE_PATTERNS`, after the `git describe` entry:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
/^\s*go\s+doc\b/,
|
|
17
|
+
/^\s*go\s+list\b/,
|
|
18
|
+
/^\s*go\s+version\b/,
|
|
19
|
+
/^\s*go\s+env\b/,
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Verify:** run `npx vitest run tests/workflow-guard.test.ts` — all existing tests should pass.
|
|
23
|
+
|
|
24
|
+
**Commit:** `feat(workflow-guard): add Go read-only commands to safe list`
|
|
25
|
+
|
|
26
|
+
### 2 — Add tests for Go safe commands [New feature]
|
|
27
|
+
|
|
28
|
+
**File:** `tests/workflow-guard.test.ts`
|
|
29
|
+
|
|
30
|
+
Add a new `it` block inside the `describe("isSafeCommand", ...)` suite:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
it("allows go read-only subcommands", () => {
|
|
34
|
+
expect(isSafeCommand("go doc go.opentelemetry.io/otel/label")).toBe(true);
|
|
35
|
+
expect(isSafeCommand("go doc go.opentelemetry.io/otel/codes 2>&1 | head -20")).toBe(true);
|
|
36
|
+
expect(isSafeCommand("go list -m -versions go.opentelemetry.io/otel 2>&1 | tr ' ' '\\n' | grep -E '^v1\\\\.(2[89]|[3-9][0-9])' | head -20")).toBe(true);
|
|
37
|
+
expect(isSafeCommand("go version")).toBe(true);
|
|
38
|
+
expect(isSafeCommand("go env GOOS GOARCH")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Also add a `go build` block test to ensure write-oriented Go commands stay blocked:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
it("blocks go write subcommands", () => {
|
|
46
|
+
expect(isSafeCommand("go build ./...")).toBe(false);
|
|
47
|
+
expect(isSafeCommand("go install golang.org/x/tools/gopls@latest")).toBe(false);
|
|
48
|
+
expect(isSafeCommand("go mod tidy")).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Verify:** run `npx vitest run tests/workflow-guard.test.ts` — all tests pass.
|
|
53
|
+
|
|
54
|
+
**Commit:** `test(workflow-guard): add tests for Go read-only safe commands`
|
|
@@ -35,7 +35,7 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
35
35
|
/\bpip\s+(install|uninstall)/i,
|
|
36
36
|
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
|
37
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,
|
|
38
|
+
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash(?!\s+list)|cherry-pick|revert|tag(?!\s+(-l|--list))|init|clone)/i,
|
|
39
39
|
/\bsudo\b/i,
|
|
40
40
|
/\bsu\b/i,
|
|
41
41
|
/\bkill\b/i,
|
|
@@ -99,6 +99,21 @@ const SAFE_PATTERNS = [
|
|
|
99
99
|
/^\s*fd\b/,
|
|
100
100
|
/^\s*bat\b/,
|
|
101
101
|
/^\s*eza\b/,
|
|
102
|
+
/^\s*cd\b/,
|
|
103
|
+
/^\s*gh\s+pr\s+(view|list|diff|checks|status)\b/i,
|
|
104
|
+
/^\s*gh\s+issue\s+(view|list)\b/i,
|
|
105
|
+
/^\s*gh\s+repo\s+(view|fork|list)\b/i,
|
|
106
|
+
/^\s*gh\s+release\s+(view|list|download)\b/i,
|
|
107
|
+
/^\s*gh\s+run\s+(view|list)\b/i,
|
|
108
|
+
/^\s*git\s+blame\b/,
|
|
109
|
+
/^\s*git\s+shortlog\b/,
|
|
110
|
+
/^\s*git\s+stash\s+list\b/i,
|
|
111
|
+
/^\s*git\s+tag\s+(-l|--list)\b/i,
|
|
112
|
+
/^\s*git\s+describe\b/,
|
|
113
|
+
/^\s*go\s+doc\b/,
|
|
114
|
+
/^\s*go\s+list\b/,
|
|
115
|
+
/^\s*go\s+version\b/,
|
|
116
|
+
/^\s*go\s+env\b/,
|
|
102
117
|
];
|
|
103
118
|
|
|
104
119
|
/** Split a compound command into individual sub-commands.
|
|
@@ -113,12 +128,18 @@ function splitCompoundCommand(command: string): string[] {
|
|
|
113
128
|
.filter((s) => s.length > 0);
|
|
114
129
|
}
|
|
115
130
|
|
|
131
|
+
/** Strip stderr redirects that are purely cosmetic (no side effects). */
|
|
132
|
+
function stripHarmlessRedirects(cmd: string): string {
|
|
133
|
+
return cmd.replace(/\s*2\s*>\s*(\/dev\/null|&1)\b/g, "");
|
|
134
|
+
}
|
|
135
|
+
|
|
116
136
|
export function isSafeCommand(command: string): boolean {
|
|
117
137
|
const parts = splitCompoundCommand(command);
|
|
118
138
|
return parts.every(
|
|
119
139
|
(part) => {
|
|
120
|
-
const
|
|
121
|
-
const
|
|
140
|
+
const cleaned = stripHarmlessRedirects(part);
|
|
141
|
+
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(cleaned));
|
|
142
|
+
const isSafe = SAFE_PATTERNS.some((p) => p.test(cleaned));
|
|
122
143
|
return !isDestructive && isSafe;
|
|
123
144
|
},
|
|
124
145
|
);
|
|
@@ -129,6 +150,18 @@ const SKILL_TO_PHASE: Record<string, Phase> = {
|
|
|
129
150
|
"writing-plans": "plan",
|
|
130
151
|
};
|
|
131
152
|
|
|
153
|
+
/** Determine if a write/edit to filePath should be blocked during the given phase.
|
|
154
|
+
* Only writes under docs/plans/ are allowed during brainstorm and plan phases.
|
|
155
|
+
*/
|
|
156
|
+
export function shouldBlockFilePath(
|
|
157
|
+
filePath: string,
|
|
158
|
+
cwd: string,
|
|
159
|
+
): boolean {
|
|
160
|
+
const absolute = resolve(cwd, filePath);
|
|
161
|
+
const plansDir = resolve(cwd, "docs/plans");
|
|
162
|
+
return !absolute.startsWith(plansDir + "/");
|
|
163
|
+
}
|
|
164
|
+
|
|
132
165
|
export function getCurrentPhase(): Phase {
|
|
133
166
|
return phase;
|
|
134
167
|
}
|
|
@@ -183,9 +216,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
183
216
|
const filePath = (event.input as { path?: string }).path ?? "";
|
|
184
217
|
if (!filePath) return;
|
|
185
218
|
|
|
186
|
-
|
|
187
|
-
const plansDir = resolve(ctx.cwd, "docs/plans");
|
|
188
|
-
if (absolute.startsWith(plansDir + "/")) return;
|
|
219
|
+
if (!shouldBlockFilePath(filePath, ctx.cwd)) return;
|
|
189
220
|
|
|
190
221
|
if (ctx.hasUI) {
|
|
191
222
|
ctx.ui.notify(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tianhai/pi-workflow-kit",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "Workflow skills and enforcement extensions for pi",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"author": "yinloo-ola",
|
|
15
15
|
"repository": {
|
|
16
16
|
"type": "git",
|
|
17
|
-
"url": "https://github.com/yinloo-ola/pi-workflow-kit.git"
|
|
17
|
+
"url": "git+https://github.com/yinloo-ola/pi-workflow-kit.git"
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
20
|
"extensions/",
|