pi-better-sandbox 0.1.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/LICENSE +21 -0
- package/README.md +212 -0
- package/commands.ts +214 -0
- package/deny-rules.ts +623 -0
- package/events.ts +55 -0
- package/files.ts +211 -0
- package/index.ts +233 -0
- package/package.json +62 -0
- package/policy.ts +102 -0
- package/rules-page.ts +176 -0
- package/shared-sandbox-core.ts +462 -0
- package/shell.ts +126 -0
- package/state.ts +300 -0
- package/status.ts +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 1aboveio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# pi-better-sandbox
|
|
2
|
+
|
|
3
|
+
A default-on write sandbox for Pi's foreground tools.
|
|
4
|
+
|
|
5
|
+
It is installed by default with [`pi-better-harness`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-harness#readme), and can be installed on its own:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pi install npm:pi-better-sandbox
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Either way you keep starting Pi the way you always have — `pi`. There is
|
|
12
|
+
no launcher, no wrapper command, and nothing to configure. From the first
|
|
13
|
+
session start, Pi's built-in `bash` tool and the `!` / `!!` commands you type
|
|
14
|
+
yourself run inside an OS sandbox that lets them write only under the directory
|
|
15
|
+
you launched Pi from, and the built-in `write` and `edit` tools are held to the
|
|
16
|
+
same policy.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
Read: every filesystem path
|
|
20
|
+
Write: the canonical launch directory and everything under it
|
|
21
|
+
Exceptions: .git/hooks, .env, .env.local
|
|
22
|
+
Network: unchanged
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
For shell commands the denial is done by the kernel, not by inspecting command
|
|
26
|
+
text: macOS uses Seatbelt (`sandbox-exec`) and Linux uses Bubblewrap (`bwrap`).
|
|
27
|
+
A crafted command cannot talk its way past it, because the write syscall itself
|
|
28
|
+
is refused.
|
|
29
|
+
|
|
30
|
+
`write` and `edit` never start a child process — they change files inside Pi's
|
|
31
|
+
own process — so there is no child to wrap. They are confined by a containment
|
|
32
|
+
check on the canonical target instead, run inside Pi's own file-mutation queue,
|
|
33
|
+
immediately before the filesystem call it guards. A refused mutation leaves
|
|
34
|
+
nothing behind on disk.
|
|
35
|
+
|
|
36
|
+
## What is confined, and what is not
|
|
37
|
+
|
|
38
|
+
**Reads and network access are never restricted.** Every path on the filesystem
|
|
39
|
+
stays readable and network behaviour is exactly what it was. This sandbox limits
|
|
40
|
+
writes, and nothing else.
|
|
41
|
+
|
|
42
|
+
Writes are confined for the integrated first-party execution paths, and only
|
|
43
|
+
those:
|
|
44
|
+
|
|
45
|
+
- Pi's built-in `bash` tool.
|
|
46
|
+
- User-entered `!` and `!!` commands.
|
|
47
|
+
- Pi's built-in `write` and `edit` tools.
|
|
48
|
+
- Local [`pi-better-background-tasks`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-background-tasks#readme)
|
|
49
|
+
spawns and watches, which capture this policy at launch.
|
|
50
|
+
- [`pi-better-subagents`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-subagents#readme)
|
|
51
|
+
children, through the same shared mechanism.
|
|
52
|
+
|
|
53
|
+
**Not** confined:
|
|
54
|
+
|
|
55
|
+
- Pi's own process.
|
|
56
|
+
- `pi.exec` calls made by extensions.
|
|
57
|
+
- Unrelated third-party extension code.
|
|
58
|
+
- Another first-party surface's control plane. Each surface denies its own —
|
|
59
|
+
the files naming what it will run next — but not every other surface's, so
|
|
60
|
+
confinement is per surface rather than global.
|
|
61
|
+
|
|
62
|
+
This is a tool-execution sandbox. It limits accidental damage from commands the
|
|
63
|
+
model or you run through Pi's shell; it is not a boundary around Pi itself.
|
|
64
|
+
|
|
65
|
+
Overriding `write` and `edit` changes nothing you can see: the parameter
|
|
66
|
+
schemas, prompt guidance, call rendering, write previews, edit diffs, result
|
|
67
|
+
details, mutation queueing, and cancellation are Pi's own. Only the filesystem
|
|
68
|
+
operations underneath them are replaced.
|
|
69
|
+
|
|
70
|
+
## Commands
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
/sandbox show the effective status
|
|
74
|
+
/sandbox on re-arm protection for operations started from now on
|
|
75
|
+
/sandbox off turn protection off for this session (interactive confirmation)
|
|
76
|
+
/sandbox deny list show the write-denied paths
|
|
77
|
+
/sandbox deny add <path> stop allowing writes to a path
|
|
78
|
+
/sandbox deny remove <path> allow writes to a path again
|
|
79
|
+
/sandbox deny reset drop your changes and restore the packaged defaults
|
|
80
|
+
/sandbox rules open the write-denied paths editor
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The footer shows `sandbox · on · <project>` while protection is active, and a
|
|
84
|
+
prominent `sandbox · OFF`, `sandbox · UNAVAILABLE`, or `sandbox · FAILED`
|
|
85
|
+
otherwise. Both surfaces report what the runtime actually resolved — which
|
|
86
|
+
backend, which executable — never what was merely configured.
|
|
87
|
+
|
|
88
|
+
`/sandbox off` needs an interactive confirmation and is refused outright when
|
|
89
|
+
there is no interactive UI. There is no tool for changing sandbox state or its
|
|
90
|
+
rules, so the model can neither disable its own confinement nor edit the paths
|
|
91
|
+
it is confined away from.
|
|
92
|
+
|
|
93
|
+
## Write-denied paths
|
|
94
|
+
|
|
95
|
+
Three paths are denied out of the box — `.git/hooks`, `.env`, and `.env.local`,
|
|
96
|
+
relative to whichever project you are in. They live in the package's source, so
|
|
97
|
+
installing writes no settings file anywhere.
|
|
98
|
+
|
|
99
|
+
Rules are paths, not patterns. Write one of three ways:
|
|
100
|
+
|
|
101
|
+
| You type | It means |
|
|
102
|
+
| ------------------- | --------------------------------------------------- |
|
|
103
|
+
| `build/artifacts` | that path inside **every** project you open |
|
|
104
|
+
| `~/.aws` | that path under your home directory |
|
|
105
|
+
| `/etc/hosts` | exactly that path |
|
|
106
|
+
|
|
107
|
+
A relative rule is stored as a template and resolved against each project, which
|
|
108
|
+
is why one global rule set is enough — there is no per-project database. Lists
|
|
109
|
+
and the editor always show the canonical absolute path a rule currently resolves
|
|
110
|
+
to. A directory denies its whole subtree; a file denies that exact file, whether
|
|
111
|
+
or not it exists yet.
|
|
112
|
+
|
|
113
|
+
`/sandbox rules` opens a compact keyboard-driven editor over the same rules:
|
|
114
|
+
arrow keys to move, enter to remove the highlighted rule, or pick *Add* to type
|
|
115
|
+
a new one and *Restore the packaged defaults* to start over. The slash commands
|
|
116
|
+
and the editor are two front ends over one validation and persistence module, so
|
|
117
|
+
they cannot disagree.
|
|
118
|
+
|
|
119
|
+
Changes take effect for shell commands and file mutations started after them.
|
|
120
|
+
A command already running keeps the rules it launched with.
|
|
121
|
+
|
|
122
|
+
### Where your rules live
|
|
123
|
+
|
|
124
|
+
Your changes are written to `~/.pi/agent/extensions/pi-better-sandbox.json`
|
|
125
|
+
(under `$PI_CODING_AGENT_DIR` when you set one):
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"version": 1,
|
|
130
|
+
"denyWrite": [".env", ".env.local", ".git/hooks", "build/artifacts"]
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
That file appears the first time you add or remove a rule, never at install
|
|
135
|
+
time. `/sandbox deny reset` deletes it and puts the defaults shipped by the
|
|
136
|
+
installed package version back in force — so an upgrade that changes the
|
|
137
|
+
defaults is picked up by a reset rather than being masked by a stale copy.
|
|
138
|
+
|
|
139
|
+
If the file cannot be read, the packaged defaults stay in force, the problem is
|
|
140
|
+
reported, and rule changes are refused until you fix the file or reset it —
|
|
141
|
+
a typo is never quietly turned into a lost rule set.
|
|
142
|
+
|
|
143
|
+
### What is refused, and why
|
|
144
|
+
|
|
145
|
+
- **Empty entries and patterns** (`*.pem`, `src/**/x`) — rules are concrete
|
|
146
|
+
paths; a pattern would silently match nothing.
|
|
147
|
+
- **Duplicates**, however they are spelled: `.env`, `./.env`, the absolute path,
|
|
148
|
+
or a symlink pointing at the same file all resolve to one canonical path.
|
|
149
|
+
- **Overlaps**, in both directions. A path already inside a denied directory
|
|
150
|
+
would change nothing; a directory that would swallow a narrower rule names
|
|
151
|
+
that rule so you can remove it deliberately instead of losing it silently.
|
|
152
|
+
- **A rule that contains the project root** — `.`, `..`, `/`, or `~` when your
|
|
153
|
+
project lives under home. Denying it would make every write in the project
|
|
154
|
+
fail. `/sandbox off` is the thing you actually want there.
|
|
155
|
+
|
|
156
|
+
A global rule that turns out to contain the root of a *different* project stays
|
|
157
|
+
in your rule set but is held out in that project, with a message saying so.
|
|
158
|
+
|
|
159
|
+
## Lifecycle
|
|
160
|
+
|
|
161
|
+
The sandbox is enabled again at every session start: startup, new session,
|
|
162
|
+
resume, fork, and reload. An off state is never written anywhere, so it cannot
|
|
163
|
+
outlive the session you switched it off in.
|
|
164
|
+
|
|
165
|
+
Toggles apply to operations launched after the change. A command already running
|
|
166
|
+
keeps the policy it launched with.
|
|
167
|
+
|
|
168
|
+
## Fail-closed behaviour
|
|
169
|
+
|
|
170
|
+
While the sandbox is enabled and a backend cannot be applied, protected commands
|
|
171
|
+
and file mutations are **blocked** rather than run unprotected:
|
|
172
|
+
|
|
173
|
+
- No backend on this platform (`unavailable`).
|
|
174
|
+
- A launch directory too broad to confine — `/` or your home directory
|
|
175
|
+
(`failed`). Relaunch Pi from the directory you are actually working in, or
|
|
176
|
+
turn the sandbox off on purpose.
|
|
177
|
+
- A backend that was selected but failed to start. It is never retried directly.
|
|
178
|
+
|
|
179
|
+
## Paths, symlinks and denied files
|
|
180
|
+
|
|
181
|
+
The launch directory is canonicalized at session start, so reaching a project
|
|
182
|
+
through a symlink does not widen what is writable. Denied entries are
|
|
183
|
+
canonicalized the same way: a directory denies its whole subtree, a file denies
|
|
184
|
+
that exact file, and an alias pointing at a denied file is denied too.
|
|
185
|
+
|
|
186
|
+
## Platform support
|
|
187
|
+
|
|
188
|
+
| Platform | Backend | Requirement |
|
|
189
|
+
| -------- | --------------------------- | ---------------------------- |
|
|
190
|
+
| macOS | Seatbelt (`sandbox-exec`) | ships with the OS |
|
|
191
|
+
| Linux | Bubblewrap (`bwrap`) | install `bubblewrap` |
|
|
192
|
+
| Other | none | protected commands are blocked |
|
|
193
|
+
|
|
194
|
+
## For other extensions
|
|
195
|
+
|
|
196
|
+
The effective policy is published as a frozen snapshot on Pi's extension event
|
|
197
|
+
bus. It carries policy and status only — never a way to run anything.
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
import {
|
|
201
|
+
FOREGROUND_SANDBOX_POLICY_CHANNEL,
|
|
202
|
+
FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL,
|
|
203
|
+
type ForegroundSandboxPolicyEvent,
|
|
204
|
+
} from "pi-better-sandbox";
|
|
205
|
+
|
|
206
|
+
pi.events.on(FOREGROUND_SANDBOX_POLICY_CHANNEL, (policy) => {
|
|
207
|
+
// Snapshot it at launch time; a running operation keeps its launch policy.
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Loaded late and missed the last publication? Ask for the current one.
|
|
211
|
+
pi.events.emit(FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL, undefined);
|
|
212
|
+
```
|
package/commands.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/sandbox` slash command.
|
|
3
|
+
*
|
|
4
|
+
* Sandbox state is human-only by construction: this is a slash command, and the
|
|
5
|
+
* package registers no tool that can read or change it, so the model has no way
|
|
6
|
+
* to disable its own confinement or edit its own deny rules. Turning protection
|
|
7
|
+
* off additionally requires an interactive confirmation and is refused outright
|
|
8
|
+
* without an interactive UI.
|
|
9
|
+
*
|
|
10
|
+
* The `deny` subcommands and the `rules` page are two front ends over one
|
|
11
|
+
* `DenyRuleManager`; this module only parses arguments and renders outcomes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
DenyRuleError,
|
|
18
|
+
type DenyRuleManager,
|
|
19
|
+
type DenyRuleReport,
|
|
20
|
+
formatDenyRuleReport,
|
|
21
|
+
} from "./deny-rules.ts";
|
|
22
|
+
import { openSandboxRulesPage } from "./rules-page.ts";
|
|
23
|
+
import { formatSandboxReport } from "./status.ts";
|
|
24
|
+
import type { ForegroundSandboxController, ForegroundSandboxStatus } from "./state.ts";
|
|
25
|
+
|
|
26
|
+
export const SANDBOX_COMMAND_NAME = "sandbox";
|
|
27
|
+
|
|
28
|
+
export const SANDBOX_COMMAND_DESCRIPTION =
|
|
29
|
+
"Show the foreground write sandbox, turn it on or off for this session, or manage write-denied paths";
|
|
30
|
+
|
|
31
|
+
const USAGE = [
|
|
32
|
+
"Usage:",
|
|
33
|
+
" /sandbox",
|
|
34
|
+
" /sandbox on",
|
|
35
|
+
" /sandbox off",
|
|
36
|
+
" /sandbox deny list",
|
|
37
|
+
" /sandbox deny add <path>",
|
|
38
|
+
" /sandbox deny remove <path>",
|
|
39
|
+
" /sandbox deny reset",
|
|
40
|
+
" /sandbox rules",
|
|
41
|
+
].join("\n");
|
|
42
|
+
|
|
43
|
+
const DENY_USAGE = [
|
|
44
|
+
"Usage:",
|
|
45
|
+
" /sandbox deny list",
|
|
46
|
+
" /sandbox deny add <path>",
|
|
47
|
+
" /sandbox deny remove <path>",
|
|
48
|
+
" /sandbox deny reset",
|
|
49
|
+
].join("\n");
|
|
50
|
+
|
|
51
|
+
const DISABLE_TITLE = "Disable the foreground write sandbox?";
|
|
52
|
+
|
|
53
|
+
const DISABLE_MESSAGE = [
|
|
54
|
+
"The built-in bash, write, and edit tools and user-entered ! / !! commands",
|
|
55
|
+
"will run with normal host write access for the rest of this session. New,",
|
|
56
|
+
"resumed, forked, and reloaded sessions start protected again.",
|
|
57
|
+
].join("\n");
|
|
58
|
+
|
|
59
|
+
const NO_UI_REJECTION =
|
|
60
|
+
"/sandbox off needs an interactive confirmation and there is no interactive UI here, so the sandbox stays on.";
|
|
61
|
+
|
|
62
|
+
const RESET_TITLE = "Restore the packaged write-deny defaults?";
|
|
63
|
+
|
|
64
|
+
export type SandboxCommandDeps = {
|
|
65
|
+
controller: ForegroundSandboxController;
|
|
66
|
+
/** The one validation and persistence path for write-deny rules. */
|
|
67
|
+
denyRules: DenyRuleManager;
|
|
68
|
+
/** Called after any state change so the footer and consumers stay truthful. */
|
|
69
|
+
onStateChange: (status: ForegroundSandboxStatus) => void;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Build the `/sandbox` handler. Exported so its behaviour is directly testable. */
|
|
73
|
+
export function createSandboxCommandHandler({
|
|
74
|
+
controller,
|
|
75
|
+
denyRules,
|
|
76
|
+
onStateChange,
|
|
77
|
+
}: SandboxCommandDeps) {
|
|
78
|
+
return async function handleSandboxCommand(
|
|
79
|
+
args: string,
|
|
80
|
+
ctx: ExtensionCommandContext,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
// Only the verbs are case-folded. Everything after them is a path, and
|
|
83
|
+
// paths are case-sensitive on the filesystems this package supports.
|
|
84
|
+
const trimmed = args.trim();
|
|
85
|
+
const [verb = "", ...tail] = trimmed.split(/\s+/);
|
|
86
|
+
const subcommand = verb.toLowerCase();
|
|
87
|
+
const rest = trimmed.slice(verb.length).trim();
|
|
88
|
+
|
|
89
|
+
if (subcommand === "") {
|
|
90
|
+
ctx.ui.notify(formatSandboxReport(controller.status()), "info");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (subcommand === "on") {
|
|
95
|
+
const status = controller.enable();
|
|
96
|
+
onStateChange(status);
|
|
97
|
+
ctx.ui.notify(
|
|
98
|
+
status.state === "enabled"
|
|
99
|
+
? `Foreground sandbox on. ${status.reason}`
|
|
100
|
+
: `Foreground sandbox re-armed but not active: ${status.reason}`,
|
|
101
|
+
status.state === "enabled" ? "info" : "warning",
|
|
102
|
+
);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (subcommand === "off") {
|
|
107
|
+
if (!ctx.hasUI) {
|
|
108
|
+
ctx.ui.notify(NO_UI_REJECTION, "error");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const confirmed = await ctx.ui.confirm(DISABLE_TITLE, DISABLE_MESSAGE);
|
|
112
|
+
if (!confirmed) {
|
|
113
|
+
ctx.ui.notify("Foreground sandbox left on.", "info");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const status = controller.disable();
|
|
117
|
+
onStateChange(status);
|
|
118
|
+
ctx.ui.notify(
|
|
119
|
+
"Foreground sandbox OFF for this session. Shell commands can now write anywhere.",
|
|
120
|
+
"warning",
|
|
121
|
+
);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (subcommand === "deny") {
|
|
126
|
+
await handleDeny(denyRules, ctx, tail[0]?.toLowerCase() ?? "list", rest);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (subcommand === "rules") {
|
|
131
|
+
await openSandboxRulesPage(denyRules, ctx);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
ctx.ui.notify(`Unknown /sandbox subcommand: ${subcommand}\n\n${USAGE}`, "error");
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function handleDeny(
|
|
140
|
+
denyRules: DenyRuleManager,
|
|
141
|
+
ctx: ExtensionCommandContext,
|
|
142
|
+
action: string,
|
|
143
|
+
rest: string,
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
// `rest` still carries the action word; the path is whatever follows it,
|
|
146
|
+
// verbatim, so a path containing spaces survives intact.
|
|
147
|
+
const argument = rest.slice(action.length).trim();
|
|
148
|
+
|
|
149
|
+
if (action === "list") {
|
|
150
|
+
ctx.ui.notify(formatDenyRuleReport(denyRules.report()), "info");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (action === "add" || action === "remove") {
|
|
155
|
+
if (argument === "") {
|
|
156
|
+
ctx.ui.notify(`/sandbox deny ${action} needs a path.\n\n${DENY_USAGE}`, "error");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
announce(ctx, () =>
|
|
160
|
+
action === "add" ? denyRules.add(argument) : denyRules.remove(argument),
|
|
161
|
+
);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (action === "reset") {
|
|
166
|
+
// Resetting discards rules the human wrote, so it is confirmed wherever
|
|
167
|
+
// there is someone to confirm it.
|
|
168
|
+
if (ctx.hasUI && denyRules.hasOverride()) {
|
|
169
|
+
const confirmed = await ctx.ui.confirm(
|
|
170
|
+
RESET_TITLE,
|
|
171
|
+
"Every write-deny rule you added or removed will be forgotten.",
|
|
172
|
+
);
|
|
173
|
+
if (!confirmed) {
|
|
174
|
+
ctx.ui.notify("Your write-deny rules were left as they are.", "info");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
announce(ctx, () => denyRules.reset());
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
ctx.ui.notify(`Unknown /sandbox deny action: ${action}\n\n${DENY_USAGE}`, "error");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Run one rule change and show either the new rule set or why it was refused. */
|
|
186
|
+
function announce(ctx: ExtensionCommandContext, change: () => DenyRuleReport): void {
|
|
187
|
+
try {
|
|
188
|
+
ctx.ui.notify(formatDenyRuleReport(change()), "info");
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error instanceof DenyRuleError) {
|
|
191
|
+
ctx.ui.notify(error.message, "error");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const SUBCOMMANDS = ["on", "off", "deny", "rules"] as const;
|
|
199
|
+
const DENY_ACTIONS = ["list", "add", "remove", "reset"] as const;
|
|
200
|
+
|
|
201
|
+
/** Argument completions for `/sandbox`, including the `deny` actions. */
|
|
202
|
+
export function sandboxArgumentCompletions(argumentPrefix: string) {
|
|
203
|
+
const prefix = argumentPrefix.trimStart().toLowerCase();
|
|
204
|
+
const denyPrefix = /^deny(\s|$)/.test(prefix) ? prefix.replace(/^deny\s*/, "") : undefined;
|
|
205
|
+
|
|
206
|
+
const values =
|
|
207
|
+
denyPrefix === undefined
|
|
208
|
+
? SUBCOMMANDS.filter((value) => value.startsWith(prefix))
|
|
209
|
+
: DENY_ACTIONS.filter((value) => value.startsWith(denyPrefix)).map(
|
|
210
|
+
(value) => `deny ${value}`,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return values.map((value) => ({ value, label: value }));
|
|
214
|
+
}
|