@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 +1 -0
- package/README.md +17 -1
- package/docs/adr/0001-read-only-mode.md +53 -0
- package/package.json +3 -2
- package/src/git-guards-command.ts +6 -52
- package/src/index.ts +11 -4
- package/src/project-config.ts +68 -4
- package/src/read-only-command.ts +131 -0
- 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/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.
|
|
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
|
-
|
|
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
|
@@ -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,
|
package/src/project-config.ts
CHANGED
|
@@ -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:
|
|
65
|
-
disableCommentGuard:
|
|
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 {
|
|
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
|
-
|
|
52
|
-
{
|
|
53
|
-
const status = gitGuardsStatus(invalidFile);
|
|
59
|
+
checkFileStatusOnInvalidConfig("git-guards", gitGuardsStatus, (status) => {
|
|
54
60
|
check("invalid JSON → guards stay enabled", status.guardsEnabled, true);
|
|
55
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
-
|
|
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");
|