@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 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
- - **79+ test cases** — Comprehensive smoke test suite validates all blocking rules.
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
- │ └── path-sensitive.ts # rm/rmdir/... outside-cwd detection
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
- # Or directly with tsx
198
- npx tsx test/smoke-test.ts
240
+ Or run a single file directly with tsx or jiti:
199
241
 
200
- # Or with jiti
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.2.2",
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
- resetProjectConfigCache,
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 path = projectConfigPath(cwd);
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
- const path = projectConfigPath(cwd);
88
-
89
- let previous: Record<string, unknown> = {};
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}`,
@@ -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: (raw as Record<string, unknown>).disableGitGuards === true,
65
- disableCommentGuard: (raw as Record<string, unknown>).disableCommentGuard === true,
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
+ }