pi-mono-sentinel 1.7.2 → 1.10.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
@@ -1,5 +1,75 @@
1
1
  # pi-mono-sentinel
2
2
 
3
+ ## 1.10.0
4
+
5
+ ### Major Changes
6
+
7
+ ### Removed: token vault
8
+
9
+ - Removed the `token-vault` guard (introduced in 1.9.0) — the feature did not work as expected.
10
+ - Deleted `guards/token-vault.ts` and its registration in `index.ts`.
11
+ - Removed `resolve_token`, `list_tokens`, `set_token` tools.
12
+ - Removed `/token` command, bash token placeholder substitution, result sanitizer, and system prompt injection.
13
+
14
+ ## 1.9.3
15
+
16
+ ### Minor Changes
17
+
18
+ ### Added: set_token tool
19
+
20
+ - New `set_token({ name, value })` tool so the LLM can programmatically save a token after collecting it via `ask_user_question`. Completes the full automated flow: ask_user_question → set_token → resolve_token → `$TOKEN_name` in bash.
21
+ - Updated `resolve_token` "not found" error to suggest `set_token` instead of `/token set`.
22
+ - Rewrote system prompt injection to guide the LLM through `ask_user_question` → `set_token` → `resolve_token` flow, removing the old instruction to tell users to run `/token set`.
23
+
24
+ ## 1.9.2
25
+
26
+ ### Patch Changes
27
+
28
+ ### Enhanced: token vault
29
+
30
+ - Inject a system-prompt reminder via `before_agent_start` so the LLM always knows to use `resolve_token`/`list_tokens` for API keys instead of `ask_user_question`.
31
+ - Added `promptSnippet` and `promptGuidelines` to `resolve_token` and `list_tokens` tools for automatic discovery.
32
+
33
+ ## 1.9.1
34
+
35
+ ### Patch Changes
36
+
37
+ ### Enhanced: token vault
38
+
39
+ - Added `promptSnippet` and `promptGuidelines` to `resolve_token` and `list_tokens` tools so the LLM automatically discovers and proactively uses them for authentication without user prompting.
40
+
41
+ ## 1.9.0
42
+
43
+ ### Minor Changes
44
+
45
+ ### Added: token vault
46
+
47
+ - Added secure local token storage at `~/.pi/agent/tokens.json` with owner-only file permissions.
48
+ - New LLM-safe tools: `resolve_token({ name })` returns only a masked confirmation and `list_tokens({})` lists names without values.
49
+ - Resolved tokens can be used in bash via `$TOKEN_name` placeholder substitution and injected environment variables without exposing secrets to the model transcript.
50
+ - Direct `read` / `write` / `edit` access to `tokens.json` is blocked; bash and read results are sanitized if a stored token value appears.
51
+ - New `/token set|list|get|delete|env` command for user-side token management.
52
+
53
+ ## 1.8.0
54
+
55
+ ### Minor Changes
56
+
57
+ ### Added: permission-gate guard
58
+
59
+ - New third guard `permission-gate` proactively intercepts raw `bash` commands and `write` / `edit` calls that perform out-of-scope or system-level operations — closing the gap left by `execution-tracker`, which only fires for files written earlier in the same session.
60
+ - Bash risk classes: `remote-pipe-exec` (`curl|wget … | bash|sh|zsh`), `privilege-escalation` (`sudo`), `destructive-system-rm` (`rm -rf` on `/usr`, `/Library`, `/System`, `/opt`, `/etc`, `/var`, `/bin`, `/sbin`, `/private`, or `~`), `package-manager-install` (`brew install/upgrade/update/reinstall`), `persistence` (`crontab`, `systemctl enable`, `launchctl load`), `shell-config-write` (redirects/`tee` into `~/.zshrc`, `~/.bashrc`, etc.), `system-binary-install` (`cp`/`mv`/`install`/`ln` into `/usr/local/bin`).
61
+ - Path categories for `write` / `edit`: `shell-config`, `system-directory`, `outside-project`. Path resolution handles `~` expansion and `cwd`-relative paths correctly.
62
+ - Project-local `rm -rf` (e.g. `node_modules`, `dist`) is intentionally not flagged — only system/home roots.
63
+ - Fail-safe behavior: when no UI is available, dangerous operations are blocked with a descriptive `reason`; when UI is present, the user gets a single combined `confirm()` showing all matched labels.
64
+
65
+ ### Tests
66
+
67
+ - New `permissions` suite covering bash command classification, path resolution (`~` expansion, cwd-relative, absolute) and path category classification.
68
+
69
+ ### Documentation
70
+
71
+ - Updated sentinel README with the new guard, risk-class table, path-category table and decision matrix.
72
+
3
73
  ## 1.7.2
4
74
 
5
75
  ### Patch Changes
@@ -8,7 +78,6 @@
8
78
 
9
79
  - Remove unused `StringEnum` import from `@mariozechner/pi-ai`.
10
80
 
11
-
12
81
  ## 1.7.1
13
82
 
14
83
  ### Patch Changes
@@ -35,7 +104,6 @@
35
104
 
36
105
  - New `intent-queue` and `model-config` suites; expanded coverage across `leader-runtime`, `team-manager`, `team-query-tool` and `formatters`.
37
106
 
38
-
39
107
  ## 1.7.0
40
108
 
41
109
  ### Minor Changes
package/README.md CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  The `sentinel` extension adds content-aware security guards that intercept tool calls before they execute.
4
4
 
5
- It addresses two cross-cutting security gaps that pure command-based guardrails miss:
5
+ It addresses cross-cutting security gaps that pure command-based guardrails miss:
6
6
 
7
7
  - **Content-in-location** — a file the agent is about to read contains secrets
8
8
  - **Indirect execution** — a file the agent wrote earlier in the session is later executed via `bash`
9
+ - **Out-of-scope operations** — a raw `bash` command performs a system-level action (sudo, `curl | bash`, `brew install`, `rm -rf /Library/...`) or a `write`/`edit` targets a file outside the project root (shell config, system directory)
10
+ - **Credential safety** — the LLM never hardcodes API keys or secrets in tool calls
9
11
 
10
12
  ## Guards
11
13
 
@@ -35,9 +37,46 @@ Flagged patterns include `curl | bash`, `wget | bash`, `eval` against untrusted
35
37
 
