@giuseppe.trisciuoglio/pi-prevent-destructive-commands 1.2.2 → 1.4.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/CHANGELOG.md +1 -0
- package/README.md +50 -7
- package/docs/adr/0001-read-only-mode.md +53 -0
- package/docs/adr/0002-workdir-guard.md +70 -0
- package/package.json +5 -2
- package/src/git-guards-command.ts +6 -52
- package/src/index.ts +39 -4
- package/src/project-config.ts +71 -6
- package/src/read-only-command.ts +131 -0
- package/src/rules/path-sensitive.ts +7 -1
- package/src/rules/path-utils.ts +38 -2
- package/src/rules/workdir-guard.ts +124 -0
- package/src/workdir-guard-command.ts +109 -0
- package/test/find-start-path-test.ts +3 -8
- package/test/git-guards-command-test.ts +18 -53
- package/test/helpers.ts +96 -0
- package/test/read-only-command-test.ts +152 -0
- package/test/smoke-test.ts +5 -3
- package/test/workdir-guard-command-test.ts +115 -0
- package/test/workdir-guard-test.ts +119 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
11
|
|
|
12
|
+
- New per-project read-only mode (`/read-only on|off|status`): when enabled, every mutating tool call is blocked — `write`, `edit`, any patch-style tool (`*apply_patch`), `bash`, and `powershell` — while read-only tools (`read`, `grep`, `find`, `ls`) keep working. The flag lives in `.pi/prevent-destructive-commands.json` under `readOnly` (default `false` when the file is missing or invalid), `/read-only` toggles it while preserving unrelated JSON keys, and it is independent of the destructive-command, git, migration, Nx, and comment guards. Adds `test/read-only-command-test.ts` (`npm run test:read-only-command`) and `docs/adr/0001-read-only-mode.md`.
|
|
12
13
|
- New guard: new comments in source files are blocked. Any tool call that would introduce a new comment into a source file (Java, TypeScript/JavaScript, Dart, Go, Rust, C/C++, C#, Swift, Kotlin, Scala, PHP, CSS/SCSS, Python, Ruby, shell, YAML, TOML, Terraform, GraphQL, SQL, Lua, Haskell, Elm, HTML/XML/SVG/Markdown) is stopped, covering `write`, `edit`, `apply_patch` (unified diff and `*** Begin Patch` formats), and bash write vectors (`cat > a.ts <<EOF`, `tee b.py <<EOF`, `echo "// x" >> c.ts`). Detection is string-aware (comment markers inside string literals are not comments), comments a file already contains may be carried over untouched, and shebang lines, tooling directives (`@ts-ignore`, `eslint-disable`, `biome-ignore`, `noqa`, …) and purely decorative lines are exempt. Opt out per project with `{"disableCommentGuard": true}` in the opt-out file. Adds `test/comment-guard-test.ts` (`npm run test:comment-guard`).
|
|
13
14
|
- New guard: interactive `git commit` without a message option (bare `git commit`, `git commit -a`, `git commit --amend`, ...) is blocked because it opens the editor and hangs the session when the agent runs unattended. Applies unconditionally, even on projects that opted out of the git add/commit/push guards; commits with a message (`git commit -m x`, `-F file`, `--amend --no-edit`, ...) are unaffected.
|
|
14
15
|
|
package/README.md
CHANGED
|
@@ -19,9 +19,11 @@ A faithful port of Claude's [`prevent-destructive-commands.py`](https://github.c
|
|
|
19
19
|
- **Recursive analysis** — Traverses command wrappers, shell invocations, pipelines, and nested commands to catch obfuscated attacks.
|
|
20
20
|
- **Nx configuration guard** — In Nx workspaces, existing `package.json` and TypeScript configuration files are immutable to the agent; missing ones can still be created.
|
|
21
21
|
- **Source comment guard** — The agent cannot introduce new comments into source files (Java, TypeScript, Dart, Python, SQL, HTML, and more) through writing tools or bash write vectors; existing comments may be carried over untouched and tooling directives (`@ts-ignore`, `eslint-disable`, …) stay allowed.
|
|
22
|
+
- **Read-only mode** — `/read-only on` turns the project into an inspection-only workspace: `write`, `edit`, patch tools, `bash`, and `powershell` are blocked while `read`, `grep`, `find`, and `ls` keep working.
|
|
23
|
+
- **Workdir boundary guard** — Every file read, write, edit, and delete is confined to the working directory where pi runs. System temp directories (`/tmp`, `os.tmpdir()`) are the only exception; the temp root itself stays protected.
|
|
22
24
|
- **Configurable** — Tune protection levels via simple flags in `src/config.ts`.
|
|
23
25
|
- **Zero dependencies** — Lightweight, fast, and self-contained.
|
|
24
|
-
- **
|
|
26
|
+
- **Extensive test suite** — Smoke tests plus guard-specific and slash-command tests validate every blocking rule.
|
|
25
27
|
|
|
26
28
|
## What Gets Blocked
|
|
27
29
|
|
|
@@ -37,6 +39,7 @@ All rules are defined in [`src/config.ts`](src/config.ts) and can be customized.
|
|
|
37
39
|
| **Destructive Docker** | `docker rm` / `rmi`, `docker container/image/volume/network rm`, `docker * prune`, `docker compose down -v`, `docker compose rm`, `docker context rm`, `docker swarm leave --force` |
|
|
38
40
|
| **Destructive GitHub CLI** | `gh api -X DELETE` / `gh api --method DELETE`, `gh repo delete` / `rename`, `gh release delete`, `gh gist delete`, `gh secret delete`, `gh variable delete`, `gh label delete`, `gh workflow disable`, `gh run delete` |
|
|
39
41
|
| **Destructive AWS CLI** | `aws s3 rm`, `aws ec2 terminate-instances`, `aws rds delete-db-instance`, `aws cloudformation delete-stack`, and 50+ more subcommands (full list in `src/config.ts`) |
|
|
42
|
+
| **Paths outside the workdir** | Any tool or bash command touching a path that resolves outside the working directory: `cat /etc/passwd`, `cp ~/.ssh/id_rsa ./`, `write`/`edit` to `../outside.ts`, `apply_patch` creating files outside cwd, `cd /etc && cat passwd`. Paths under a system temp directory (`/tmp`, `os.tmpdir()`) are allowed; the temp root itself is not *(see the per-project opt-out below)* |
|
|
40
43
|
| **Sensitive file reads** | `cat`, `grep`, etc. on `.env`, SSH keys, `.pem` files — **disabled by default** via `ENABLE_SENSITIVE_FILE_CHECK` *(see Configuration)* |
|
|
41
44
|
| **Existing Nx configuration** | `package.json`, `tsconfig.json`, `tsconfig.base.json`, `tsconfig.lib.json`, and `tsconfig.spec.json` anywhere below a workspace containing `nx.json`. Creating a missing file is allowed; changing or deleting an existing file is blocked. |
|
|
42
45
|
| **New comments in source files** | Any tool call that introduces a **new** comment into a source file: `write`/`edit`/`apply_patch` touching Java, TypeScript/JavaScript, Dart, Go, Rust, C/C++, C#, Swift, Kotlin, Scala, PHP, CSS/SCSS, Python, Ruby, shell, YAML, TOML, Terraform, GraphQL, SQL, Lua, Haskell, Elm, HTML/XML/SVG/Markdown files, plus bash write vectors (`cat > a.ts <<EOF`, `tee b.py <<EOF`, `echo "// x" >> c.ts`). Comments already in the file may be carried over; shebang lines, tooling directives (`@ts-ignore`, `eslint-disable`, `biome-ignore`, `noqa`, …) and purely decorative lines are exempt *(see the per-project opt-out below)* |
|
|
@@ -112,7 +115,9 @@ Create `.pi/prevent-destructive-commands.json` in the project root to tune the g
|
|
|
112
115
|
```json
|
|
113
116
|
{
|
|
114
117
|
"disableGitGuards": true,
|
|
115
|
-
"disableCommentGuard": true
|
|
118
|
+
"disableCommentGuard": true,
|
|
119
|
+
"readOnly": false,
|
|
120
|
+
"disableWorkdirGuard": false
|
|
116
121
|
}
|
|
117
122
|
```
|
|
118
123
|
|
|
@@ -120,6 +125,8 @@ Create `.pi/prevent-destructive-commands.json` in the project root to tune the g
|
|
|
120
125
|
|------|---------|--------|
|
|
121
126
|
| `disableGitGuards` | `false` | Disables the `git add` / `git commit` / `git push` guards. |
|
|
122
127
|
| `disableCommentGuard` | `false` | Disables the source comment guard, allowing the agent to write comments into source files again. |
|
|
128
|
+
| `readOnly` | `false` | When `true`, blocks every mutating tool call (`write`, `edit`, patch tools, `bash`, `powershell`) while read-only tools keep working. |
|
|
129
|
+
| `disableWorkdirGuard` | `false` | Disables the workdir boundary guard, allowing the agent to touch paths outside the working directory again. |
|
|
123
130
|
|
|
124
131
|
Or use the `/git-guards` slash command from the pi TUI:
|
|
125
132
|
|
|
@@ -129,6 +136,37 @@ Or use the `/git-guards` slash command from the pi TUI:
|
|
|
129
136
|
| `/git-guards off` | Write `{"disableGitGuards": true}` (unrelated JSON keys are preserved). |
|
|
130
137
|
| `/git-guards on` | Write `{"disableGitGuards": false}`. |
|
|
131
138
|
|
|
139
|
+
### Read-Only Mode
|
|
140
|
+
|
|
141
|
+
Use the `/read-only` slash command to make the project inspection-only:
|
|
142
|
+
|
|
143
|
+
| Command | Effect |
|
|
144
|
+
|---------|--------|
|
|
145
|
+
| `/read-only` | Show whether read-only mode is active for the current project, and the state of the configuration file. |
|
|
146
|
+
| `/read-only on` | Write `{"readOnly": true}` (unrelated JSON keys are preserved). |
|
|
147
|
+
| `/read-only off` | Write `{"readOnly": false}`. |
|
|
148
|
+
|
|
149
|
+
While read-only mode is active, the agent can inspect files with `read`, `grep`, `find`, and `ls`, but `write`, `edit`, every patch-style tool, `bash`, and `powershell` are blocked with a clear reason. Read-only mode is independent of the destructive-command, git, migration, Nx, and comment guards, and it defaults to off when the configuration file is missing or invalid.
|
|
150
|
+
|
|
151
|
+
### Workdir Boundary Guard
|
|
152
|
+
|
|
153
|
+
The workdir boundary guard keeps every filesystem operation inside the working directory where pi is running. It covers bash commands (including `cd` tracking inside a command chain) and the file tools (`read`, `write`, `edit`, `apply_patch`).
|
|
154
|
+
|
|
155
|
+
Allowed without any opt-out:
|
|
156
|
+
|
|
157
|
+
- paths inside the working directory, relative or absolute;
|
|
158
|
+
- paths under a system temp directory (`/tmp`, `os.tmpdir()`), so temp files keep working.
|
|
159
|
+
|
|
160
|
+
Blocked: any path resolving outside the workdir — parent traversals (`../..`), absolute paths such as `/etc/passwd`, home-directory shortcuts (`~/file`), unresolvable variables (`$HOME/x`), `cd` out of the boundary, and the temp root itself (`rm -rf /tmp`).
|
|
161
|
+
|
|
162
|
+
| Command | Effect |
|
|
163
|
+
|---------|--------|
|
|
164
|
+
| `/workdir-guard` | Show whether the guard is active for the current project, and the state of the configuration file. |
|
|
165
|
+
| `/workdir-guard off` | Write `{"disableWorkdirGuard": true}` (unrelated JSON keys are preserved). |
|
|
166
|
+
| `/workdir-guard on` | Write `{"disableWorkdirGuard": false}`. |
|
|
167
|
+
|
|
168
|
+
The exception for system temp directories applies to every guard, so `rm /tmp/scratch.log` and `tee /tmp/out.txt` keep working while the workdir boundary stays enforced.
|
|
169
|
+
|
|
132
170
|
Notes:
|
|
133
171
|
|
|
134
172
|
- Forceful and deleting push variants (`--force`, `-f`, `--force-with-lease`, `-d`, `--delete`) are **always blocked** and cannot be opted out.
|
|
@@ -147,6 +185,8 @@ prevent-destructive-commands/
|
|
|
147
185
|
│ ├── config.ts # Blacklists and behavior flags — tune protection here
|
|
148
186
|
│ ├── tokenizer.ts # Shell tokenizer (shlex-like)
|
|
149
187
|
│ ├── checker.ts # Recursive command walker (wrappers/shell/find/xargs)
|
|
188
|
+
│ ├── read-only-command.ts # /read-only toggle and read-only tool-call gate
|
|
189
|
+
│ ├── workdir-guard-command.ts # /workdir-guard toggle
|
|
150
190
|
│ ├── migration-guard.ts
|
|
151
191
|
│ ├── nx-guard.ts # Protects existing Nx package and TypeScript configuration files
|
|
152
192
|
│ ├── comment-guard.ts # Blocks new comments written into source files
|
|
@@ -160,7 +200,8 @@ prevent-destructive-commands/
|
|
|
160
200
|
│ ├── docker.ts # docker rm, system prune, ...
|
|
161
201
|
│ ├── aws.ts # aws s3 rm, ec2 terminate-instances, ...
|
|
162
202
|
│ ├── file-reading.ts # sensitive file read detection
|
|
163
|
-
│
|
|
203
|
+
│ ├── path-sensitive.ts # rm/rmdir/... outside-cwd detection
|
|
204
|
+
│ └── workdir-guard.ts # workdir boundary guard (filesystem tools + bash)
|
|
164
205
|
├── test/
|
|
165
206
|
│ ├── smoke-test.ts # Standalone test suite (79+ cases)
|
|
166
207
|
│ ├── helpers.ts # Shared test utilities (check, makeProject, runCheckCases, ...)
|
|
@@ -177,7 +218,7 @@ prevent-destructive-commands/
|
|
|
177
218
|
|
|
178
219
|
## Testing
|
|
179
220
|
|
|
180
|
-
Run the full test suite (command safety, migration, Nx, and end-to-end checks):
|
|
221
|
+
Run the full test suite (command safety, migration, Nx, workdir boundary, slash commands, and end-to-end checks):
|
|
181
222
|
|
|
182
223
|
```bash
|
|
183
224
|
npm test
|
|
@@ -192,12 +233,14 @@ npm run test:smoke
|
|
|
192
233
|
npm run test:heredoc
|
|
193
234
|
npm run test:nx-guard
|
|
194
235
|
npm run test:comment-guard
|
|
236
|
+
npm run test:workdir-guard
|
|
237
|
+
npm run test:workdir-guard-command
|
|
195
238
|
```
|
|
196
239
|
|
|
197
|
-
|
|
198
|
-
npx tsx test/smoke-test.ts
|
|
240
|
+
Or run a single file directly with tsx or jiti:
|
|
199
241
|
|
|
200
|
-
|
|
242
|
+
```bash
|
|
243
|
+
npx tsx test/smoke-test.ts
|
|
201
244
|
npx jiti test/smoke-test.ts
|
|
202
245
|
```
|
|
203
246
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# ADR 0001: Read-only mode blocks shell execution wholesale
|
|
2
|
+
|
|
3
|
+
- Status: accepted
|
|
4
|
+
- Date: 2026-10-23
|
|
5
|
+
- Author: Giuseppe Trisciuoglio
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
The extension guards against destructive commands and protected paths, but there
|
|
10
|
+
is no way to declare a project inspection-only. Users wanted a per-project flag
|
|
11
|
+
that lets the agent read files and explore the codebase while preventing any
|
|
12
|
+
modification. The natural home for the flag is the existing per-project
|
|
13
|
+
configuration file, `.pi/prevent-destructive-commands.json`, and the `/git-guards`
|
|
14
|
+
command already established the pattern of a user-invoked toggle that writes that
|
|
15
|
+
file from the extension process.
|
|
16
|
+
|
|
17
|
+
Tools that can mutate state are `write`, `edit`, any patch-style variant such as
|
|
18
|
+
`apply_patch`, `bash`, and `powershell`. Read-only tools are `read`, `grep`,
|
|
19
|
+
`find`, `ls`, and similar non-mutating tools.
|
|
20
|
+
|
|
21
|
+
The open design question was whether read-only mode should keep allowing
|
|
22
|
+
"obviously read-only" shell commands such as `cat`, `ls`, or `grep` invoked
|
|
23
|
+
through `bash`. Classifying a shell command line as read-only is the same
|
|
24
|
+
unsolved problem the destructive-command checker already deals with: commands
|
|
25
|
+
can hide mutations behind wrappers, redirects, command substitution, heredocs,
|
|
26
|
+
and arbitrary binaries.
|
|
27
|
+
|
|
28
|
+
## Decision
|
|
29
|
+
|
|
30
|
+
Read-only mode blocks `bash` and `powershell` entirely, without attempting to
|
|
31
|
+
classify individual shell commands as safe. It also blocks `write`, `edit`, and
|
|
32
|
+
every tool whose name ends with `apply_patch`. Read-only tools keep working.
|
|
33
|
+
|
|
34
|
+
The mode is stored as a `readOnly` boolean in
|
|
35
|
+
`.pi/prevent-destructive-commands.json`. It defaults to `false` when the file is
|
|
36
|
+
missing or invalid, so the agent stays writable until the user explicitly turns
|
|
37
|
+
it on. The `/read-only on|off|status` slash command toggles the flag while
|
|
38
|
+
preserving unrelated JSON keys, mirroring `/git-guards`.
|
|
39
|
+
|
|
40
|
+
When read-only mode is active, the `tool_call` handler checks it first and
|
|
41
|
+
short-circuits the other write-related guards: a rejected write needs no further
|
|
42
|
+
analysis.
|
|
43
|
+
|
|
44
|
+
## Consequences
|
|
45
|
+
|
|
46
|
+
- Inspection-only projects are safe: no shell invocation can mutate the
|
|
47
|
+
workspace while the mode is on.
|
|
48
|
+
- Users lose the convenience of running read-only shell commands through `bash`
|
|
49
|
+
in read-only mode; they use `read`, `grep`, `find`, and `ls` instead.
|
|
50
|
+
- The mode is per project, so different projects can be in different modes at
|
|
51
|
+
the same time.
|
|
52
|
+
- Read-only mode is independent of the destructive-command, git, migration, Nx,
|
|
53
|
+
and comment guards; those behaviors are unchanged when the mode is off.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# ADR 0002: Workdir boundary guard blocks path access outside the working directory
|
|
2
|
+
|
|
3
|
+
- Status: accepted
|
|
4
|
+
- Date: 2026-10-26
|
|
5
|
+
- Author: Giuseppe Trisciuoglio
|
|
6
|
+
- Issue: [#24](https://github.com/giuseppe-trisciuoglio/pi-prevent-destructive-commands/issues/24)
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
The extension guards against destructive commands and protected paths, but it
|
|
11
|
+
does not prevent the agent from reading, writing, modifying, or deleting files
|
|
12
|
+
outside the working directory where pi runs. Users need a guard that confines
|
|
13
|
+
all file operations to the workdir, with a single exception for system temp
|
|
14
|
+
files (e.g. `/tmp`).
|
|
15
|
+
|
|
16
|
+
Two constraints shaped the design:
|
|
17
|
+
|
|
18
|
+
1. The exception must be coherent across the whole extension. Allow the agent
|
|
19
|
+
to `cat /tmp/x` while the older destructive-command checker still blocks
|
|
20
|
+
`rm /tmp/x` would produce contradictory messages from a single extension.
|
|
21
|
+
2. The configuration is per project and user-managed, so the guard needs an
|
|
22
|
+
opt-out and a discoverable way to toggle it, like the existing `/git-guards`
|
|
23
|
+
and `/read-only` commands.
|
|
24
|
+
|
|
25
|
+
## Decision
|
|
26
|
+
|
|
27
|
+
The workdir boundary guard lives in `src/rules/workdir-guard.ts`. It checks
|
|
28
|
+
every path used by bash commands and by file tools (`read`, `write`, `edit`,
|
|
29
|
+
`apply_patch`) and blocks any path that resolves outside the working directory.
|
|
30
|
+
|
|
31
|
+
The system temp exception is implemented once, in `src/rules/path-utils.ts`
|
|
32
|
+
(`isSystemTempPath` / `isSystemTempRoot`), and shared by every path-based rule.
|
|
33
|
+
Both the raw and the realpath form of each temp root are recorded because path
|
|
34
|
+
resolution here is textual: on macOS `os.tmpdir()` is `/var/folders/...` with
|
|
35
|
+
realpath `/private/var/folders/...`, and `/tmp` is a symlink to `/private/tmp`.
|
|
36
|
+
|
|
37
|
+
The temp root itself is not a temp file: `rm -rf /tmp` stays blocked. Copying
|
|
38
|
+
into it (`cp file /tmp/`) is allowed, since the trailing slash states the
|
|
39
|
+
destination is the directory.
|
|
40
|
+
|
|
41
|
+
Bash analysis tracks `cd` inside a command chain: `cd` must stay inside the
|
|
42
|
+
boundary, and relative paths after it resolve against the tracked directory.
|
|
43
|
+
A `cd` that escapes, a bare `cd` (home directory), and `cd -` are blocked
|
|
44
|
+
because their effect cannot be verified against the boundary.
|
|
45
|
+
|
|
46
|
+
The guard is configurable per project via `.pi/prevent-destructive-commands.json`
|
|
47
|
+
using the `disableWorkdirGuard` boolean, defaulting to `false` (guard active),
|
|
48
|
+
and toggled from the pi TUI with `/workdir-guard [on|off|status]`.
|
|
49
|
+
|
|
50
|
+
Integration points:
|
|
51
|
+
|
|
52
|
+
- `src/rules/path-utils.ts`: shared system temp helpers.
|
|
53
|
+
- `src/rules/path-sensitive.ts`: rejects the temp root as a deletion target.
|
|
54
|
+
- `src/index.ts`: `checkToolInputWorkdir` runs for non-bash tools;
|
|
55
|
+
`checkCommandWorkdir` runs for bash commands.
|
|
56
|
+
- `src/project-config.ts`: `disableWorkdirGuard` in the project configuration.
|
|
57
|
+
- `src/workdir-guard-command.ts`: the `/workdir-guard` slash command.
|
|
58
|
+
|
|
59
|
+
## Consequences
|
|
60
|
+
|
|
61
|
+
- File operations outside the workdir are blocked for both bash and file tools,
|
|
62
|
+
while temp files keep working everywhere in the extension.
|
|
63
|
+
- The guard shifts behaviour of the existing destructive checker: `/tmp` targets
|
|
64
|
+
are no longer rejected. Tests that relied on `/tmp` being outside the boundary
|
|
65
|
+
were rewritten to use non-temp paths so they keep asserting what they meant to.
|
|
66
|
+
- Paths with variables or globs cannot be resolved statically and are blocked,
|
|
67
|
+
which is conservative but predictable.
|
|
68
|
+
- The guard is opt-out per project through the JSON file or `/workdir-guard off`.
|
|
69
|
+
- Operating inside a workdir that itself lives under a temp root weakens the
|
|
70
|
+
boundary, because the whole temp root is trusted by design.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@giuseppe.trisciuoglio/pi-prevent-destructive-commands",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Unconditionally blocks destructive bash commands before execution. A pi extension that guards against rm, git reset --hard, docker rm, aws delete operations, and more.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"node": ">=18.0.0"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
|
-
"test": "npm run test:smoke && npm run test:heredoc && npm run test:migration-guard && npm run test:nx-guard && npm run test:project-config && npm run test:find-start-path && npm run test:git-guards-command && npm run test:gh-guard && npm run test:comment-guard && npm run test:e2e",
|
|
35
|
+
"test": "npm run test:smoke && npm run test:heredoc && npm run test:migration-guard && npm run test:nx-guard && npm run test:project-config && npm run test:find-start-path && npm run test:git-guards-command && npm run test:read-only-command && npm run test:gh-guard && npm run test:comment-guard && npm run test:workdir-guard && npm run test:workdir-guard-command && npm run test:e2e",
|
|
36
36
|
"test:smoke": "npx tsx test/smoke-test.ts",
|
|
37
37
|
"test:heredoc": "npx tsx test/heredoc-test.ts",
|
|
38
38
|
"test:e2e": "npx tsx test/e2e-install-test.ts",
|
|
@@ -41,8 +41,11 @@
|
|
|
41
41
|
"test:project-config": "npx tsx test/project-config-test.ts",
|
|
42
42
|
"test:find-start-path": "npx tsx test/find-start-path-test.ts",
|
|
43
43
|
"test:git-guards-command": "npx tsx test/git-guards-command-test.ts",
|
|
44
|
+
"test:read-only-command": "npx tsx test/read-only-command-test.ts",
|
|
44
45
|
"test:gh-guard": "npx tsx test/gh-guard-test.ts",
|
|
45
46
|
"test:comment-guard": "npx tsx test/comment-guard-test.ts",
|
|
47
|
+
"test:workdir-guard": "npx tsx test/workdir-guard-test.ts",
|
|
48
|
+
"test:workdir-guard-command": "npx tsx test/workdir-guard-command-test.ts",
|
|
46
49
|
"lint": "tsc --noEmit",
|
|
47
50
|
"typecheck": "tsc --noEmit"
|
|
48
51
|
},
|
|
@@ -9,14 +9,12 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
-
import { dirname } from "node:path";
|
|
14
12
|
import { ENABLE_GIT_ADD_COMMIT_BLOCK } from "./config";
|
|
15
13
|
import {
|
|
16
14
|
loadProjectConfig,
|
|
17
|
-
projectConfigPath,
|
|
18
15
|
PROJECT_CONFIG_RELATIVE_PATH,
|
|
19
|
-
|
|
16
|
+
projectConfigFileStatus,
|
|
17
|
+
writeProjectConfigFlag,
|
|
20
18
|
} from "./project-config";
|
|
21
19
|
|
|
22
20
|
export type GitGuardsAction = "status" | "off" | "on" | "usage";
|
|
@@ -48,26 +46,7 @@ export interface GitGuardsStatus {
|
|
|
48
46
|
}
|
|
49
47
|
|
|
50
48
|
export function gitGuardsStatus(cwd: string): GitGuardsStatus {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
let fileExists = false;
|
|
54
|
-
try {
|
|
55
|
-
statSync(path);
|
|
56
|
-
fileExists = true;
|
|
57
|
-
} catch {
|
|
58
|
-
// No file: guards active by default.
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
let fileValid = !fileExists; // A missing file is not an invalid file.
|
|
62
|
-
if (fileExists) {
|
|
63
|
-
try {
|
|
64
|
-
const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
65
|
-
fileValid = typeof raw === "object" && raw !== null && !Array.isArray(raw);
|
|
66
|
-
} catch {
|
|
67
|
-
fileValid = false;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
49
|
+
const { fileExists, fileValid } = projectConfigFileStatus(cwd);
|
|
71
50
|
const disabledByFile = loadProjectConfig(cwd).disableGitGuards;
|
|
72
51
|
|
|
73
52
|
return {
|
|
@@ -84,34 +63,9 @@ export function gitGuardsStatus(cwd: string): GitGuardsStatus {
|
|
|
84
63
|
* file content was invalid (and therefore replaced).
|
|
85
64
|
*/
|
|
86
65
|
export function applyGitGuards(cwd: string, disable: boolean): boolean {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
let replacedInvalid = false;
|
|
91
|
-
try {
|
|
92
|
-
const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
93
|
-
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
|
94
|
-
previous = raw as Record<string, unknown>;
|
|
95
|
-
} else {
|
|
96
|
-
replacedInvalid = true;
|
|
97
|
-
}
|
|
98
|
-
} catch {
|
|
99
|
-
// Missing file or unparseable content: start from a clean object.
|
|
100
|
-
try {
|
|
101
|
-
statSync(path);
|
|
102
|
-
replacedInvalid = true;
|
|
103
|
-
} catch {
|
|
104
|
-
// File simply absent: nothing lost.
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
previous.disableGitGuards = disable;
|
|
109
|
-
|
|
110
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
111
|
-
writeFileSync(path, `${JSON.stringify(previous, null, "\t")}\n`, "utf8");
|
|
112
|
-
resetProjectConfigCache();
|
|
113
|
-
|
|
114
|
-
return replacedInvalid;
|
|
66
|
+
return writeProjectConfigFlag(cwd, (config) => {
|
|
67
|
+
config.disableGitGuards = disable;
|
|
68
|
+
});
|
|
115
69
|
}
|
|
116
70
|
|
|
117
71
|
function statusMessage(cwd: string): { text: string; level: "info" | "warning" | "error" } {
|
package/src/index.ts
CHANGED
|
@@ -20,7 +20,10 @@ import {
|
|
|
20
20
|
loadProjectConfig,
|
|
21
21
|
} from "./project-config";
|
|
22
22
|
import { tokenize } from "./tokenizer";
|
|
23
|
+
import { checkCommandWorkdir, checkToolInputWorkdir } from "./rules/workdir-guard";
|
|
23
24
|
import { registerGitGuardsCommand } from "./git-guards-command";
|
|
25
|
+
import { readOnlyBlockResult, registerReadOnlyCommand } from "./read-only-command";
|
|
26
|
+
import { registerWorkdirGuardCommand } from "./workdir-guard-command";
|
|
24
27
|
|
|
25
28
|
function pathsFromPatch(patch: string): string[] {
|
|
26
29
|
return extractPatchPaths(patch, [
|
|
@@ -52,8 +55,19 @@ function commandReferencesMigrationPath(command: string, cwd: string, migrationD
|
|
|
52
55
|
|
|
53
56
|
export default function (pi: ExtensionAPI) {
|
|
54
57
|
registerGitGuardsCommand(pi);
|
|
58
|
+
registerReadOnlyCommand(pi);
|
|
59
|
+
registerWorkdirGuardCommand(pi);
|
|
55
60
|
|
|
56
61
|
pi.on("tool_call", async (event, ctx) => {
|
|
62
|
+
const projectConfig = ctx.cwd
|
|
63
|
+
? loadProjectConfig(ctx.cwd)
|
|
64
|
+
: DEFAULT_PROJECT_CONFIG;
|
|
65
|
+
|
|
66
|
+
const readOnlyBlock = readOnlyBlockResult(event.toolName, projectConfig);
|
|
67
|
+
if (readOnlyBlock) {
|
|
68
|
+
return readOnlyBlock;
|
|
69
|
+
}
|
|
70
|
+
|
|
57
71
|
// The per-project opt-out file is user-only: the agent must never create
|
|
58
72
|
// or edit it, neither through the writing tools nor through bash.
|
|
59
73
|
if (isToolCallEventType("bash", event)) {
|
|
@@ -84,10 +98,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
84
98
|
return { block: true, reason: NX_CONFIG_BLOCK_REASON };
|
|
85
99
|
}
|
|
86
100
|
|
|
87
|
-
const projectConfig = ctx.cwd
|
|
88
|
-
? loadProjectConfig(ctx.cwd)
|
|
89
|
-
: DEFAULT_PROJECT_CONFIG;
|
|
90
|
-
|
|
91
101
|
if (!projectConfig.disableCommentGuard) {
|
|
92
102
|
const commentViolation = await findNewSourceComments(
|
|
93
103
|
event.toolName,
|
|
@@ -122,6 +132,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
122
132
|
}
|
|
123
133
|
}
|
|
124
134
|
|
|
135
|
+
if (!projectConfig.disableWorkdirGuard && !isToolCallEventType("bash", event)) {
|
|
136
|
+
const input = event.input as Record<string, unknown>;
|
|
137
|
+
const extraPaths: string[] = [];
|
|
138
|
+
if (typeof input.path === "string") {
|
|
139
|
+
extraPaths.push(input.path);
|
|
140
|
+
}
|
|
141
|
+
const workdirBlock = checkToolInputWorkdir(
|
|
142
|
+
event.toolName,
|
|
143
|
+
input,
|
|
144
|
+
pathsFromPatch,
|
|
145
|
+
ctx.cwd,
|
|
146
|
+
extraPaths,
|
|
147
|
+
);
|
|
148
|
+
if (workdirBlock.dangerous) {
|
|
149
|
+
return { block: true, reason: `[prevent-destructive-commands] Blocked: ${workdirBlock.reason}` };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
125
153
|
if (!isToolCallEventType("bash", event)) return undefined;
|
|
126
154
|
|
|
127
155
|
const command = event.input.command;
|
|
@@ -135,6 +163,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
135
163
|
});
|
|
136
164
|
if (!dangerous) return undefined;
|
|
137
165
|
|
|
166
|
+
if (!projectConfig.disableWorkdirGuard) {
|
|
167
|
+
const workdirBlock = checkCommandWorkdir(command, ctx.cwd);
|
|
168
|
+
if (workdirBlock.dangerous) {
|
|
169
|
+
return { block: true, reason: `[prevent-destructive-commands] Blocked: ${workdirBlock.reason}` };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
138
173
|
return {
|
|
139
174
|
block: true,
|
|
140
175
|
reason: `[prevent-destructive-commands] Blocked: ${reason}`,
|
package/src/project-config.ts
CHANGED
|
@@ -8,21 +8,23 @@
|
|
|
8
8
|
* user — the extension itself blocks the agent from writing to it.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { readFileSync, statSync } from "node:fs";
|
|
12
|
-
import { join } from "node:path";
|
|
11
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
13
|
|
|
14
14
|
export const PROJECT_CONFIG_RELATIVE_PATH = ".pi/prevent-destructive-commands.json";
|
|
15
15
|
|
|
16
16
|
export interface ProjectConfig {
|
|
17
|
-
/** When true, the `git add` / `git commit` / `git push` guards are disabled. */
|
|
18
17
|
disableGitGuards: boolean;
|
|
19
|
-
/** When true, the source comment guard is disabled. */
|
|
20
18
|
disableCommentGuard: boolean;
|
|
19
|
+
readOnly: boolean;
|
|
20
|
+
disableWorkdirGuard: boolean;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
|
|
24
24
|
disableGitGuards: false,
|
|
25
25
|
disableCommentGuard: false,
|
|
26
|
+
readOnly: false,
|
|
27
|
+
disableWorkdirGuard: false,
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
/** Absolute path of the project configuration file for a given cwd. */
|
|
@@ -60,9 +62,12 @@ export function loadProjectConfig(cwd: string): ProjectConfig {
|
|
|
60
62
|
try {
|
|
61
63
|
const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
62
64
|
if (typeof raw === "object" && raw !== null) {
|
|
65
|
+
const fields = raw as Record<string, unknown>;
|
|
63
66
|
config = {
|
|
64
|
-
disableGitGuards:
|
|
65
|
-
disableCommentGuard:
|
|
67
|
+
disableGitGuards: fields.disableGitGuards === true,
|
|
68
|
+
disableCommentGuard: fields.disableCommentGuard === true,
|
|
69
|
+
readOnly: fields.readOnly === true,
|
|
70
|
+
disableWorkdirGuard: fields.disableWorkdirGuard === true,
|
|
66
71
|
};
|
|
67
72
|
}
|
|
68
73
|
} catch {
|
|
@@ -92,3 +97,63 @@ export function commandReferencesProjectConfig(command: string): boolean {
|
|
|
92
97
|
export function resetProjectConfigCache(): void {
|
|
93
98
|
cache.clear();
|
|
94
99
|
}
|
|
100
|
+
|
|
101
|
+
export interface ProjectConfigFileStatus {
|
|
102
|
+
fileExists: boolean;
|
|
103
|
+
fileValid: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function projectConfigFileStatus(cwd: string): ProjectConfigFileStatus {
|
|
107
|
+
const path = projectConfigPath(cwd);
|
|
108
|
+
|
|
109
|
+
let fileExists = false;
|
|
110
|
+
try {
|
|
111
|
+
statSync(path);
|
|
112
|
+
fileExists = true;
|
|
113
|
+
} catch {
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let fileValid = !fileExists;
|
|
117
|
+
if (fileExists) {
|
|
118
|
+
try {
|
|
119
|
+
const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
120
|
+
fileValid = typeof raw === "object" && raw !== null && !Array.isArray(raw);
|
|
121
|
+
} catch {
|
|
122
|
+
fileValid = false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { fileExists, fileValid };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function writeProjectConfigFlag(
|
|
130
|
+
cwd: string,
|
|
131
|
+
mutate: (config: Record<string, unknown>) => void,
|
|
132
|
+
): boolean {
|
|
133
|
+
const path = projectConfigPath(cwd);
|
|
134
|
+
|
|
135
|
+
let previous: Record<string, unknown> = {};
|
|
136
|
+
let replacedInvalid = false;
|
|
137
|
+
try {
|
|
138
|
+
const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
139
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
|
140
|
+
previous = raw as Record<string, unknown>;
|
|
141
|
+
} else {
|
|
142
|
+
replacedInvalid = true;
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
try {
|
|
146
|
+
statSync(path);
|
|
147
|
+
replacedInvalid = true;
|
|
148
|
+
} catch {
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
mutate(previous);
|
|
153
|
+
|
|
154
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
155
|
+
writeFileSync(path, `${JSON.stringify(previous, null, "\t")}\n`, "utf8");
|
|
156
|
+
resetProjectConfigCache();
|
|
157
|
+
|
|
158
|
+
return replacedInvalid;
|
|
159
|
+
}
|