@giuseppe.trisciuoglio/pi-prevent-destructive-commands 1.2.2 → 1.3.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,6 +19,7 @@ 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.
22
23
  - **Configurable** — Tune protection levels via simple flags in `src/config.ts`.
23
24
  - **Zero dependencies** — Lightweight, fast, and self-contained.
24
25
  - **79+ test cases** — Comprehensive smoke test suite validates all blocking rules.
@@ -112,7 +113,8 @@ Create `.pi/prevent-destructive-commands.json` in the project root to tune the g
112
113
  ```json
113
114
  {
114
115
  "disableGitGuards": true,
115
- "disableCommentGuard": true
116
+ "disableCommentGuard": true,
117
+ "readOnly": false
116
118
  }
117
119
  ```
118
120
 
@@ -120,6 +122,7 @@ Create `.pi/prevent-destructive-commands.json` in the project root to tune the g
120
122
  |------|---------|--------|
121
123
  | `disableGitGuards` | `false` | Disables the `git add` / `git commit` / `git push` guards. |
122
124
  | `disableCommentGuard` | `false` | Disables the source comment guard, allowing the agent to write comments into source files again. |
125
+ | `readOnly` | `false` | When `true`, blocks every mutating tool call (`write`, `edit`, patch tools, `bash`, `powershell`) while read-only tools keep working. |
123
126
 
124
127
  Or use the `/git-guards` slash command from the pi TUI:
125
128
 
@@ -129,6 +132,18 @@ Or use the `/git-guards` slash command from the pi TUI:
129
132
  | `/git-guards off` | Write `{"disableGitGuards": true}` (unrelated JSON keys are preserved). |
130
133
  | `/git-guards on` | Write `{"disableGitGuards": false}`. |
131
134
 
135
+ ### Read-Only Mode
136
+
137
+ Use the `/read-only` slash command to make the project inspection-only:
138
+
139
+ | Command | Effect |
140
+ |---------|--------|
141
+ | `/read-only` | Show whether read-only mode is active for the current project, and the state of the configuration file. |
142
+ | `/read-only on` | Write `{"readOnly": true}` (unrelated JSON keys are preserved). |
143
+ | `/read-only off` | Write `{"readOnly": false}`. |
144
+
145
+ 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.
146
+
132
147
  Notes:
133
148
 
134
149
  - Forceful and deleting push variants (`--force`, `-f`, `--force-with-lease`, `-d`, `--delete`) are **always blocked** and cannot be opted out.
@@ -147,6 +162,7 @@ prevent-destructive-commands/
147
162
  │ ├── config.ts # Blacklists and behavior flags — tune protection here
148
163
  │ ├── tokenizer.ts # Shell tokenizer (shlex-like)
149
164
  │ ├── checker.ts # Recursive command walker (wrappers/shell/find/xargs)
165
+ │ ├── read-only-command.ts # /read-only toggle and read-only tool-call gate
150
166
  │ ├── migration-guard.ts
151
167
  │ ├── nx-guard.ts # Protects existing Nx package and TypeScript configuration files
152
168
  │ ├── comment-guard.ts # Blocks new comments written into source files
@@ -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.
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.3.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: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,6 +41,7 @@
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",
46
47
  "lint": "tsc --noEmit",
@@ -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
@@ -21,6 +21,7 @@ import {
21
21
  } from "./project-config";
22
22
  import { tokenize } from "./tokenizer";
23
23
  import { registerGitGuardsCommand } from "./git-guards-command";
24
+ import { readOnlyBlockResult, registerReadOnlyCommand } from "./read-only-command";
24
25
 