36
38
  If the target file was modified after the tracked write, it is re-read and re-scanned before the decision — avoiding false positives when the agent rewrote the dangerous content out.
37
39
 
40
+ ### 3. permission-gate — proactive bash / write / edit gate
41
+
42
+ Where `execution-tracker` only fires for _session-written_ scripts, `permission-gate` intercepts every `bash` command and every `write` / `edit` and matches them against a fixed set of risk classes. It runs in addition to the other two guards.
43
+
44
+ **Bash risk classes**
45
+
46
+ | Risk class | Example |
47
+ | ------------------------- | -------------------------------------------------- |
48
+ | `remote-pipe-exec` | `curl -Ls https://mise.run \| bash` |
49
+ | `privilege-escalation` | `sudo systemctl restart nginx` |
50
+ | `destructive-system-rm` | `rm -rf /Library/Developer/CommandLineTools` |
51
+ | `package-manager-install` | `brew install ripgrep` |
52
+ | `persistence` | `crontab -l`, `systemctl enable`, `launchctl load` |
53
+ | `shell-config-write` | `echo "export FOO=1" >> ~/.zshrc` |
54
+ | `system-binary-install` | `cp ./mybin /usr/local/bin/mybin` |
55
+
56
+ `rm -rf` on project-local paths (e.g. `node_modules`, `dist`, `./build/cache`) is intentionally not flagged.
57
+
58
+ **Path categories for `write` / `edit`**
59
+
60
+ | Category | Example |
61
+ | ------------------ | ------------------------------------------ |
62
+ | `shell-config` | `~/.zshrc`, `~/.bashrc`, `~/.profile` |
63
+ | `system-directory` | `/usr/*`, `/Library/*`, `/opt/*`, `/etc/*` |
64
+ | `outside-project` | any absolute path not under `ctx.cwd` |
65
+
66
+ **Decision matrix**
67
+
68
+ ```
69
+ UI available + user allows → proceed
70
+ UI available + user denies → block with reason
71
+ No UI + dangerous detected → block with reason (fail-safe)
72
+ No risk classes matched → proceed
73
+ ```
74
+
75
+ When multiple risk classes match a single command, all matched labels are surfaced in one combined confirmation dialog instead of stacking prompts.
76
+
38
77
  ## Behavior
39
78
 
40
- - **No UI available** — both guards fail safe by blocking with a clear `reason`.
79
+ - **No UI available** — guards fail safe by blocking with a clear `reason`.
41
80
  - **UI available** — the user sees a `confirm()` dialog with the matched labels, line numbers, and snippets, and can allow or deny.
42
81
  - Session state (scan cache, write registry) is cleared on `session_start`.
