@huseynsnmz/pi-modes 0.0.0-stage → 0.1.4
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 +38 -0
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/package.json +47 -4
- package/src/bash.ts +211 -0
- package/src/index.ts +175 -0
- package/src/judge-gate.ts +93 -0
- package/src/modes.ts +81 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.4]
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Fixed a false "Auto has no permission judge" warning when `pi-permission-classifier` loads after `pi-permission-system`, where it only registers during the first prompt.
|
|
8
|
+
- Moved the "could not take control" check to after every extension has had the chance to register.
|
|
9
|
+
|
|
10
|
+
## [0.1.3]
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Published to npm as `@huseynsnmz/pi-modes`.
|
|
15
|
+
- Added a quick start to the README.
|
|
16
|
+
|
|
17
|
+
## [0.1.2]
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Warns at startup if this extension loads after `pi-permission-system`, which causes needless approval prompts in `plan` and `read-only`.
|
|
22
|
+
|
|
23
|
+
## [0.1.1]
|
|
24
|
+
|
|
25
|
+
### Security
|
|
26
|
+
|
|
27
|
+
- Fixed read-only bash bypasses that could write files or run code in `plan` and `read-only`. These used bundled, attached or abbreviated options (`sort -uo`, `sort --outp`, `fd -Hx`, `tree -ao`, `file -bC`), `uniq - file`, extra options on `git stash list`, and unquoted globs that expand into flags. Upgrade from 0.1.0.
|
|
28
|
+
- Plan-mode prompts now show control and bidirectional characters as visible escapes, so a command can't hide part of itself.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- `fd`, `tree` and `file` are no longer treated as read-only. `sort`, `uniq`, `date` and `printf` accept fewer options. These now ask in `plan` and are blocked in `read-only`.
|
|
33
|
+
|
|
34
|
+
## [0.1.0]
|
|
35
|
+
|
|
36
|
+
- Added `normal`, `auto`, `plan` and `read-only` modes, switched with `Shift+Tab` or `/mode`.
|
|
37
|
+
- Added plan approval through the `submit_plan` tool.
|
|
38
|
+
- Added per-session control of the `pi-permission-classifier` judge.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hüseyin Sönmez
|
|
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
CHANGED
|
@@ -1,3 +1,118 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-modes
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Switch [Pi](https://pi.dev) between permission modes with `Shift+Tab`.
|
|
4
|
+
|
|
5
|
+
| Mode | What the agent can do | Who approves |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `normal` | Anything your permission rules allow | You |
|
|
8
|
+
| `auto` | Anything your permission rules allow | Your permission judge first, then you if it defers |
|
|
9
|
+
| `plan` | Read and research, then submit a plan. No edits | You, for any non-read-only command |
|
|
10
|
+
| `read-only` | Read and research only | Nobody. Everything else is blocked |
|
|
11
|
+
|
|
12
|
+
`Shift+Tab` cycles `normal → auto → plan → read-only → normal`. The footer shows the current mode.
|
|
13
|
+
|
|
14
|
+
The mode is saved per session and survives `--continue`. A new session always starts in `normal`, and nothing in a project can change that.
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
Requires Pi 1.5 or newer.
|
|
19
|
+
|
|
20
|
+
1. Install it:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pi install npm:@huseynsnmz/pi-modes@0.1.4
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
2. Move it above `@gotgenes/pi-permission-system`, because `pi install` adds packages last. Otherwise, in `plan` and `read-only` you may be asked to approve an action that is then blocked anyway. It stays blocked either way, and the extension warns you at startup. Skip this step if you don't use that package.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
f=~/.pi/agent/settings.json
|
|
30
|
+
jq '.packages |= (map(select(tostring | contains("pi-modes"))) + map(select(tostring | contains("pi-modes") | not)))' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
3. If you use `pi-permission-system`, allow plans so you aren't asked twice:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
f=~/.pi/agent/extensions/pi-permission-system/config.json
|
|
37
|
+
jq '.permission.submit_plan = "allow"' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
4. Restart Pi. The footer shows `mode: normal`, and `Shift+Tab` changes the mode.
|
|
41
|
+
|
|
42
|
+
To install from git instead, use `pi install git:github.com/huseynsnmz/pi-modes@v0.1.4`.
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
| Input | What it does |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `Shift+Tab` | Next mode |
|
|
49
|
+
| `/mode` | Pick a mode from a list |
|
|
50
|
+
| `/mode plan` | Switch directly to a mode |
|
|
51
|
+
|
|
52
|
+
`Shift+Tab` normally cycles the thinking level, and this extension takes it over. To keep that action on another key, add this to `~/.pi/agent/keybindings.json`:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{ "app.thinking.cycle": "alt+t" }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## How plan mode ends
|
|
59
|
+
|
|
60
|
+
In plan mode the agent calls the `submit_plan` tool. You then choose one of:
|
|
61
|
+
|
|
62
|
+
- **Approve, normal:** switches to `normal` and the agent starts implementing.
|
|
63
|
+
- **Approve, auto:** switches to `auto` and the agent starts implementing.
|
|
64
|
+
- **Keep planning:** stays in `plan` and sends your feedback to the agent.
|
|
65
|
+
|
|
66
|
+
## Using it with other extensions
|
|
67
|
+
|
|
68
|
+
**`@gotgenes/pi-permission-system`:** still runs in every mode. This extension only adds restrictions, so a `deny` rule always wins.
|
|
69
|
+
|
|
70
|
+
**`pi-permission-classifier`:** runs only in `auto`. In every other mode it defers, and the request goes to your normal prompt. Set it up as its README describes, with `permission-judge` in `authorizerChain`. Its `perm-judge:` footer entry stays visible in all modes, so go by the `mode:` entry. If the classifier is listed after `pi-permission-system`, it only registers during your first prompt, and `auto` starts working from then on.
|
|
71
|
+
|
|
72
|
+
Without a classifier, `auto` behaves like `normal`, and the extension warns you when you switch to it.
|
|
73
|
+
|
|
74
|
+
**`pi-subagents`:** blocked in `plan` and `read-only`, because a child agent could write files. In `normal` and `auto` it is unchanged.
|
|
75
|
+
|
|
76
|
+
**`pi-look` and `pi-claude-code-ui`:** no conflicts are expected, as neither uses `Shift+Tab`. The mode appears through Pi's standard status API.
|
|
77
|
+
|
|
78
|
+
## What plan and read-only allow
|
|
79
|
+
|
|
80
|
+
- **Allowed tools:** `read`, `grep`, `find`, `ls`, read-only `bash`, and `submit_plan` in plan mode.
|
|
81
|
+
- **Blocked tools:** `write`, `edit`, subagents, MCP tools and every other tool, including ones added later.
|
|
82
|
+
- **Read-only bash:** a fixed list of commands, such as `ls`, `cat`, `rg` and `git status`. A command is refused if it uses `$`, backticks, redirects, subshells, background jobs, unquoted globs, control characters or a path to a binary. Options that write or run code are refused too, including bundled and abbreviated forms such as `sort -uo` and `sort --outp`. Quote a glob you mean literally, as in `find . -name '*.ts'`.
|
|
83
|
+
|
|
84
|
+
Any other command asks you in `plan` and is blocked in `read-only`.
|
|
85
|
+
|
|
86
|
+
## Security limits
|
|
87
|
+
|
|
88
|
+
These modes keep an honest agent on track. They are not a sandbox.
|
|
89
|
+
|
|
90
|
+
- **Reads are not restricted by this extension.** A read-only command can still read secrets, so keep `deny` rules for them in your permission system.
|
|
91
|
+
- **A repo can already be malicious.** Commands like `git status` can run code from an existing `.git/config`, such as `core.fsmonitor`. Use a container for repos you don't trust.
|
|
92
|
+
- **Child agents started earlier keep running.** Switching mode doesn't stop a subagent that is already running.
|
|
93
|
+
- **The classifier hook relies on an internal detail.** There is no public way to switch a registered judge on and off, so this extension wraps it when it registers. It uses the global key that `pi-permission-system` documents for its service map. If that breaks, you get a warning on your first prompt that the judge may auto-approve outside `auto`.
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
npm install --ignore-scripts
|
|
99
|
+
npm run check
|
|
100
|
+
npm test
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The end-to-end test drives real Pi in a real terminal against a fake model, using a throwaway home directory:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npm run e2e:solo # only this extension
|
|
107
|
+
npm run e2e # with the five packages it was built for, in three load orders
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`npm run e2e` installs those packages from npm with scripts disabled, then starts them.
|
|
111
|
+
|
|
112
|
+
## Contributing
|
|
113
|
+
|
|
114
|
+
Issues are welcome, and pull requests are turned off. See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huseynsnmz/pi-modes",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Shift+Tab permission modes for Pi: normal, auto, plan and read-only",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"permissions",
|
|
11
|
+
"plan-mode"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/huseynsnmz/pi-modes.git"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"README.md",
|
|
20
|
+
"CHANGELOG.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"pi": {
|
|
24
|
+
"extensions": [
|
|
25
|
+
"./src/index.ts"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22.18"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"check": "tsc --noEmit",
|
|
33
|
+
"test": "node --test test/*.test.ts",
|
|
34
|
+
"e2e:solo": "python3 test/e2e/run.py solo",
|
|
35
|
+
"e2e": "python3 test/e2e/run.py modes-first && python3 test/e2e/run.py modes-last && python3 test/e2e/run.py classifier-last"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
39
|
+
"@earendil-works/pi-tui": "*",
|
|
40
|
+
"typebox": "*"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": "1.5.0",
|
|
44
|
+
"@earendil-works/pi-tui": "1.5.0",
|
|
45
|
+
"@types/node": "24.13.3",
|
|
46
|
+
"typebox": "1.1.38",
|
|
47
|
+
"typescript": "6.0.3"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/bash.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides whether a bash command is safe to run in a restricted mode.
|
|
3
|
+
*
|
|
4
|
+
* This is an allowlist that fails closed. Anything the parser cannot prove
|
|
5
|
+
* read-only is rejected, including shell expansion, globs, redirects, subshells
|
|
6
|
+
* and background jobs, so a false "no" costs a prompt and never a write. Globs
|
|
7
|
+
* count because a file named -exec could turn a pattern into a dangerous flag.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type BashVerdict = { readOnly: true } | { readOnly: false; reason: string };
|
|
11
|
+
|
|
12
|
+
const no = (reason: string): BashVerdict => ({ readOnly: false, reason });
|
|
13
|
+
const YES: BashVerdict = { readOnly: true };
|
|
14
|
+
|
|
15
|
+
type ArgCheck = (args: readonly string[]) => string | null;
|
|
16
|
+
|
|
17
|
+
const noArgs: ArgCheck = (args) => (args.length === 0 ? null : "takes no arguments here");
|
|
18
|
+
const anyArgs: ArgCheck = () => null;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Rejects a long option if its name could abbreviate a forbidden one, because
|
|
22
|
+
* getopt_long and git both accept unique prefixes such as --outp for --output.
|
|
23
|
+
*/
|
|
24
|
+
function isForbiddenLong(arg: string, forbidden: readonly string[]): boolean {
|
|
25
|
+
if (!arg.startsWith("--")) return false;
|
|
26
|
+
const name = arg.slice(2).split("=", 1)[0] as string;
|
|
27
|
+
return name !== "" && forbidden.some((flag) => flag.startsWith(name));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rejectLong(...forbidden: readonly string[]): ArgCheck {
|
|
31
|
+
return (args) => {
|
|
32
|
+
const hit = args.find((arg) => isForbiddenLong(arg, forbidden));
|
|
33
|
+
return hit === undefined ? null : `${hit} can write or run code`;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Only these flags are allowed. A denylist can't model parsers that accept
|
|
39
|
+
* bundles and attached values, such as -uo or -ofile.
|
|
40
|
+
*/
|
|
41
|
+
function onlyFlags(allowed: RegExp, maxOperands = Number.POSITIVE_INFINITY): ArgCheck {
|
|
42
|
+
return (args) => {
|
|
43
|
+
let operands = 0;
|
|
44
|
+
for (const arg of args) {
|
|
45
|
+
if (arg.startsWith("-")) {
|
|
46
|
+
if (!allowed.test(arg)) return `${arg} is not an allowed option`;
|
|
47
|
+
} else {
|
|
48
|
+
operands += 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return operands > maxOperands ? "too many file arguments" : null;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const FIND_UNSAFE = new Set(["-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprint0", "-fprintf", "-fls"]);
|
|
56
|
+
|
|
57
|
+
const COMMANDS: Readonly<Record<string, ArgCheck>> = {
|
|
58
|
+
basename: anyArgs,
|
|
59
|
+
cat: anyArgs,
|
|
60
|
+
cd: anyArgs,
|
|
61
|
+
cut: anyArgs,
|
|
62
|
+
df: anyArgs,
|
|
63
|
+
diff: anyArgs,
|
|
64
|
+
dirname: anyArgs,
|
|
65
|
+
du: anyArgs,
|
|
66
|
+
echo: anyArgs,
|
|
67
|
+
grep: anyArgs,
|
|
68
|
+
head: anyArgs,
|
|
69
|
+
id: anyArgs,
|
|
70
|
+
jq: anyArgs,
|
|
71
|
+
ls: anyArgs,
|
|
72
|
+
pwd: noArgs,
|
|
73
|
+
readlink: anyArgs,
|
|
74
|
+
realpath: anyArgs,
|
|
75
|
+
stat: anyArgs,
|
|
76
|
+
tail: anyArgs,
|
|
77
|
+
tr: anyArgs,
|
|
78
|
+
type: anyArgs,
|
|
79
|
+
uname: anyArgs,
|
|
80
|
+
wc: anyArgs,
|
|
81
|
+
which: anyArgs,
|
|
82
|
+
whoami: noArgs,
|
|
83
|
+
date: (args) => (args.every((arg) => arg === "-u" || arg.startsWith("+")) ? null : "can only print the date"),
|
|
84
|
+
printf: (args) => (args[0]?.startsWith("-") ? "options are not allowed" : null),
|
|
85
|
+
sort: onlyFlags(/^-(?:[bdfghinrsuVM]+|k[\w.,]*|t.?)$/),
|
|
86
|
+
uniq: onlyFlags(/^-[cdui]+$/, 1),
|
|
87
|
+
rg: rejectLong("pre", "pre-glob", "hostname-bin"),
|
|
88
|
+
find: (args) => {
|
|
89
|
+
const hit = args.find((arg) => FIND_UNSAFE.has(arg));
|
|
90
|
+
return hit === undefined ? null : `${hit} can write or run code`;
|
|
91
|
+
},
|
|
92
|
+
git: gitCheck,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const GIT_READ_ONLY = new Set(["status", "diff", "log", "show", "blame", "rev-parse", "ls-files", "describe", "shortlog"]);
|
|
96
|
+
const GIT_UNSAFE_LONG = ["output", "ext-diff", "textconv", "open-files-in-pager"];
|
|
97
|
+
const GIT_LIST_FLAGS: Readonly<Record<string, ReadonlySet<string>>> = {
|
|
98
|
+
branch: new Set(["-a", "-r", "-v", "-vv", "--all", "--remotes", "--list", "-l", "--show-current"]),
|
|
99
|
+
remote: new Set(["-v", "--verbose"]),
|
|
100
|
+
tag: new Set(["-l", "--list"]),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
function gitCheck(args: readonly string[]): string | null {
|
|
104
|
+
const [sub, ...rest] = args;
|
|
105
|
+
if (sub === undefined) return "needs a subcommand";
|
|
106
|
+
if (sub.startsWith("-")) return `global option ${sub} is not allowed`;
|
|
107
|
+
if (GIT_READ_ONLY.has(sub)) {
|
|
108
|
+
const hit = rest.find((arg) => arg.startsWith("-O") || isForbiddenLong(arg, GIT_UNSAFE_LONG));
|
|
109
|
+
return hit === undefined ? null : `${hit} can write or run code`;
|
|
110
|
+
}
|
|
111
|
+
const listFlags = GIT_LIST_FLAGS[sub];
|
|
112
|
+
if (listFlags) {
|
|
113
|
+
const bad = rest.find((arg) => !listFlags.has(arg));
|
|
114
|
+
return bad === undefined ? null : `git ${sub} ${bad} is not a listing`;
|
|
115
|
+
}
|
|
116
|
+
const exact = rest.join(" ");
|
|
117
|
+
if ((sub === "stash" || sub === "worktree") && exact === "list") return null;
|
|
118
|
+
if (sub === "config") {
|
|
119
|
+
if (exact === "--list" || exact === "-l") return null;
|
|
120
|
+
if (rest.length === 2 && rest[0] === "--get" && !rest[1]?.startsWith("-")) return null;
|
|
121
|
+
}
|
|
122
|
+
return `git ${sub} is not on the read-only list`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface Parsed {
|
|
126
|
+
commands: string[][];
|
|
127
|
+
error?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Splits a command line into argv lists, refusing anything that needs a real shell. */
|
|
131
|
+
function parse(input: string): Parsed {
|
|
132
|
+
// Includes quoted text: these bytes can disguise a command in a terminal.
|
|
133
|
+
if (/[\x00-\x08\x0b-\x1f\x7f-\x9f]/.test(input)) return { commands: [], error: "control character" };
|
|
134
|
+
|
|
135
|
+
const commands: string[][] = [];
|
|
136
|
+
let argv: string[] = [];
|
|
137
|
+
let word = "";
|
|
138
|
+
let inWord = false;
|
|
139
|
+
let quote: "'" | '"' | null = null;
|
|
140
|
+
|
|
141
|
+
const endWord = () => {
|
|
142
|
+
if (inWord) argv.push(word);
|
|
143
|
+
word = "";
|
|
144
|
+
inWord = false;
|
|
145
|
+
};
|
|
146
|
+
const endCommand = () => {
|
|
147
|
+
endWord();
|
|
148
|
+
if (argv.length > 0) commands.push(argv);
|
|
149
|
+
argv = [];
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
153
|
+
const ch = input[i] as string;
|
|
154
|
+
if (quote === "'") {
|
|
155
|
+
if (ch === "'") quote = null;
|
|
156
|
+
else word += ch;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (quote === '"') {
|
|
160
|
+
if (ch === '"') quote = null;
|
|
161
|
+
else if (ch === "$" || ch === "`" || ch === "\\") return { commands, error: `${ch} inside double quotes` };
|
|
162
|
+
else word += ch;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (ch === "'" || ch === '"') {
|
|
166
|
+
quote = ch;
|
|
167
|
+
inWord = true;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (ch === " " || ch === "\t") {
|
|
171
|
+
endWord();
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (ch === "\n" || ch === ";") {
|
|
175
|
+
endCommand();
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (ch === "|") {
|
|
179
|
+
endCommand();
|
|
180
|
+
if (input[i + 1] === "|") i += 1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (ch === "&") {
|
|
184
|
+
if (input[i + 1] !== "&") return { commands, error: "background job" };
|
|
185
|
+
endCommand();
|
|
186
|
+
i += 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if ("$`\\<>(){}#!*?[]".includes(ch)) return { commands, error: `shell syntax ${ch}` };
|
|
190
|
+
word += ch;
|
|
191
|
+
inWord = true;
|
|
192
|
+
}
|
|
193
|
+
if (quote !== null) return { commands, error: "unbalanced quote" };
|
|
194
|
+
endCommand();
|
|
195
|
+
return { commands };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function classifyBash(input: string): BashVerdict {
|
|
199
|
+
const { commands, error } = parse(input);
|
|
200
|
+
if (error) return no(error);
|
|
201
|
+
if (commands.length === 0) return no("empty command");
|
|
202
|
+
|
|
203
|
+
for (const [name, ...args] of commands) {
|
|
204
|
+
if (name === undefined) return no("empty command");
|
|
205
|
+
if (name.includes("/")) return no(`${name} is run by path`);
|
|
206
|
+
if (!Object.hasOwn(COMMANDS, name)) return no(`${name} is not on the read-only list`);
|
|
207
|
+
const problem = (COMMANDS[name] as ArgCheck)(args);
|
|
208
|
+
if (problem) return no(`${name}: ${problem}`);
|
|
209
|
+
}
|
|
210
|
+
return YES;
|
|
211
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { isKeyRelease, matchesKey, Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { clearGate, installJudgeGate, JUDGE_NAMES, judgeGated, setGateMode } from "./judge-gate.ts";
|
|
7
|
+
import { decideToolCall, displayCommand, isMode, loadsAfterPermissionSystem, type Mode, MODES, modePrompt, nextMode, SUBMIT_PLAN_TOOL } from "./modes.ts";
|
|
8
|
+
|
|
9
|
+
const STATUS_KEY = "pi-modes";
|
|
10
|
+
const ENTRY_TYPE = "pi-modes";
|
|
11
|
+
const CYCLE_KEY = "shift+tab";
|
|
12
|
+
|
|
13
|
+
const STATUS_COLOR = { normal: "dim", auto: "warning", plan: "accent", "read-only": "success" } as const;
|
|
14
|
+
|
|
15
|
+
function chainNamesJudge(): boolean {
|
|
16
|
+
try {
|
|
17
|
+
const path = join(getAgentDir(), "extensions", "pi-permission-system", "config.json");
|
|
18
|
+
const chain: unknown = JSON.parse(readFileSync(path, "utf8")).authorizerChain;
|
|
19
|
+
return Array.isArray(chain) && chain.some((name) => JUDGE_NAMES.has(name));
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function loadOrderIsLate(): boolean {
|
|
26
|
+
try {
|
|
27
|
+
return loadsAfterPermissionSystem(JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf8")).packages);
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function restoredMode(ctx: ExtensionContext): Mode {
|
|
34
|
+
let mode: Mode = "normal";
|
|
35
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
36
|
+
if (entry.type !== "custom" || entry.customType !== ENTRY_TYPE) continue;
|
|
37
|
+
const saved: unknown = (entry.data as { mode?: unknown } | undefined)?.mode;
|
|
38
|
+
if (isMode(saved)) mode = saved;
|
|
39
|
+
}
|
|
40
|
+
return mode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function piModes(pi: ExtensionAPI): void {
|
|
44
|
+
installJudgeGate();
|
|
45
|
+
|
|
46
|
+
let mode: Mode = "normal";
|
|
47
|
+
let stopListening: (() => void) | undefined;
|
|
48
|
+
let warnedUngated = false;
|
|
49
|
+
|
|
50
|
+
function apply(next: Mode, ctx: ExtensionContext): void {
|
|
51
|
+
mode = next;
|
|
52
|
+
setGateMode(ctx.sessionManager.getSessionId(), next);
|
|
53
|
+
const others = pi.getActiveTools().filter((name) => name !== SUBMIT_PLAN_TOOL);
|
|
54
|
+
pi.setActiveTools(next === "plan" ? [...others, SUBMIT_PLAN_TOOL] : others);
|
|
55
|
+
if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg(STATUS_COLOR[next], `mode: ${next}`));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function warnIfJudgeUncontrolled(ctx: ExtensionContext): void {
|
|
59
|
+
if (warnedUngated || !ctx.hasUI || judgeGated(ctx.sessionManager.getSessionId()) || !chainNamesJudge()) return;
|
|
60
|
+
warnedUngated = true;
|
|
61
|
+
ctx.ui.notify(
|
|
62
|
+
"pi-modes could not take control of the permission judge. It may still auto-approve outside auto mode. See the README.",
|
|
63
|
+
"warning",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function change(next: Mode, ctx: ExtensionContext): void {
|
|
68
|
+
if (next === mode) return;
|
|
69
|
+
apply(next, ctx);
|
|
70
|
+
pi.appendEntry(ENTRY_TYPE, { mode: next });
|
|
71
|
+
if (!ctx.hasUI) return;
|
|
72
|
+
// A judge named in the chain may not have registered yet: some load orders
|
|
73
|
+
// only register it during the first prompt. agent_start checks that case.
|
|
74
|
+
if (next === "auto" && !judgeGated(ctx.sessionManager.getSessionId()) && !chainNamesJudge()) {
|
|
75
|
+
ctx.ui.notify(
|
|
76
|
+
"Auto has no permission judge, so it behaves like normal. Set up pi-permission-classifier to use it.",
|
|
77
|
+
"warning",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
pi.on("session_start", (_event, ctx) => {
|
|
83
|
+
apply(restoredMode(ctx), ctx);
|
|
84
|
+
if (ctx.mode === "tui") {
|
|
85
|
+
if (loadOrderIsLate()) {
|
|
86
|
+
ctx.ui.notify(
|
|
87
|
+
"pi-modes loads after pi-permission-system, so you may be asked to approve actions that are then blocked. Move it first in packages in settings.json.",
|
|
88
|
+
"warning",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
stopListening = ctx.ui.onTerminalInput((data) => {
|
|
92
|
+
if (!matchesKey(data, CYCLE_KEY)) return undefined;
|
|
93
|
+
if (!isKeyRelease(data)) change(nextMode(mode), ctx);
|
|
94
|
+
return { consume: true };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
pi.on("session_tree", (_event, ctx) => apply(restoredMode(ctx), ctx));
|
|
100
|
+
|
|
101
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
102
|
+
stopListening?.();
|
|
103
|
+
stopListening = undefined;
|
|
104
|
+
clearGate(ctx.sessionManager.getSessionId());
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Runs after every before_agent_start handler, which is the judge's last chance to register.
|
|
108
|
+
pi.on("agent_start", (_event, ctx) => warnIfJudgeUncontrolled(ctx));
|
|
109
|
+
|
|
110
|
+
pi.on("before_agent_start", (event) => {
|
|
111
|
+
const prompt = modePrompt(mode);
|
|
112
|
+
return prompt ? { systemPrompt: `${event.systemPrompt}\n\n${prompt}` } : undefined;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
116
|
+
const decision = decideToolCall(mode, event.toolName, event.input);
|
|
117
|
+
if (decision.action === "allow") return undefined;
|
|
118
|
+
if (decision.action === "block") return { block: true, reason: decision.reason };
|
|
119
|
+
|
|
120
|
+
const denied = `Plan mode blocked this command: ${decision.reason}.`;
|
|
121
|
+
if (!ctx.hasUI) return { block: true, reason: denied };
|
|
122
|
+
const command = (event.input as { command: string }).command;
|
|
123
|
+
const approved = await ctx.ui.confirm("Plan mode: run this command?", `${decision.reason}\n\n${displayCommand(command)}`);
|
|
124
|
+
return approved ? undefined : { block: true, reason: denied };
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
pi.registerTool({
|
|
128
|
+
name: SUBMIT_PLAN_TOOL,
|
|
129
|
+
label: "Submit plan",
|
|
130
|
+
description: "Show the finished plan to the user for approval. Only available in plan mode.",
|
|
131
|
+
parameters: Type.Object({
|
|
132
|
+
plan: Type.String({ description: "The complete plan, as markdown" }),
|
|
133
|
+
}),
|
|
134
|
+
renderCall: (args, theme) => new Text(`${theme.bold("Plan")}\n${args.plan ?? ""}`, 0, 0),
|
|
135
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
136
|
+
const reply = (text: string) => ({ content: [{ type: "text" as const, text }], details: {} });
|
|
137
|
+
if (mode !== "plan") return reply("Plan mode is off, so there is nothing to approve.");
|
|
138
|
+
if (!ctx.hasUI) return reply("Nobody can approve a plan here. Stay in plan mode.");
|
|
139
|
+
|
|
140
|
+
const NORMAL = "Approve, and ask before risky actions (normal)";
|
|
141
|
+
const AUTO = "Approve, and let the judge approve actions (auto)";
|
|
142
|
+
const KEEP = "Keep planning";
|
|
143
|
+
const choice = await ctx.ui.select("Approve this plan?", [NORMAL, AUTO, KEEP]);
|
|
144
|
+
|
|
145
|
+
if (choice === NORMAL || choice === AUTO) {
|
|
146
|
+
const next: Mode = choice === AUTO ? "auto" : "normal";
|
|
147
|
+
change(next, ctx);
|
|
148
|
+
return reply(`The user approved the plan and switched to ${next} mode. Implement it now.`);
|
|
149
|
+
}
|
|
150
|
+
const feedback = await ctx.ui.input("What should change?", "");
|
|
151
|
+
return reply(`The user did not approve the plan. Stay in plan mode. ${feedback ? `Feedback: ${feedback}` : "Ask what to change."}`);
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
pi.registerCommand("mode", {
|
|
156
|
+
description: `Show or set the mode: ${MODES.join(", ")}`,
|
|
157
|
+
getArgumentCompletions: (prefix) => {
|
|
158
|
+
const items = MODES.filter((name) => name.startsWith(prefix.trim())).map((value) => ({ value, label: value }));
|
|
159
|
+
return items.length > 0 ? items : null;
|
|
160
|
+
},
|
|
161
|
+
handler: async (args, ctx) => {
|
|
162
|
+
const requested = args.trim();
|
|
163
|
+
if (requested === "") {
|
|
164
|
+
const picked = ctx.hasUI ? await ctx.ui.select(`Mode (now ${mode})`, [...MODES]) : undefined;
|
|
165
|
+
if (isMode(picked)) change(picked, ctx);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!isMode(requested)) {
|
|
169
|
+
ctx.ui.notify(`Unknown mode ${requested}. Use one of: ${MODES.join(", ")}.`, "error");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
change(requested, ctx);
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lets the permission judge run only in auto mode, per session.
|
|
3
|
+
*
|
|
4
|
+
* pi-permission-system reads `authorizerChain` from a config file shared by
|
|
5
|
+
* every Pi process, so editing it would flip auto for all open sessions. It
|
|
6
|
+
* also has no API to read or disable a registered link. Instead this wraps the
|
|
7
|
+
* judge at registration: outside auto the wrapper defers, which sends the ask
|
|
8
|
+
* on to the normal prompt.
|
|
9
|
+
*
|
|
10
|
+
* Package load order must not matter, so the wrap is installed where each
|
|
11
|
+
* session's service is published, which happens before any extension can
|
|
12
|
+
* register on it. This depends on the service map's documented global key. If
|
|
13
|
+
* that changes, `judgeGated` stays false and the extension warns.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Mode } from "./modes.ts";
|
|
17
|
+
|
|
18
|
+
export const JUDGE_NAMES: ReadonlySet<string> = new Set(["permission-judge", "classifier"]);
|
|
19
|
+
|
|
20
|
+
const SERVICES_KEY = Symbol.for("@gotgenes/pi-permission-system:session-services");
|
|
21
|
+
const STATE_KEY = Symbol.for("pi-modes:session-state");
|
|
22
|
+
const PATCHED = Symbol.for("pi-modes:patched");
|
|
23
|
+
|
|
24
|
+
type Authorize = (details: unknown, query: unknown, log: unknown) => Promise<unknown>;
|
|
25
|
+
|
|
26
|
+
interface ServiceLike {
|
|
27
|
+
registerAuthorizer?: (name: string, authorize: Authorize) => () => void;
|
|
28
|
+
[PATCHED]?: true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type ServiceMap = Map<string, ServiceLike> & { [PATCHED]?: true };
|
|
32
|
+
|
|
33
|
+
interface SessionState {
|
|
34
|
+
mode: Mode;
|
|
35
|
+
judgeGated: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Store = Record<symbol, unknown>;
|
|
39
|
+
|
|
40
|
+
function sessionStates(store: Store): Map<string, SessionState> {
|
|
41
|
+
store[STATE_KEY] ??= new Map<string, SessionState>();
|
|
42
|
+
return store[STATE_KEY] as Map<string, SessionState>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stateFor(store: Store, sessionId: string): SessionState {
|
|
46
|
+
const states = sessionStates(store);
|
|
47
|
+
let state = states.get(sessionId);
|
|
48
|
+
if (!state) {
|
|
49
|
+
state = { mode: "normal", judgeGated: false };
|
|
50
|
+
states.set(sessionId, state);
|
|
51
|
+
}
|
|
52
|
+
return state;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function gateService(store: Store, sessionId: string, service: ServiceLike): void {
|
|
56
|
+
if (service[PATCHED] || typeof service.registerAuthorizer !== "function") return;
|
|
57
|
+
service[PATCHED] = true;
|
|
58
|
+
const register = service.registerAuthorizer.bind(service);
|
|
59
|
+
service.registerAuthorizer = (name, authorize) => {
|
|
60
|
+
if (!JUDGE_NAMES.has(name)) return register(name, authorize);
|
|
61
|
+
stateFor(store, sessionId).judgeGated = true;
|
|
62
|
+
return register(name, async (details, query, log) =>
|
|
63
|
+
stateFor(store, sessionId).mode === "auto" ? authorize(details, query, log) : { kind: "defer" },
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Safe to call from every extension instance: the map is patched once per process. */
|
|
69
|
+
export function installJudgeGate(store: Store = globalThis as unknown as Store): void {
|
|
70
|
+
store[SERVICES_KEY] ??= new Map<string, ServiceLike>();
|
|
71
|
+
const services = store[SERVICES_KEY] as ServiceMap;
|
|
72
|
+
if (services[PATCHED]) return;
|
|
73
|
+
services[PATCHED] = true;
|
|
74
|
+
|
|
75
|
+
for (const [sessionId, service] of services) gateService(store, sessionId, service);
|
|
76
|
+
const set = services.set.bind(services);
|
|
77
|
+
services.set = (sessionId, service) => {
|
|
78
|
+
gateService(store, sessionId, service);
|
|
79
|
+
return set(sessionId, service);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function setGateMode(sessionId: string, mode: Mode, store: Store = globalThis as unknown as Store): void {
|
|
84
|
+
stateFor(store, sessionId).mode = mode;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function judgeGated(sessionId: string, store: Store = globalThis as unknown as Store): boolean {
|
|
88
|
+
return stateFor(store, sessionId).judgeGated;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function clearGate(sessionId: string, store: Store = globalThis as unknown as Store): void {
|
|
92
|
+
sessionStates(store).delete(sessionId);
|
|
93
|
+
}
|
package/src/modes.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { classifyBash } from "./bash.ts";
|
|
2
|
+
|
|
3
|
+
export const MODES = ["normal", "auto", "plan", "read-only"] as const;
|
|
4
|
+
export type Mode = (typeof MODES)[number];
|
|
5
|
+
|
|
6
|
+
export const SUBMIT_PLAN_TOOL = "submit_plan";
|
|
7
|
+
|
|
8
|
+
const READ_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
|
9
|
+
|
|
10
|
+
export function isMode(value: unknown): value is Mode {
|
|
11
|
+
return (MODES as readonly unknown[]).includes(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function nextMode(mode: Mode): Mode {
|
|
15
|
+
return MODES[(MODES.indexOf(mode) + 1) % MODES.length] as Mode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ToolDecision =
|
|
19
|
+
| { action: "allow" }
|
|
20
|
+
| { action: "ask"; reason: string }
|
|
21
|
+
| { action: "block"; reason: string };
|
|
22
|
+
|
|
23
|
+
const ALLOW: ToolDecision = { action: "allow" };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What a mode does to one tool call, before the permission system's own rules.
|
|
27
|
+
* Unknown tools are blocked in restricted modes, because an MCP tool or a
|
|
28
|
+
* subagent could write without this extension seeing how.
|
|
29
|
+
*/
|
|
30
|
+
export function decideToolCall(mode: Mode, toolName: string, input: unknown): ToolDecision {
|
|
31
|
+
if (mode === "normal" || mode === "auto") return ALLOW;
|
|
32
|
+
const leave = "Press Shift+Tab to change mode.";
|
|
33
|
+
if (READ_TOOLS.has(toolName)) return ALLOW;
|
|
34
|
+
if (toolName === SUBMIT_PLAN_TOOL && mode === "plan") return ALLOW;
|
|
35
|
+
|
|
36
|
+
if (toolName === "bash") {
|
|
37
|
+
const command = (input as { command?: unknown } | null)?.command;
|
|
38
|
+
if (typeof command !== "string") return { action: "block", reason: `${mode} mode: bash needs a command.` };
|
|
39
|
+
const verdict = classifyBash(command);
|
|
40
|
+
if (verdict.readOnly) return ALLOW;
|
|
41
|
+
if (mode === "plan") return { action: "ask", reason: verdict.reason };
|
|
42
|
+
return { action: "block", reason: `read-only mode blocked this command: ${verdict.reason}. ${leave}` };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { action: "block", reason: `${mode} mode blocks ${toolName}. ${leave}` };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Makes a command safe to show in a dialog. Control and bidirectional characters
|
|
50
|
+
* become visible escapes, so a command can't hide or reorder what the user sees.
|
|
51
|
+
*/
|
|
52
|
+
export function displayCommand(command: string): string {
|
|
53
|
+
return command.replace(
|
|
54
|
+
/[\x00-\x08\x0b-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g,
|
|
55
|
+
(ch) => `\\u${(ch.codePointAt(0) as number).toString(16).padStart(4, "0")}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** True when Pi will run the permission system's dialog before this extension's check. */
|
|
60
|
+
export function loadsAfterPermissionSystem(packages: unknown): boolean {
|
|
61
|
+
if (!Array.isArray(packages)) return false;
|
|
62
|
+
const names = packages.map((entry) => String((entry as { source?: unknown } | null)?.source ?? entry));
|
|
63
|
+
const self = names.findIndex((name) => name.includes("pi-modes"));
|
|
64
|
+
const gate = names.findIndex((name) => name.includes("pi-permission-system"));
|
|
65
|
+
return self !== -1 && gate !== -1 && self > gate;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const PLAN_PROMPT = `Plan mode is on. Research and propose, but do not implement.
|
|
69
|
+
- Do not edit files or change any state. write, edit, subagents and MCP tools are blocked.
|
|
70
|
+
- Read-only shell commands run freely. Any other command asks the user first, so run one only if the plan depends on its result.
|
|
71
|
+
- When the plan is ready, call ${SUBMIT_PLAN_TOOL} with the full plan. Do not start implementing until it is approved.`;
|
|
72
|
+
|
|
73
|
+
const READ_ONLY_PROMPT = `Read-only mode is on. Answer from what you can read.
|
|
74
|
+
- Do not edit files or change any state. write, edit, subagents and MCP tools are blocked.
|
|
75
|
+
- Only read-only shell commands run. Do not try to work around a blocked command.`;
|
|
76
|
+
|
|
77
|
+
export function modePrompt(mode: Mode): string | undefined {
|
|
78
|
+
if (mode === "plan") return PLAN_PROMPT;
|
|
79
|
+
if (mode === "read-only") return READ_ONLY_PROMPT;
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|