25
26
  function pathsFromPatch(patch: string): string[] {
26
27
  return extractPatchPaths(patch, [
@@ -52,8 +53,18 @@ function commandReferencesMigrationPath(command: string, cwd: string, migrationD
52
53
 
53
54
  export default function (pi: ExtensionAPI) {
54
55
  registerGitGuardsCommand(pi);
56
+ registerReadOnlyCommand(pi);
55
57
 
56
58
  pi.on("tool_call", async (event, ctx) => {
59
+ const projectConfig = ctx.cwd
60
+ ? loadProjectConfig(ctx.cwd)
61
+ : DEFAULT_PROJECT_CONFIG;
62
+
63
+ const readOnlyBlock = readOnlyBlockResult(event.toolName, projectConfig);
64
+ if (readOnlyBlock) {
65
+ return readOnlyBlock;
66
+ }
67
+
57
68
  // The per-project opt-out file is user-only: the agent must never create
58
69
  // or edit it, neither through the writing tools nor through bash.
59
70
  if (isToolCallEventType("bash", event)) {
@@ -84,10 +95,6 @@ export default function (pi: ExtensionAPI) {
84
95
  return { block: true, reason: NX_CONFIG_BLOCK_REASON };
85
96
  }
86
97
 
87
- const projectConfig = ctx.cwd
88
- ? loadProjectConfig(ctx.cwd)
89
- : DEFAULT_PROJECT_CONFIG;
90
-
91
98
  if (!projectConfig.disableCommentGuard) {
92
99
  const commentViolation = await findNewSourceComments(
93
100
  event.toolName,
@@ -8,8 +8,8 @@
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
 
@@ -18,11 +18,13 @@ export interface ProjectConfig {
18
18
  disableGitGuards: boolean;
19
19
  /** When true, the source comment guard is disabled. */
20
20
  disableCommentGuard: boolean;
21
+ readOnly: boolean;
21
22
  }
22
23
 
23
24
  export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
24
25
  disableGitGuards: false,
25
26
  disableCommentGuard: false,
27
+ readOnly: false,
26
28
  };
27
29
 
28
30
  /** Absolute path of the project configuration file for a given cwd. */
@@ -60,9 +62,11 @@ 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,
66
70
  };
67
71
  }
68
72
  } catch {
@@ -92,3 +96,63 @@ export function commandReferencesProjectConfig(command: string): boolean {
92
96
  export function resetProjectConfigCache(): void {
93
97
  cache.clear();
94
98
  }
99
+
100
+ export interface ProjectConfigFileStatus {
101
+ fileExists: boolean;
102
+ fileValid: boolean;
103
+ }
104
+
105
+ export function projectConfigFileStatus(cwd: string): ProjectConfigFileStatus {
106
+ const path = projectConfigPath(cwd);
107
+
108
+ let fileExists = false;
109
+ try {
110
+ statSync(path);
111
+ fileExists = true;
112
+ } catch {
113
+ }
114
+
115
+ let fileValid = !fileExists;
116
+ if (fileExists) {
117
+ try {
118
+ const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
119
+ fileValid = typeof raw === "object" && raw !== null && !Array.isArray(raw);
120
+ } catch {
121
+ fileValid = false;
122
+ }
123
+ }
124
+
125
+ return { fileExists, fileValid };
126
+ }
127
+
128
+ export function writeProjectConfigFlag(
129
+ cwd: string,
130
+ mutate: (config: Record<string, unknown>) => void,
131
+ ): boolean {
132
+ const path = projectConfigPath(cwd);
133
+
134
+ let previous: Record<string, unknown> = {};
135
+ let replacedInvalid = false;
136
+ try {
137
+ const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
138
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
139
+ previous = raw as Record<string, unknown>;
140
+ } else {
141
+ replacedInvalid = true;
142
+ }
143
+ } catch {
144
+ try {
145
+ statSync(path);
146
+ replacedInvalid = true;
147
+ } catch {
148
+ }
149
+ }
150
+
151
+ mutate(previous);
152
+
153
+ mkdirSync(dirname(path), { recursive: true });
154
+ writeFileSync(path, `${JSON.stringify(previous, null, "\t")}\n`, "utf8");
155
+ resetProjectConfigCache();
156
+
157
+ return replacedInvalid;
158
+ }
@@ -0,0 +1,131 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ loadProjectConfig,
4
+ PROJECT_CONFIG_RELATIVE_PATH,
5
+ projectConfigFileStatus,
6
+ writeProjectConfigFlag,
7
+ } from "./project-config";
8
+
9
+ export type ReadOnlyAction = "status" | "off" | "on" | "usage";
10
+
11
+ export function parseReadOnlyArgs(args: string): ReadOnlyAction {
12
+ switch (args.trim().toLowerCase()) {
13
+ case "":
14
+ case "status":
15
+ return "status";
16
+ case "off":
17
+ return "off";
18
+ case "on":
19
+ return "on";
20
+ default:
21
+ return "usage";
22
+ }
23
+ }
24
+
25
+ export interface ReadOnlyStatus {
26
+ readOnly: boolean;
27
+ fileExists: boolean;
28
+ fileValid: boolean;
29
+ }
30
+
31
+ export function readOnlyStatus(cwd: string): ReadOnlyStatus {
32
+ const { fileExists, fileValid } = projectConfigFileStatus(cwd);
33
+ return {
34
+ readOnly: loadProjectConfig(cwd).readOnly,
35
+ fileExists,
36
+ fileValid,
37
+ };
38
+ }
39
+
40
+ export function applyReadOnly(cwd: string, value: boolean): boolean {
41
+ return writeProjectConfigFlag(cwd, (config) => {
42
+ config.readOnly = value;
43
+ });
44
+ }
45
+
46
+ function statusMessage(cwd: string): { text: string; level: "info" | "warning" | "error" } {
47
+ const status = readOnlyStatus(cwd);
48
+
49
+ const lines: string[] = [];
50
+ if (status.readOnly) {
51
+ lines.push("read-only mode is ACTIVE for this project: write, edit, patch, bash, and powershell are blocked.");
52
+ } else {
53
+ lines.push("read-only mode is INACTIVE for this project: mutating tools are allowed.");
54
+ }
55
+
56
+ if (!status.fileExists) {
57
+ lines.push(`configuration file: none (${PROJECT_CONFIG_RELATIVE_PATH} missing — safe default: read-only off)`);
58
+ } else if (!status.fileValid) {
59
+ lines.push(`configuration file: ${PROJECT_CONFIG_RELATIVE_PATH} exists but is not valid JSON — ignored, read-only off`);
60
+ } else {
61
+ lines.push(`configuration file: ${PROJECT_CONFIG_RELATIVE_PATH}`);
62
+ }
63
+
64
+ const level = status.readOnly ? "warning" : "info";
65
+ return { text: lines.join("\n"), level };
66
+ }
67
+
68
+ export const READ_ONLY_USAGE = `Usage: /read-only [on|off|status]
69
+ (no args) show whether read-only mode is active for this project
70
+ on write {"readOnly": true} to ${PROJECT_CONFIG_RELATIVE_PATH}
71
+ off write {"readOnly": false} to ${PROJECT_CONFIG_RELATIVE_PATH}`;
72
+
73
+ export const READ_ONLY_BLOCK_REASON =
74
+ "[prevent-destructive-commands] Blocked: read-only mode is enabled for this project (use /read-only off to allow modifications).";
75
+
76
+ export function isReadOnlyMutatingTool(toolName: string): boolean {
77
+ return (
78
+ toolName === "write" ||
79
+ toolName === "edit" ||
80
+ toolName === "bash" ||
81
+ toolName === "powershell" ||
82
+ toolName.endsWith("apply_patch")
83
+ );
84
+ }
85
+
86
+ export interface ReadOnlyBlockResult {
87
+ block: true;
88
+ reason: string;
89
+ }
90
+
91
+ export function readOnlyBlockResult(
92
+ toolName: string,
93
+ config: { readOnly: boolean },
94
+ ): ReadOnlyBlockResult | undefined {
95
+ if (!config.readOnly || !isReadOnlyMutatingTool(toolName)) return undefined;
96
+ return { block: true, reason: READ_ONLY_BLOCK_REASON };
97
+ }
98
+
99
+ export function registerReadOnlyCommand(pi: ExtensionAPI): void {
100
+ pi.registerCommand("read-only", {
101
+ description: "Show or toggle per-project read-only mode (/read-only [on|off])",
102
+ handler: async (args: string, ctx: { cwd: string; ui: { notify: (message: string, type?: "info" | "warning" | "error") => void } }) => {
103
+ switch (parseReadOnlyArgs(args)) {
104
+ case "status": {
105
+ const { text, level } = statusMessage(ctx.cwd);
106
+ ctx.ui.notify(text, level);
107
+ return;
108
+ }
109
+ case "on": {
110
+ const replacedInvalid = applyReadOnly(ctx.cwd, true);
111
+ ctx.ui.notify(
112
+ `read-only mode ENABLED for this project (write, edit, patch, bash, and powershell are blocked).${replacedInvalid ? ` Note: previous ${PROJECT_CONFIG_RELATIVE_PATH} was invalid JSON and was replaced.` : ""}`,
113
+ "warning",
114
+ );
115
+ return;
116
+ }
117
+ case "off": {
118
+ const replacedInvalid = applyReadOnly(ctx.cwd, false);
119
+ ctx.ui.notify(
120
+ `read-only mode DISABLED for this project (mutating tools allowed again).${replacedInvalid ? ` Note: previous ${PROJECT_CONFIG_RELATIVE_PATH} was invalid JSON and was replaced.` : ""}`,
121
+ "info",
122
+ );
123
+ return;
124
+ }
125
+ case "usage":
126
+ ctx.ui.notify(READ_ONLY_USAGE, "error");
127
+ return;
128
+ }
129
+ },
130
+ });
131
+ }
@@ -13,7 +13,15 @@ import {
13
13
  parseGitGuardsArgs,
14
14
  } from "../src/git-guards-command";
15
15
  import { PROJECT_CONFIG_RELATIVE_PATH, loadProjectConfig } from "../src/project-config";
16
- import { check, checkFailures, isBlocked, makeProject } from "./helpers";
16
+ import {
17
+ check,
18
+ checkFileStatusOnInvalidConfig,
19
+ checkUnknownArgument,
20
+ finishTest,
21
+ isBlocked,
22
+ makeProject,
23
+ setupCommandHarness,
24
+ } from "./helpers";
17
25
 
18
26
  // ─── parseGitGuardsArgs ───────────────────────────────────────────────────────
19
27
 
@@ -48,14 +56,9 @@ const disabledFile = makeProject('{"disableGitGuards": true}');
48
56
  }
49
57
  rmSync(disabledFile, { recursive: true, force: true });
50
58
 
51
- const invalidFile = makeProject("{ not json");
52
- {
53
- const status = gitGuardsStatus(invalidFile);
59
+ checkFileStatusOnInvalidConfig("git-guards", gitGuardsStatus, (status) => {
54
60
  check("invalid JSON → guards stay enabled", status.guardsEnabled, true);
55
- check("invalid JSON → fileExists true", status.fileExists, true);
56
- check("invalid JSON → fileValid false", status.fileValid, false);
57
- }
58
- rmSync(invalidFile, { recursive: true, force: true });
61
+ });
59
62
 
60
63
  // ─── applyGitGuards ──────────────────────────────────────────────────────────
61
64
 
@@ -111,42 +114,14 @@ const broken = makeProject("{ not json");
111
114
  }
112
115
  rmSync(broken, { recursive: true, force: true });
113
116
 
114
- // ─── registerGitGuardsCommand (handler wiring with a fake pi/ctx) ───────────
115
-
116
- interface RecordedNotification {
117
- message: string;
118
- type: string;
119
- }
120
-
121
- function fakePi() {
122
- const commands = new Map<string, { description?: string; handler: (args: string, ctx: unknown) => Promise<void> }>();
123
- return {
124
- commands,
125
- registerCommand: (name: string, options: { description?: string; handler: (args: string, ctx: unknown) => Promise<void> }) => {
126
- commands.set(name, options);
127
- },
128
- };
129
- }
130
-
131
- function fakeCtx(cwd: string) {
132
- const notifications: RecordedNotification[] = [];
133
- return {
134
- notifications,
135
- ctx: { cwd, ui: { notify: (message: string, type = "info") => notifications.push({ message, type }) } },
136
- };
137
- }
138
-
139
117
  const commandProject = makeProject();
140
118
  {
141
- const pi = fakePi();
142
- registerGitGuardsCommand(pi as unknown as Parameters<typeof registerGitGuardsCommand>[0]);
143
- check("command registered as git-guards", pi.commands.has("git-guards"), true);
144
- check("command has a description", typeof pi.commands.get("git-guards")?.description, "string");
145
-
146
- const handler = pi.commands.get("git-guards")!.handler;
147
- const { ctx, notifications } = fakeCtx(commandProject);
119
+ const { handler, ctx, notifications } = setupCommandHarness(
120
+ commandProject,
121
+ "git-guards",
122
+ (pi) => registerGitGuardsCommand(pi as unknown as Parameters<typeof registerGitGuardsCommand>[0]),
123
+ );
148
124
 
149
- ctx.cwd = commandProject;
150
125
  handler("", ctx);
151
126
  check("status notifies once", notifications.length, 1);
152
127
  check("status mentions ACTIVE", notifications[0].message.includes("ACTIVE"), true);
@@ -164,20 +139,10 @@ const commandProject = makeProject();
164
139
  check("on notifies once", notifications.length, 4);
165
140
  check("on effect via loadProjectConfig", loadProjectConfig(commandProject).disableGitGuards, false);
166
141
 
167
- handler("bogus", ctx);
168
- check("unknown arg notifies once", notifications.length, 5);
169
- check("unknown arg shows usage", notifications[4].message.includes("Usage:"), true);
170
- check("unknown arg notification is an error", notifications[4].type, "error");
142
+ checkUnknownArgument(handler, ctx, notifications);
171
143
  }
172
144
  rmSync(commandProject, { recursive: true, force: true });
173
145
 
174
- // ─── result ──────────────────────────────────────────────────────────────────
175
-
176
146
  rmSync(noFile, { recursive: true, force: true });
177
147
 
178
- if (checkFailures() > 0) {
179
- console.error(`\n${checkFailures()} git-guards command test(s) failed.`);
180
- process.exit(1);
181
- } else {
182
- console.log("\nAll git-guards command tests passed.");
183
- }
148
+ finishTest("git-guards command");
package/test/helpers.ts CHANGED
@@ -110,4 +110,100 @@ export async function runIsolatedTest(name: string, body: (root: string) => Prom
110
110
  console.error(error);
111
111
  process.exit(1);
112
112
  });
113
+ }
114
+
115
+ export interface RecordedNotification {
116
+ message: string;
117
+ type: string;
118
+ }
119
+
120
+ export type FakeCommandHandler = (args: string, ctx: unknown) => Promise<void> | void;
121
+
122
+ export interface FakePi {
123
+ commands: Map<string, { description?: string; handler: FakeCommandHandler }>;
124
+ registerCommand: (name: string, options: { description?: string; handler: FakeCommandHandler }) => void;
125
+ }
126
+
127
+ export function fakePi(): FakePi {
128
+ const commands = new Map<string, { description?: string; handler: FakeCommandHandler }>();
129
+ return {
130
+ commands,
131
+ registerCommand: (name, options) => {
132
+ commands.set(name, options);
133
+ },
134
+ };
135
+ }
136
+
137
+ export function fakeCtx(cwd: string) {
138
+ const notifications: RecordedNotification[] = [];
139
+ return {
140
+ notifications,
141
+ ctx: { cwd, ui: { notify: (message: string, type = "info") => notifications.push({ message, type }) } },
142
+ };
143
+ }
144
+
145
+ export interface CommandHarness {
146
+ handler: FakeCommandHandler;
147
+ ctx: { cwd: string; ui: { notify: (message: string, type?: string) => void } };
148
+ notifications: RecordedNotification[];
149
+ }
150
+
151
+ export function setupCommandHarness(
152
+ project: string,
153
+ commandName: string,
154
+ register: (pi: FakePi) => void,
155
+ ): CommandHarness {
156
+ const pi = fakePi();
157
+ register(pi);
158
+ check(`command registered as ${commandName}`, pi.commands.has(commandName), true);
159
+ check("command has a description", typeof pi.commands.get(commandName)?.description, "string");
160
+
161
+ const handler = pi.commands.get(commandName)!.handler;
162
+ const { ctx, notifications } = fakeCtx(project);
163
+ ctx.cwd = project;
164
+
165
+ return { handler, ctx, notifications };
166
+ }
167
+
168
+ export function checkUnknownArgument(
169
+ handler: FakeCommandHandler,
170
+ ctx: unknown,
171
+ notifications: RecordedNotification[],
172
+ ): void {
173
+ const before = notifications.length;
174
+ handler("bogus", ctx);
175
+ check("unknown arg notifies once", notifications.length, before + 1);
176
+ const last = notifications[notifications.length - 1];
177
+ check("unknown arg shows usage", last.message.includes("Usage:"), true);
178
+ check("unknown arg notification is an error", last.type, "error");
179
+ }
180
+
181
+ export interface ConfigFileStatus {
182
+ fileExists: boolean;
183
+ fileValid: boolean;
184
+ }
185
+
186
+ export function checkFileStatusOnInvalidConfig<T extends ConfigFileStatus>(
187
+ label: string,
188
+ statusFn: (cwd: string) => T,
189
+ extra?: (status: T) => void,
190
+ ): void {
191
+ const project = makeProject("{ not json");
192
+ try {
193
+ const status = statusFn(project);
194
+ extra?.(status);
195
+ check(`${label}: invalid JSON → fileExists true`, status.fileExists, true);
196
+ check(`${label}: invalid JSON → fileValid false`, status.fileValid, false);
197
+ } finally {
198
+ rmSync(project, { recursive: true, force: true });
199
+ }
200
+ }
201
+
202
+ export function finishTest(label: string): void {
203
+ if (checkFailures() > 0) {
204
+ console.error(`\n${checkFailures()} ${label} test(s) failed.`);
205
+ process.exit(1);
206
+ } else {
207
+ console.log(`\nAll ${label} tests passed.`);
208
+ }
113
209
  }
@@ -0,0 +1,152 @@
1
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ READ_ONLY_BLOCK_REASON,
5
+ applyReadOnly,
6
+ isReadOnlyMutatingTool,
7
+ parseReadOnlyArgs,
8
+ readOnlyBlockResult,
9
+ readOnlyStatus,
10
+ registerReadOnlyCommand,
11
+ } from "../src/read-only-command";
12
+ import { PROJECT_CONFIG_RELATIVE_PATH, loadProjectConfig } from "../src/project-config";
13
+ import {
14
+ check,
15
+ checkFileStatusOnInvalidConfig,
16
+ checkUnknownArgument,
17
+ finishTest,
18
+ makeProject,
19
+ setupCommandHarness,
20
+ } from "./helpers";
21
+
22
+ check("no args → status", parseReadOnlyArgs(""), "status");
23
+ check("' ' → status", parseReadOnlyArgs(" "), "status");
24
+ check("'status' → status", parseReadOnlyArgs("status"), "status");
25
+ check("'STATUS' → status (case-insensitive)", parseReadOnlyArgs("STATUS"), "status");
26
+ check("'on' → on", parseReadOnlyArgs("on"), "on");
27
+ check("'off' → off", parseReadOnlyArgs("off"), "off");
28
+ check("'foo' → usage", parseReadOnlyArgs("foo"), "usage");
29
+ check("'true' → usage", parseReadOnlyArgs("true"), "usage");
30
+
31
+ const noFile = makeProject();
32
+ {
33
+ const status = readOnlyStatus(noFile);
34
+ check("missing file → readOnly false", status.readOnly, false);
35
+ check("missing file → fileExists false", status.fileExists, false);
36
+ check("missing file → fileValid true", status.fileValid, true);
37
+ }
38
+
39
+ const enabledFile = makeProject('{"readOnly": true}');
40
+ {
41
+ const status = readOnlyStatus(enabledFile);
42
+ check("readOnly: true → readOnly true", status.readOnly, true);
43
+ check("readOnly: true → fileExists true", status.fileExists, true);
44
+ check("readOnly: true → fileValid true", status.fileValid, true);
45
+ }
46
+ rmSync(enabledFile, { recursive: true, force: true });
47
+
48
+ checkFileStatusOnInvalidConfig("read-only", readOnlyStatus, (status) => {
49
+ check("invalid JSON → readOnly false", status.readOnly, false);
50
+ });
51
+
52
+ const empty = makeProject();
53
+ {
54
+ const replacedInvalid = applyReadOnly(empty, true);
55
+ check("on on missing file → no replaced-invalid flag", replacedInvalid, false);
56
+ check("on creates the file", existsSync(join(empty, ...PROJECT_CONFIG_RELATIVE_PATH.split("/"))), true);
57
+ const raw: unknown = JSON.parse(readFileSync(join(empty, ...PROJECT_CONFIG_RELATIVE_PATH.split("/")), "utf8"));
58
+ check("on writes readOnly: true", (raw as Record<string, unknown>).readOnly, true);
59
+ check("on visible via loadProjectConfig", loadProjectConfig(empty).readOnly, true);
60
+
61
+ writeFileSync(
62
+ join(empty, ...PROJECT_CONFIG_RELATIVE_PATH.split("/")),
63
+ '{"disableGitGuards": true, "futureOption": 42}',
64
+ );
65
+ applyReadOnly(empty, false);
66
+ const merged: unknown = JSON.parse(readFileSync(join(empty, ...PROJECT_CONFIG_RELATIVE_PATH.split("/")), "utf8"));
67
+ check("off writes readOnly: false", (merged as Record<string, unknown>).readOnly, false);
68
+ check("off preserves disableGitGuards", (merged as Record<string, unknown>).disableGitGuards, true);
69
+ check("off preserves unrelated keys", (merged as Record<string, unknown>).futureOption, 42);
70
+ check("off visible via loadProjectConfig", loadProjectConfig(empty).readOnly, false);
71
+ }
72
+ rmSync(empty, { recursive: true, force: true });
73
+
74
+ const broken = makeProject("{ not json");
75
+ {
76
+ const replacedInvalid = applyReadOnly(broken, true);
77
+ check("on on invalid file → reports replacement", replacedInvalid, true);
78
+ const status = readOnlyStatus(broken);
79
+ check("invalid file normalized after on", status.fileValid, true);
80
+ check("normalized file → readOnly true", status.readOnly, true);
81
+ }
82
+ rmSync(broken, { recursive: true, force: true });
83
+
84
+ check("write is mutating", isReadOnlyMutatingTool("write"), true);
85
+ check("edit is mutating", isReadOnlyMutatingTool("edit"), true);
86
+ check("apply_patch is mutating", isReadOnlyMutatingTool("apply_patch"), true);
87
+ check("custom apply_patch variant is mutating", isReadOnlyMutatingTool("smart_apply_patch"), true);
88
+ check("bash is mutating", isReadOnlyMutatingTool("bash"), true);
89
+ check("powershell is mutating", isReadOnlyMutatingTool("powershell"), true);
90
+ check("read is read-only", isReadOnlyMutatingTool("read"), false);
91
+ check("grep is read-only", isReadOnlyMutatingTool("grep"), false);
92
+ check("find is read-only", isReadOnlyMutatingTool("find"), false);
93
+ check("ls is read-only", isReadOnlyMutatingTool("ls"), false);
94
+
95
+ check("read-only off → write not blocked", readOnlyBlockResult("write", { readOnly: false }), undefined);
96
+ check(
97
+ "read-only on → write blocked",
98
+ readOnlyBlockResult("write", { readOnly: true }),
99
+ { block: true, reason: READ_ONLY_BLOCK_REASON },
100
+ );
101
+ check(
102
+ "read-only on → bash blocked",
103
+ readOnlyBlockResult("bash", { readOnly: true }),
104
+ { block: true, reason: READ_ONLY_BLOCK_REASON },
105
+ );
106
+ check("read-only on → read allowed", readOnlyBlockResult("read", { readOnly: true }), undefined);
107
+ check("block reason identifies the cause", READ_ONLY_BLOCK_REASON.includes("read-only mode is enabled"), true);
108
+
109
+ const readOnlyProject = makeProject('{"readOnly": true, "disableGitGuards": true}');
110
+ {
111
+ check("read-only project config loads readOnly", loadProjectConfig(readOnlyProject).readOnly, true);
112
+ check("read-only project still parses other keys", loadProjectConfig(readOnlyProject).disableGitGuards, true);
113
+ check(
114
+ "matrix: write blocked in read-only project",
115
+ readOnlyBlockResult("write", loadProjectConfig(readOnlyProject)),
116
+ { block: true, reason: READ_ONLY_BLOCK_REASON },
117
+ );
118
+ }
119
+ rmSync(readOnlyProject, { recursive: true, force: true });
120
+
121
+ const commandProject = makeProject();
122
+ {
123
+ const { handler, ctx, notifications } = setupCommandHarness(
124
+ commandProject,
125
+ "read-only",
126
+ (pi) => registerReadOnlyCommand(pi as unknown as Parameters<typeof registerReadOnlyCommand>[0]),
127
+ );
128
+
129
+ handler("", ctx);
130
+ check("status notifies once", notifications.length, 1);
131
+ check("status mentions INACTIVE", notifications[0].message.includes("INACTIVE"), true);
132
+ check("status mentions missing file", notifications[0].message.includes("missing"), true);
133
+
134
+ handler("on", ctx);
135
+ check("on notifies once", notifications.length, 2);
136
+ check("on notification is a warning", notifications[1].type, "warning");
137
+ check("on effect via loadProjectConfig", loadProjectConfig(commandProject).readOnly, true);
138
+
139
+ handler("", ctx);
140
+ check("status after on mentions ACTIVE", notifications[2].message.includes("ACTIVE"), true);
141
+
142
+ handler("off", ctx);
143
+ check("off notifies once", notifications.length, 4);
144
+ check("off effect via loadProjectConfig", loadProjectConfig(commandProject).readOnly, false);
145
+
146
+ checkUnknownArgument(handler, ctx, notifications);
147
+ }
148
+ rmSync(commandProject, { recursive: true, force: true });
149
+
150
+ rmSync(noFile, { recursive: true, force: true });
151
+
152
+ finishTest("read-only command");