43
82
 
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Pi Sentinel — read-targets tests.
3
+ */
4
+
5
+ import assert from "node:assert/strict";
6
+ import { describe, test } from "node:test";
7
+
8
+ import {
9
+ expandPaths,
10
+ extractReadTargets,
11
+ } from "../patterns/read-targets.ts";
12
+
13
+ describe("extractReadTargets", () => {
14
+ test("detects cat, head, tail, less, more", () => {
15
+ assert.deepEqual(extractReadTargets("cat .env"), [".env"]);
16
+ assert.deepEqual(extractReadTargets("head -n 5 .env.local"), [
17
+ "5",
18
+ ".env.local",
19
+ ]);
20
+ assert.deepEqual(extractReadTargets("tail -f logs/app.log"), [
21
+ "logs/app.log",
22
+ ]);
23
+ assert.deepEqual(extractReadTargets("less README.md"), ["README.md"]);
24
+ assert.deepEqual(extractReadTargets("more config.json"), ["config.json"]);
25
+ });
26
+
27
+ test("skips flags for direct commands", () => {
28
+ assert.deepEqual(extractReadTargets("cat -n .env"), [".env"]);
29
+ assert.deepEqual(extractReadTargets("head --lines=10 .env"), [".env"]);
30
+ });
31
+
32
+ test("detects grep and rg", () => {
33
+ assert.deepEqual(extractReadTargets('grep -ri "linear" .env*'), [
34
+ ".env*",
35
+ ]);
36
+ assert.deepEqual(
37
+ extractReadTargets("rg --hidden api_key src/"),
38
+ ["api_key", "src/"],
39
+ );
40
+ assert.deepEqual(
41
+ extractReadTargets("grep pattern file1 file2"),
42
+ ["pattern", "file1", "file2"],
43
+ );
44
+ });
45
+
46
+ test("skips quoted patterns in grep/rg", () => {
47
+ assert.deepEqual(
48
+ extractReadTargets('grep -i "secret" .env .env.local'),
49
+ [".env", ".env.local"],
50
+ );
51
+ assert.deepEqual(
52
+ extractReadTargets("grep -E 'password|token' .env"),
53
+ [".env"],
54
+ );
55
+ });
56
+
57
+ test("stops at shell operators for grep/rg", () => {
58
+ assert.deepEqual(
59
+ extractReadTargets('grep x .env | head -5'),
60
+ ["x", ".env"],
61
+ );
62
+ assert.deepEqual(
63
+ extractReadTargets("grep x .env && cat .env"),
64
+ ["x", ".env"],
65
+ );
66
+ });
67
+
68
+ test("detects awk and sed", () => {
69
+ assert.deepEqual(
70
+ extractReadTargets("awk '{print $1}' .env"),
71
+ [".env"],
72
+ );
73
+ assert.deepEqual(
74
+ extractReadTargets("sed 's/foo/bar/g' .env"),
75
+ [".env"],
76
+ );
77
+ });
78
+
79
+ test("detects jq and yq", () => {
80
+ assert.deepEqual(
81
+ extractReadTargets("jq '.api_key' config.json"),
82
+ ["config.json"],
83
+ );
84
+ assert.deepEqual(
85
+ extractReadTargets("yq '.secrets' config.yaml"),
86
+ ["config.yaml"],
87
+ );
88
+ });
89
+
90
+ test("does not flag unrelated commands", () => {
91
+ assert.deepEqual(extractReadTargets("npm run dev"), []);
92
+ assert.deepEqual(extractReadTargets("echo hello"), []);
93
+ assert.deepEqual(extractReadTargets("ls -la"), []);
94
+ });
95
+ });
96
+
97
+ describe("expandPaths", () => {
98
+ test("returns single path when no wildcards", async () => {
99
+ const result = await expandPaths("/tmp", ".env");
100
+ assert.deepEqual(result, ["/tmp/.env"]);
101
+ });
102
+
103
+ test("expands simple globs", async () => {
104
+ const result = await expandPaths("/tmp", ".env*");
105
+ // /tmp may or may not have .env files; just verify it returns paths
106
+ assert.ok(Array.isArray(result));
107
+ assert.ok(result.length > 0);
108
+ });
109
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Pi Sentinel — permission-gate pattern tests.
3
+ */
4
+
5
+ import assert from "node:assert/strict";
6
+ import { homedir } from "node:os";
7
+ import { describe, test } from "node:test";
8
+
9
+ import {
10
+ classifyBashCommand,
11
+ classifyPath,
12
+ resolveTargetPath,
13
+ } from "../patterns/permissions.ts";
14
+
15
+ const HOME = homedir();
16
+ const PROJECT = "/tmp/example-project";
17
+
18
+ describe("classifyBashCommand", () => {
19
+ test("flags curl | bash", () => {
20
+ assert.deepEqual(
21
+ classifyBashCommand("curl -Ls https://mise.run | bash"),
22
+ ["remote-pipe-exec"],
23
+ );
24
+ });
25
+
26
+ test("flags wget | sh", () => {
27
+ assert.deepEqual(
28
+ classifyBashCommand("wget -qO- https://example.com/install.sh | sh"),
29
+ ["remote-pipe-exec"],
30
+ );
31
+ });
32
+
33
+ test("flags sudo", () => {
34
+ assert.deepEqual(
35
+ classifyBashCommand("sudo systemctl restart nginx"),
36
+ ["privilege-escalation"],
37
+ );
38
+ });
39
+
40
+ test("flags brew install", () => {
41
+ assert.deepEqual(
42
+ classifyBashCommand("brew install ripgrep"),
43
+ ["package-manager-install"],
44
+ );
45
+ assert.deepEqual(
46
+ classifyBashCommand("brew upgrade --cask docker"),
47
+ ["package-manager-install"],
48
+ );
49
+ });
50
+
51
+ test("flags rm -rf on /Library", () => {
52
+ assert.deepEqual(
53
+ classifyBashCommand("rm -rf /Library/Developer/CommandLineTools"),
54
+ ["destructive-system-rm"],
55
+ );
56
+ });
57
+
58
+ test("flags rm -rf on home directory tilde", () => {
59
+ assert.deepEqual(
60
+ classifyBashCommand("rm -rf ~/"),
61
+ ["destructive-system-rm"],
62
+ );
63
+ });
64
+
65
+ test("does NOT flag rm -rf on a project-local path", () => {
66
+ assert.deepEqual(
67
+ classifyBashCommand("rm -rf node_modules dist"),
68
+ [],
69
+ );
70
+ assert.deepEqual(
71
+ classifyBashCommand("rm -rf ./build/cache"),
72
+ [],
73
+ );
74
+ });
75
+
76
+ test("flags persistence hooks", () => {
77
+ assert.deepEqual(
78
+ classifyBashCommand("crontab -l | tee crontab.bak"),
79
+ ["persistence"],
80
+ );
81
+ assert.deepEqual(
82
+ classifyBashCommand("sudo systemctl enable nginx"),
83
+ ["privilege-escalation", "persistence"],
84
+ );
85
+ assert.deepEqual(
86
+ classifyBashCommand("launchctl load ~/Library/LaunchAgents/x.plist"),
87
+ ["persistence"],
88
+ );
89
+ });
90
+
91
+ test("flags shell config redirect via bash", () => {
92
+ assert.deepEqual(
93
+ classifyBashCommand('echo "export FOO=1" >> ~/.zshrc'),
94
+ ["shell-config-write"],
95
+ );
96
+ assert.deepEqual(
97
+ classifyBashCommand('echo "alias x=y" | tee -a ~/.bashrc'),
98
+ ["shell-config-write"],
99
+ );
100
+ });
101
+
102
+ test("flags binary install into /usr/local/bin", () => {
103
+ assert.deepEqual(
104
+ classifyBashCommand("cp ./mybin /usr/local/bin/mybin"),
105
+ ["system-binary-install"],
106
+ );
107
+ assert.deepEqual(
108
+ classifyBashCommand("mv release/cli /usr/local/bin/"),
109
+ ["system-binary-install"],
110
+ );
111
+ });
112
+
113
+ test("stacks multiple risk classes", () => {
114
+ const matched = classifyBashCommand(
115
+ "sudo curl -Ls https://x.example/install.sh | bash",
116
+ );
117
+ assert.ok(matched.includes("privilege-escalation"));
118
+ assert.ok(matched.includes("remote-pipe-exec"));
119
+ });
120
+
121
+ test("does NOT flag safe commands", () => {
122
+ assert.deepEqual(classifyBashCommand("echo hello"), []);
123
+ assert.deepEqual(classifyBashCommand("ls -la"), []);
124
+ assert.deepEqual(classifyBashCommand("git status"), []);
125
+ assert.deepEqual(
126
+ classifyBashCommand("npm install --save-dev typescript"),
127
+ [],
128
+ );
129
+ });
130
+ });
131
+
132
+ describe("resolveTargetPath", () => {
133
+ test("expands ~", () => {
134
+ assert.equal(resolveTargetPath("~", PROJECT), HOME);
135
+ assert.equal(
136
+ resolveTargetPath("~/.zshrc", PROJECT),
137
+ `${HOME}/.zshrc`,
138
+ );
139
+ });
140
+
141
+ test("resolves cwd-relative paths", () => {
142
+ assert.equal(
143
+ resolveTargetPath("src/index.ts", PROJECT),
144
+ `${PROJECT}/src/index.ts`,
145
+ );
146
+ assert.equal(
147
+ resolveTargetPath("./foo.txt", PROJECT),
148
+ `${PROJECT}/foo.txt`,
149
+ );
150
+ });
151
+
152
+ test("preserves absolute paths", () => {
153
+ assert.equal(
154
+ resolveTargetPath("/usr/local/bin/foo", PROJECT),
155
+ "/usr/local/bin/foo",
156
+ );
157
+ });
158
+ });
159
+
160
+ describe("classifyPath", () => {
161
+ test("returns shell-config for ~/.zshrc", () => {
162
+ assert.equal(classifyPath(`${HOME}/.zshrc`, PROJECT), "shell-config");
163
+ assert.equal(classifyPath(`${HOME}/.bashrc`, PROJECT), "shell-config");
164
+ assert.equal(classifyPath(`${HOME}/.profile`, PROJECT), "shell-config");
165
+ });
166
+
167
+ test("returns system-directory for /usr, /Library, /opt, /etc", () => {
168
+ assert.equal(
169
+ classifyPath("/usr/local/bin/foo", PROJECT),
170
+ "system-directory",
171
+ );
172
+ assert.equal(
173
+ classifyPath("/Library/LaunchDaemons/foo.plist", PROJECT),
174
+ "system-directory",
175
+ );
176
+ assert.equal(classifyPath("/opt/homebrew/bin/x", PROJECT), "system-directory");
177
+ assert.equal(classifyPath("/etc/hosts", PROJECT), "system-directory");
178
+ });
179
+
180
+ test("returns outside-project for paths outside cwd", () => {
181
+ assert.equal(
182
+ classifyPath("/tmp/elsewhere/foo.txt", PROJECT),
183
+ "outside-project",
184
+ );
185
+ assert.equal(
186
+ classifyPath(`${HOME}/Desktop/notes.txt`, PROJECT),
187
+ "outside-project",
188
+ );
189
+ });
190
+
191
+ test("returns null for in-project paths", () => {
192
+ assert.equal(classifyPath(`${PROJECT}/src/index.ts`, PROJECT), null);
193
+ assert.equal(classifyPath(`${PROJECT}/package.json`, PROJECT), null);
194
+ });
195
+
196
+ test("does not confuse a sibling dir with the project root", () => {
197
+ assert.equal(
198
+ classifyPath("/tmp/example-project-other/file.txt", PROJECT),
199
+ "outside-project",
200
+ );
201
+ });
202
+ });
@@ -20,6 +20,10 @@ import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
20
20
 
21
21
  import type { SentinelSession } from "../session.js";
22
22
  import type { ScanMatch } from "../types.js";
23
+ import {
24
+ expandPaths,
25
+ extractReadTargets,
26
+ } from "../patterns/read-targets.js";
23
27
  import {
24
28
  isBinaryContent,
25
29
  MAX_SCAN_BYTES,
@@ -43,24 +47,6 @@ function formatConfirmMessage(matches: ScanMatch[]): string {
43
47
  ].join("\n");
44
48
  }
45
49
 
46
- /**
47
- * Extract file paths from bash commands that read file content.
48
- * Targets: cat, head, tail, less, more.
49
- */
50
- function extractReadTargets(command: string): string[] {
51
- const pattern = /\b(?:cat|head|tail|less|more)\s+([^\s|;&]+)/g;
52
- const paths: string[] = [];
53
- let match: RegExpExecArray | null;
54
- while ((match = pattern.exec(command)) !== null) {
55
- const target = match[1];
56
- // Skip flags (start with -)
57
- if (!target.startsWith("-")) {
58
- paths.push(target);
59
- }
60
- }
61
- return paths;
62
- }
63
-
64
50
  /**
65
51
  * Pre-read a file and scan for secrets. Uses the session scan cache to
66
52
  * avoid redundant filesystem reads on unchanged files.
@@ -159,13 +145,15 @@ export function registerOutputScanner(
159
145
  const targets = extractReadTargets(command);
160
146
  if (targets.length === 0) return;
161
147
 
162
- // Scan all targeted files
148
+ // Scan all targeted files (with glob expansion)
163
149
  const allMatches: Array<{ path: string; matches: ScanMatch[] }> = [];
164
150
  for (const target of targets) {
165
- const absolutePath = resolve(ctx.cwd, target);
166
- const matches = await scanFile(absolutePath, session);
167
- if (matches.length > 0) {
168
- allMatches.push({ path: target, matches });
151
+ const absolutePaths = await expandPaths(ctx.cwd, target);
152
+ for (const absolutePath of absolutePaths) {
153
+ const matches = await scanFile(absolutePath, session);
154
+ if (matches.length > 0) {
155
+ allMatches.push({ path: target, matches });
156
+ }
169
157
  }
170
158
  }
171
159
 
@@ -0,0 +1,136 @@
1
+ /**
2
+ * permission-gate — proactive bash / write / edit guard.
3
+ *
4
+ * Complements `execution-tracker` (which only fires for *session-written*
5
+ * scripts) by intercepting raw bash commands and out-of-scope writes that
6
+ * perform system-level operations: `curl | bash`, `sudo`, `brew install`,
7
+ * `rm -rf /Library/...`, writes to `~/.zshrc`, `/usr/local/bin`, etc.
8
+ *
9
+ * Decision matrix:
10
+ * - UI available + user allows → proceed
11
+ * - UI available + user denies → block with reason
12
+ * - No UI + dangerous detected → block with reason (fail-safe)
13
+ * - No dangerous patterns → proceed
14
+ */
15
+
16
+ import type {
17
+ ExtensionAPI,
18
+ ExtensionContext,
19
+ } from "@mariozechner/pi-coding-agent";
20
+ import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
21
+
22
+ import {
23
+ BASH_RISK_DESCRIPTIONS,
24
+ PATH_CATEGORY_DESCRIPTIONS,
25
+ classifyBashCommand,
26
+ classifyPath,
27
+ resolveTargetPath,
28
+ } from "../patterns/permissions.js";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Bash gating
32
+ // ---------------------------------------------------------------------------
33
+
34
+ function registerBashGate(pi: ExtensionAPI): void {
35
+ pi.on("tool_call", async (event, ctx) => {
36
+ if (!isToolCallEventType("bash", event)) return;
37
+
38
+ const command = event.input.command ?? "";
39
+ if (!command) return;
40
+
41
+ const risks = classifyBashCommand(command);
42
+ if (risks.length === 0) return;
43
+
44
+ const labelLines = risks.map(
45
+ (risk) => ` - ${risk}: ${BASH_RISK_DESCRIPTIONS[risk]}`,
46
+ );
47
+ const message = [
48
+ "Bash command matched permission-gate risk classes:",
49
+ ...labelLines,
50
+ "",
51
+ `Command:`,
52
+ ` ${command}`,
53
+ "",
54
+ "Allow execution?",
55
+ ].join("\n");
56
+
57
+ if (ctx.hasUI) {
58
+ const allowed = await ctx.ui.confirm(
59
+ "[sentinel] Permission gate — bash",
60
+ message,
61
+ );
62
+ if (allowed) return;
63
+ }
64
+
65
+ return {
66
+ block: true,
67
+ reason:
68
+ `[sentinel] Blocked bash command (${risks.join(", ")}). ` +
69
+ `Command: ${command}`,
70
+ };
71
+ });
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Write / edit gating
76
+ // ---------------------------------------------------------------------------
77
+
78
+ function registerPathGate(pi: ExtensionAPI): void {
79
+ const handler = async (
80
+ rawPath: string | undefined,
81
+ toolName: "write" | "edit",
82
+ ctx: ExtensionContext,
83
+ ): Promise<{ block: true; reason: string } | undefined> => {
84
+ if (!rawPath) return;
85
+
86
+ const absolute = resolveTargetPath(rawPath, ctx.cwd);
87
+ const category = classifyPath(absolute, ctx.cwd);
88
+ if (!category) return;
89
+
90
+ const message = [
91
+ `${toolName === "write" ? "Write" : "Edit"} targets a ${PATH_CATEGORY_DESCRIPTIONS[category]}:`,
92
+ ` Path: ${absolute}`,
93
+ "",
94
+ category === "shell-config"
95
+ ? "This is a persistent user shell configuration change."
96
+ : category === "system-directory"
97
+ ? "This modifies a system directory and may affect other applications."
98
+ : "This path is outside the current project root.",
99
+ "",
100
+ "Allow this write?",
101
+ ].join("\n");
102
+
103
+ if (ctx.hasUI) {
104
+ const allowed = await ctx.ui.confirm(
105
+ `[sentinel] Permission gate — ${toolName}`,
106
+ message,
107
+ );
108
+ if (allowed) return;
109
+ }
110
+
111
+ return {
112
+ block: true,
113
+ reason:
114
+ `[sentinel] Blocked ${toolName} to ${PATH_CATEGORY_DESCRIPTIONS[category]}: ${absolute}`,
115
+ };
116
+ };
117
+
118
+ pi.on("tool_call", async (event, ctx) => {
119
+ if (!isToolCallEventType("write", event)) return;
120
+ return handler(event.input.path, "write", ctx);
121
+ });
122
+
123
+ pi.on("tool_call", async (event, ctx) => {
124
+ if (!isToolCallEventType("edit", event)) return;
125
+ return handler(event.input.path, "edit", ctx);
126
+ });
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Public registration
131
+ // ---------------------------------------------------------------------------
132
+
133
+ export function registerPermissionGate(pi: ExtensionAPI): void {
134
+ registerBashGate(pi);
135
+ registerPathGate(pi);
136
+ }
package/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * sentinel — content-aware security guard for pi coding agents.
3
3
  *
4
- * Two guards addressing cross-cutting security gaps:
4
+ * Guards addressing cross-cutting security gaps:
5
5
  *
6
6
  * 1. **output-scanner** (Gap 2 — content-in-location):
7
7
  * Pre-reads files before `read` tool calls and scans for secret patterns.
@@ -11,6 +11,11 @@
11
11
  * Tracks files written during the session and scans for dangerous patterns.
12
12
  * When `bash` executes a file written this session, correlates the write
13
13
  * with the execution and asks/denies based on flagged content.
14
+ *
15
+ * 3. **permission-gate** (Gap 4 — out-of-scope operations):
16
+ * Intercepts raw bash commands and write/edit calls that perform
17
+ * system-level operations (sudo, curl|bash, brew install, writes to
18
+ * shell configs / system directories, rm -rf on system paths).
14
19
  */
15
20
 
16
21
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
@@ -18,6 +23,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
18
23
  import { SentinelSession } from "./session.js";
19
24
  import { registerOutputScanner } from "./guards/output-scanner.js";
20
25
  import { registerExecutionTracker } from "./guards/execution-tracker.js";
26
+ import { registerPermissionGate } from "./guards/permission-gate.js";
21
27
 
22
28
  export default function (pi: ExtensionAPI): void {
23
29
  const session = new SentinelSession();
@@ -31,4 +37,7 @@ export default function (pi: ExtensionAPI): void {
31
37
 
32
38
  // Gap 3: track writes + correlate with bash execution
33
39
  registerExecutionTracker(pi, session);
40
+
41
+ // Gap 4: proactive permission gate for bash + out-of-scope writes
42
+ registerPermissionGate(pi);
34
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mono-sentinel",
3
- "version": "1.7.2",
3
+ "version": "1.10.0",
4
4
  "description": "Pi extension that guards against content-based secret leaks and indirect script execution",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -19,5 +19,8 @@
19
19
  "type": "git",
20
20
  "url": "git+https://github.com/emanuelcasco/pi-mono-extensions.git",
21
21
  "directory": "extensions/sentinel"
22
+ },
23
+ "scripts": {
24
+ "test": "npx tsx --test '__tests__/**/*.test.ts'"
22
25
  }
23
26
  }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Permission-gate pattern matchers.
3
+ *
4
+ * Pure helpers (no I/O, no extension API) so they can be unit-tested in
5
+ * isolation from the pi runtime.
6
+ */
7
+
8
+ import { homedir } from "node:os";
9
+ import { isAbsolute, normalize, resolve, sep } from "node:path";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Bash risk classes
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export type BashRiskClass =
16
+ | "remote-pipe-exec"
17
+ | "privilege-escalation"
18
+ | "destructive-system-rm"
19
+ | "package-manager-install"
20
+ | "persistence"
21
+ | "shell-config-write"
22
+ | "system-binary-install";
23
+
24
+ type BashPattern = {
25
+ label: BashRiskClass;
26
+ pattern: RegExp;
27
+ };
28
+
29
+ const BASH_PATTERNS: readonly BashPattern[] = [
30
+ {
31
+ label: "remote-pipe-exec",
32
+ pattern: /(?:curl|wget)\b[^\n|]*\|\s*(?:bash|sh|zsh)\b/,
33
+ },
34
+ {
35
+ label: "privilege-escalation",
36
+ pattern: /\bsudo\b/,
37
+ },
38
+ {
39
+ // rm -rf targeting system roots or the user's home directory itself.
40
+ // Project-local rm -rf is intentionally NOT matched here.
41
+ label: "destructive-system-rm",
42
+ pattern:
43
+ /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\b[^\n;]*?(?:\s|=)(?:\/(?:usr|Library|System|opt|etc|var|bin|sbin|private)(?:\/|\b)|~(?:\/|\s|$)|\$HOME\b)/,
44
+ },
45
+ {
46
+ label: "package-manager-install",
47
+ pattern: /\bbrew\s+(?:install|upgrade|update|reinstall)\b/,
48
+ },
49
+ {
50
+ label: "persistence",
51
+ pattern: /\b(?:crontab\s+-|systemctl\s+enable|launchctl\s+(?:load|bootstrap))\b/,
52
+ },
53
+ {
54
+ label: "shell-config-write",
55
+ pattern:
56
+ /(?:>>?|tee\b[^\n|]*)\s*~?\/?\.?(?:zshrc|bashrc|bash_profile|profile|zprofile|zshenv|zlogin|inputrc)\b/,
57
+ },
58
+ {
59
+ label: "system-binary-install",
60
+ pattern:
61
+ /\b(?:cp|mv|install|ln)\b[^\n|;]*\s\/usr\/local\/(?:bin|sbin|lib)\/?/,
62
+ },
63
+ ];
64
+
65
+ /** Scan a bash command string. Returns matched risk-class labels. */
66
+ export function classifyBashCommand(command: string): BashRiskClass[] {
67
+ const matched: BashRiskClass[] = [];
68
+ for (const { label, pattern } of BASH_PATTERNS) {
69
+ if (pattern.test(command)) matched.push(label);
70
+ }
71
+ return matched;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Path classification
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export type PathCategory =
79
+ | "shell-config"
80
+ | "system-directory"
81
+ | "outside-project";
82
+
83
+ const SYSTEM_PREFIXES: readonly string[] = [
84
+ "/usr/",
85
+ "/Library/",
86
+ "/System/",
87
+ "/opt/",
88
+ "/etc/",
89
+ "/var/",
90
+ "/bin/",
91
+ "/sbin/",
92
+ "/private/",
93
+ ];
94
+
95
+ const SHELL_CONFIG_BASENAMES: readonly string[] = [
96
+ ".zshrc",
97
+ ".bashrc",
98
+ ".bash_profile",
99
+ ".profile",
100
+ ".zprofile",
101
+ ".zshenv",
102
+ ".zlogin",
103
+ ".inputrc",
104
+ ".config/fish/config.fish",
105
+ ];
106
+
107
+ /** Resolve a raw user-supplied path to an absolute, normalized form. */
108
+ export function resolveTargetPath(rawPath: string, cwd: string): string {
109
+ const home = homedir();
110
+ let expanded = rawPath;
111
+
112
+ if (expanded === "~") {
113
+ expanded = home;
114
+ } else if (expanded.startsWith("~/")) {
115
+ expanded = `${home}/${expanded.slice(2)}`;
116
+ }
117
+
118
+ const absolute = isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
119
+ return normalize(absolute);
120
+ }
121
+
122
+ /**
123
+ * Classify an absolute path against permission-gate categories.
124
+ *
125
+ * Returns the matched category (most-specific wins: shell-config first, then
126
+ * system-directory, then outside-project) or `null` for safe in-project paths.
127
+ */
128
+ export function classifyPath(
129
+ absolutePath: string,
130
+ projectRoot: string,
131
+ ): PathCategory | null {
132
+ const home = homedir();
133
+
134
+ // 1. Shell config files
135
+ for (const basename of SHELL_CONFIG_BASENAMES) {
136
+ const candidate = normalize(`${home}/${basename}`);
137
+ if (absolutePath === candidate) return "shell-config";
138
+ }
139
+
140
+ // 2. System directories
141
+ for (const prefix of SYSTEM_PREFIXES) {
142
+ if (absolutePath.startsWith(prefix)) return "system-directory";
143
+ }
144
+
145
+ // 3. Outside project root
146
+ const root = normalize(projectRoot).replace(/\/$/, "");
147
+ if (
148
+ absolutePath !== root &&
149
+ !absolutePath.startsWith(`${root}${sep}`)
150
+ ) {
151
+ return "outside-project";
152
+ }
153
+
154
+ return null;
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Human-readable labels
159
+ // ---------------------------------------------------------------------------
160
+
161
+ export const BASH_RISK_DESCRIPTIONS: Record<BashRiskClass, string> = {
162
+ "remote-pipe-exec": "Pipes a remote download into a shell",
163
+ "privilege-escalation": "Runs with elevated privileges (sudo)",
164
+ "destructive-system-rm": "Recursively deletes a system or home path",
165
+ "package-manager-install": "Installs/upgrades a system package",
166
+ persistence: "Installs a persistence hook (cron / launchd / systemd)",
167
+ "shell-config-write": "Modifies a user shell config file",
168
+ "system-binary-install": "Installs a binary into a system path",
169
+ };
170
+
171
+ export const PATH_CATEGORY_DESCRIPTIONS: Record<PathCategory, string> = {
172
+ "shell-config": "user shell configuration file",
173
+ "system-directory": "system directory",
174
+ "outside-project": "path outside the project root",
175
+ };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Pure helpers for extracting file-read targets from bash commands.
3
+ * Kept separate from output-scanner.ts so they can be unit-tested
4
+ * without pulling in ExtensionAPI imports.
5
+ */
6
+
7
+ import { readdir } from "node:fs/promises";
8
+ import { basename, dirname, join, resolve } from "node:path";
9
+
10
+ /**
11
+ * Strip quoted substrings so they don't confuse the tokenizer.
12
+ */
13
+ function stripQuotes(s: string): string {
14
+ return s.replace(/"[^"]*"/g, " ").replace(/'[^']*'/g, " ");
15
+ }
16
+
17
+ /**
18
+ * Extract file paths from bash commands that read file content.
19
+ * Targets: cat, head, tail, less, more, grep, rg, sed, awk, strings,
20
+ * nl, sort, uniq, wc, diff, tac, rev, xxd, hexdump, od, base64, file,
21
+ * jq, yq, and similar file-reading utilities.
22
+ */
23
+ export function extractReadTargets(command: string): string[] {
24
+ const paths: string[] = [];
25
+
26
+ // Commands that take a script / filter / pattern first, then files:
27
+ // grep [options] pattern file...
28
+ // rg [options] pattern file...
29
+ // awk [options] 'script' file...
30
+ // sed [options] 'script' file...
31
+ // jq [options] filter file...
32
+ const scriptCommands =
33
+ /\b(?:grep|rg|egrep|fgrep|awk|sed|perl|python3?|jq|yq)\b/g;
34
+ let sm: RegExpExecArray | null;
35
+ while ((sm = scriptCommands.exec(command)) !== null) {
36
+ const tail = command.slice(sm.index + sm[0].length);
37
+ const tokens = stripQuotes(tail)
38
+ .split(/\s+/)
39
+ .filter(Boolean);
40
+ for (const token of tokens) {
41
+ // Stop at shell operators and redirects
42
+ if (/^[|;&]|^(\d?[<>]|>>|<<)/.test(token)) break;
43
+ // Skip flags
44
+ if (token.startsWith("-")) continue;
45
+ paths.push(token);
46
+ }
47
+ }
48
+
49
+ // Commands that take files directly after the command name.
50
+ const directCommands =
51
+ /\b(?:cat|head|tail|less|more|nl|tac|rev|strings|xxd|hexdump|od|base64|file|sort|uniq|wc|diff|comm|join|cut|paste|column)\b/g;
52
+ let dm: RegExpExecArray | null;
53
+ while ((dm = directCommands.exec(command)) !== null) {
54
+ const tail = command.slice(dm.index + dm[0].length);
55
+ const tokens = stripQuotes(tail)
56
+ .split(/\s+/)
57
+ .filter(Boolean);
58
+ for (const token of tokens) {
59
+ // Stop at shell operators and redirects
60
+ if (/^[|;&]|^(\d?[<>]|>>|<<)/.test(token)) break;
61
+ // Skip flags
62
+ if (token.startsWith("-")) continue;
63
+ paths.push(token);
64
+ }
65
+ }
66
+
67
+ return [...new Set(paths)];
68
+ }
69
+
70
+ /**
71
+ * Expand globs like `.env*` into concrete file paths.
72
+ * Falls back to the raw path if expansion fails or if there is no wildcard.
73
+ */
74
+ export async function expandPaths(
75
+ cwd: string,
76
+ rawTarget: string,
77
+ ): Promise<string[]> {
78
+ const absolutePath = resolve(cwd, rawTarget);
79
+ const base = basename(absolutePath);
80
+ const dir = dirname(absolutePath);
81
+
82
+ if (!base.includes("*") && !base.includes("?")) {
83
+ return [absolutePath];
84
+ }
85
+
86
+ try {
87
+ const entries = await readdir(dir);
88
+ const regex = new RegExp(
89
+ "^" +
90
+ base
91
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
92
+ .replace(/\*/g, ".*")
93
+ .replace(/\?/g, ".") +
94
+ "$",
95
+ );
96
+ const matches = entries.filter((f) => regex.test(f));
97
+ if (matches.length === 0) {
98
+ return [absolutePath];
99
+ }
100
+ return matches.map((f) => join(dir, f));
101
+ } catch {
102
+ return [absolutePath];
103
+ }
104
+ }
@@ -0,0 +1,145 @@
1
+ # Sentinel Extension — Permission Gate Guard
2
+
3
+ Stage: `Implemented`
4
+ Last Updated: 2026-04-25
5
+
6
+ ## High-Level Objective
7
+
8
+ Add a proactive permission-gate guard to the `sentinel` extension that intercepts `bash`, `write`, and `edit` tool calls before they perform system-level or out-of-scope operations (e.g., `sudo`, `curl | bash`, modifying shell configs, writing to `/usr/local/bin`, `brew install`, `rm -rf` on system paths). This closes the gap where the current `execution-tracker` only guards *session-written scripts* and misses dangerous commands issued directly as raw `bash` strings or out-of-project writes.
9
+
10
+ <!-- FEEDBACK: high_level_objective
11
+ Status: OPEN
12
+ -->
13
+
14
+ ## Mid-Level Objectives
15
+
16
+ - [ ] Create a new `permission-gate.ts` guard that subscribes to `tool_call` events for `bash`, `write`, and `edit`.
17
+ - [ ] Implement `bash` command pattern matching for high-risk operations (piped remote execution, sudo, privileged-path rm -rf, brew install, persistence hooks).
18
+ - [ ] Implement path classification logic to detect writes/edits targeting outside the current project root, system directories (`/usr/*`, `/Library/*`, `/System/*`, `/opt/*`), and shell config files (`~/.zshrc`, `~/.bashrc`, etc.).
19
+ - [ ] Provide a consistent escalation UX: `ctx.ui.confirm` when UI is available; fail-safe block with a descriptive `reason` when UI is unavailable.
20
+ - [ ] Register the new guard in `index.ts` alongside `output-scanner` and `execution-tracker`.
21
+ - [ ] Add unit tests for pattern matching and path classification helpers.
22
+ - [ ] Update the `sentinel` README to document the new guard and its behavior matrix.
23
+ - [ ] Bump the sentinel package version and update `CHANGELOG.md`.
24
+
25
+ <!-- FEEDBACK: mid_level_objectives
26
+ Questions or feedback about the requirements and milestones.
27
+ Status: OPEN
28
+ -->
29
+
30
+ ## Context
31
+
32
+ The `sentinel` extension currently provides two guards:
33
+
34
+ 1. **`output-scanner`** — Pre-reads files before `read` tool calls and scans them for secrets/credentials. Blocks or asks based on scan results.
35
+ 2. **`execution-tracker`** — Tracks files written via `write`/`edit` and scans them for dangerous patterns; later correlates those files with `bash` executions. If a script written in the session is executed and contains dangerous patterns, it asks/denies.
36
+
37
+ **The gap:** `execution-tracker` does **not** intercept raw `bash` commands that themselves contain dangerous operations (e.g., `curl -Ls https://mise.run | bash`). It only acts when a *file written earlier in the session* is executed. Similarly, neither guard prevents writes to system paths or shell config files.
38
+
39
+ The incident report from 2026-04-24 documents seven classes of ungated operations that should have required explicit user confirmation:
40
+
41
+ | Operation | Current behavior |
42
+ |-----------|----------------|
43
+ | `curl … \| bash` raw command | **Not blocked** |
44
+ | `wget … \| bash` raw command | **Not blocked** |
45
+ | `sudo …` raw command | **Not blocked** |
46
+ | `brew install …` raw command | **Not blocked** |
47
+ | `rm -rf /Library/…` raw command | **Not blocked** by sentinel (only `rm -rf` in session-written scripts) |
48
+ | Write to `~/.zshrc`, `~/.bashrc` | **Not blocked** |
49
+ | Write to `/usr/local/bin`, `/usr/*`, `/Library/*`, `/opt/*` | **Not blocked** |
50
+
51
+ The pi extension API supports blocking via returning `{ block: true, reason: string }` from `pi.on("tool_call", …)` handlers, and user confirmation via `ctx.ui.confirm(title, message)` when `ctx.hasUI` is true.
52
+
53
+ There are already example extensions (`permission-gate.ts`, `confirm-destructive.ts`, `protected-paths.ts`) demonstrating this pattern.
54
+
55
+ <!-- FEEDBACK: context
56
+ Questions or feedback about the technical context and background.
57
+ Status: OPEN
58
+ -->
59
+
60
+ ## Proposed Solution
61
+
62
+ Introduce a third sentinel guard named **`permission-gate`** (file: `guards/permission-gate.ts`).
63
+
64
+ ### Bash permission gating
65
+
66
+ On every `bash` `tool_call`, scan `event.input.command` against a curated list of dangerous patterns grouped by risk class:
67
+
68
+ | Risk class | Patterns | Escalation |
69
+ |------------|----------|------------|
70
+ | **Remote pipe execution** | `curl \| (bash\|sh\|zsh)`, `wget \| (bash\|sh\|zsh)` | Confirm (or block if no UI) |
71
+ | **Privilege escalation** | `\bsudo\b` | Confirm (showing full command) |
72
+ | **Destructive recursive delete** | `rm\s+-[a-zA-Z]*rf?.*(/(usr\|Library\|System\|opt)\|~/)` | Double-confirm or block (system paths); confirm for project-local paths |
73
+ | **Package manager system install** | `\bbrew\s+(install\|upgrade\|update)\b` | Confirm |
74
+ | **Persistence** | `crontab`, `systemctl enable`, `launchctl load` | Confirm |
75
+ | **Shell config modification (via bash)** | Appending to `~/.zshrc`, `~/.bashrc`, etc. | Confirm |
76
+ | **Binary installation outside project** | `cp\s+.*\s+/usr/local/bin/`, `mv\s+.*\s+/usr/local/bin/` | Confirm |
77
+
78
+ ### Write/Edit permission gating
79
+
80
+ On every `write` and `edit` `tool_call`, resolve the absolute target path and classify it:
81
+
82
+ | Path category | Example | Action |
83
+ |---------------|---------|--------|
84
+ | **Shell config files** | `~/.zshrc`, `~/.bashrc`, `~/.profile` | Confirm |
85
+ | **System directories** | `/usr/*`, `/Library/*`, `/System/*`, `/opt/*`, `/usr/local/bin` | Confirm |
86
+ | **Outside project root** | Any path not under `ctx.cwd` or the resolved project root | Confirm (with path shown) |
87
+
88
+ When a `write`/`edit` targets a shell config file, the confirmation dialog should show the target path and warn that this is a persistent system change.
89
+
90
+ ### Decision matrix
91
+
92
+ ```
93
+ UI available + user allows → return undefined (proceed)
94
+ UI available + user denies → return { block: true, reason }
95
+ No UI + dangerous detected → return { block: true, reason }
96
+ No dangerous patterns → return undefined (proceed)
97
+ ```
98
+
99
+ ### Scope boundaries
100
+
101
+ - This guard does **not** replace `output-scanner` or `execution-tracker`; it complements them.
102
+ - This guard does **not** block reads; only writes, edits, and bash executions.
103
+ - This guard intentionally does **not** block every `rm -rf` in the project directory (that would be overly restrictive); it only escalates `rm -rf` on known system/privileged paths.
104
+
105
+ <!-- FEEDBACK: proposed_solution
106
+ Questions or feedback about the proposed approach and scope.
107
+ Status: OPEN
108
+ -->
109
+
110
+ ## Implementation Notes
111
+
112
+ _No phases defined yet. Use `/dev:pair plan extensions/sentinel/specs/2026/04/sentinel/001-permission-gate.md` to generate the implementation plan._
113
+
114
+ <!-- FEEDBACK: implementation_approach
115
+ Questions or feedback about the overall implementation approach before diving into phases.
116
+ Status: OPEN
117
+ -->
118
+
119
+ ## Success Criteria
120
+
121
+ - [ ] A `bash` command containing `curl … | bash` is intercepted and escalated to the user (or blocked without UI).
122
+ - [ ] A `bash` command containing `sudo …` is intercepted and escalated.
123
+ - [ ] A `bash` command running `brew install` is intercepted and escalated.
124
+ - [ ] A `write` or `edit` targeting `~/.zshrc` is intercepted and escalated.
125
+ - [ ] A `write` or `edit` targeting `/usr/local/bin/` is intercepted and escalated.
126
+ - [ ] A `bash` command running `rm -rf /Library/Developer/CommandLineTools` is blocked or double-confirmed.
127
+ - [ ] When UI is unavailable, all matched dangerous operations fail-safe (block with a clear reason).
128
+ - [ ] Safe operations (e.g., `echo "hello"`, writing to project-local files) are not blocked and incur minimal overhead.
129
+ - [ ] The sentinel README and CHANGELOG are updated.
130
+
131
+ <!-- FEEDBACK: success_criteria
132
+ Questions or feedback about the completion criteria and validation approach.
133
+ Status: OPEN
134
+ -->
135
+
136
+ ## Notes
137
+
138
+ - Consider whether `sudo rm -rf` should trigger a single combined confirmation for both the `sudo` and the `rm -rf` risk classes, or if they should stack. A single confirmation with all matched labels is simpler and less noisy.
139
+ - Path resolution must handle `~` expansion and `ctx.cwd`-relative paths correctly.
140
+ - The guard should share the same notification style as existing guards (`[sentinel] …` prefix).
141
+
142
+ <!-- FEEDBACK: general
143
+ General questions, concerns, or suggestions for the entire implementation plan.
144
+ Status: OPEN
145
+ -->