@andares/pi-toolkits 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 +51 -0
- package/package.json +44 -0
- package/src/features/ask/bash-sandbox.test.ts +66 -0
- package/src/features/ask/bash-sandbox.ts +39 -0
- package/src/features/ask/constants.ts +13 -0
- package/src/features/ask/index.ts +112 -0
- package/src/features/ask/prompt.ts +22 -0
- package/src/index.ts +16 -0
- package/src/lib/tools.ts +23 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andares Cui
|
|
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,51 @@
|
|
|
1
|
+
# @andares/pi-toolkits
|
|
2
|
+
|
|
3
|
+
Integrated toolkit extension for [pi](https://github.com/earendil-works/pi-coding-agent) — a single npm package that grows new capabilities over time. Currently provides **ask mode**.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
### ask — read-only Q&A mode
|
|
8
|
+
|
|
9
|
+
Toggle with `/ask` (press again to exit). While active:
|
|
10
|
+
|
|
11
|
+
- **`write`/`edit` hard-disabled** — removed from the active tool set; pi's runtime rejects calls to non-active tools, so file modification is genuinely impossible
|
|
12
|
+
- **`bash` kept but sandboxed to read-only commands** — shell-quote tokenization with per-segment validation plus write-intent detection (`curl -o/-O`, `wget -O`, `dd of=`, `tee`, redirects); heuristic, not a security boundary
|
|
13
|
+
- **System prompt banner** appended describing the current mode and its restrictions
|
|
14
|
+
- **Gray `ask` footer status** while active
|
|
15
|
+
|
|
16
|
+
Exiting restores the exact tool set from before ask mode was entered.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# local development
|
|
22
|
+
pi install .
|
|
23
|
+
# or one-off session
|
|
24
|
+
pi -e ./src/index.ts
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Development
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pnpm install # install deps (pnpm is the only supported toolchain)
|
|
31
|
+
pnpm typecheck # tsc --noEmit
|
|
32
|
+
pnpm test # vitest (bash sandbox boundary cases)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Project structure
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
src/
|
|
39
|
+
├── index.ts # entry point: aggregates feature modules
|
|
40
|
+
├── lib/ # shared infrastructure (tool-set snapshot/restore)
|
|
41
|
+
└── features/ # feature modules — one directory per feature
|
|
42
|
+
└── ask/ # ask mode: command + state machine, bash sandbox,
|
|
43
|
+
# system prompt banner, tests
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Adding a new feature = create `src/features/<name>/` exporting `registerXxx(pi)`
|
|
47
|
+
and call it from `src/index.ts`. Existing modules stay untouched.
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@andares/pi-toolkits",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Integrated toolkit extension for pi — currently provides ask mode (read-only Q&A); more features to come",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/andares/pi-toolkits.git"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"pi",
|
|
19
|
+
"pi-extension",
|
|
20
|
+
"ask-mode",
|
|
21
|
+
"readonly",
|
|
22
|
+
"toolkit"
|
|
23
|
+
],
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@dreki-gg/pi-command-sandbox": "^0.4.3"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
32
|
+
"typescript": "^5.6.0",
|
|
33
|
+
"vitest": "^4.1.10"
|
|
34
|
+
},
|
|
35
|
+
"pi": {
|
|
36
|
+
"extensions": [
|
|
37
|
+
"./src/index.ts"
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"test": "vitest run"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boundary tests for the ask-mode read-only bash sandbox.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { isReadOnlyCommand } from "./bash-sandbox.js";
|
|
6
|
+
|
|
7
|
+
describe("isReadOnlyCommand — allowed (read-only)", () => {
|
|
8
|
+
const allowed = [
|
|
9
|
+
"echo hi",
|
|
10
|
+
"echo a && echo b",
|
|
11
|
+
"ls -la",
|
|
12
|
+
"cat package.json",
|
|
13
|
+
"head -20 README.md",
|
|
14
|
+
"git status",
|
|
15
|
+
"git log --oneline -5",
|
|
16
|
+
"git diff --stat",
|
|
17
|
+
"grep -r foo src",
|
|
18
|
+
"find . -name '*.ts'",
|
|
19
|
+
"cd src && ls",
|
|
20
|
+
"node --version",
|
|
21
|
+
"npm list --depth=0",
|
|
22
|
+
"curl -s https://example.com",
|
|
23
|
+
"curl -o - https://example.com", // stdout, allowed
|
|
24
|
+
"wget -O - https://example.com", // stdout, allowed
|
|
25
|
+
"echo 'hello && world'", // quoted operator is data, not control
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
it.each(allowed)("allows: %s", (command) => {
|
|
29
|
+
expect(isReadOnlyCommand(command)).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("isReadOnlyCommand — blocked (mutating/destructive)", () => {
|
|
34
|
+
const blocked = [
|
|
35
|
+
"rm -rf /",
|
|
36
|
+
"echo a && rm -rf /",
|
|
37
|
+
"mv a b",
|
|
38
|
+
"cp a b",
|
|
39
|
+
"mkdir -p foo",
|
|
40
|
+
"touch foo",
|
|
41
|
+
"npm install",
|
|
42
|
+
"npm run build",
|
|
43
|
+
"git commit -m x",
|
|
44
|
+
"git push",
|
|
45
|
+
"git checkout main",
|
|
46
|
+
"cat x > out.txt",
|
|
47
|
+
"echo hi >> log",
|
|
48
|
+
"echo $(rm -rf /)", // command substitution
|
|
49
|
+
"ls | rm -rf /", // pipeline with destructive command
|
|
50
|
+
"curl -o file https://example.com",
|
|
51
|
+
"curl -O https://example.com/x",
|
|
52
|
+
"curl --output file https://example.com",
|
|
53
|
+
"wget -O file https://example.com",
|
|
54
|
+
"wget --output-document file https://example.com",
|
|
55
|
+
"wget -qO- https://example.com", // sandbox rejects (only wget -O - is safe); fine, be strict
|
|
56
|
+
"dd if=/dev/zero of=/tmp/x bs=1 count=1",
|
|
57
|
+
"echo x | tee file",
|
|
58
|
+
"vim README.md",
|
|
59
|
+
"sudo ls",
|
|
60
|
+
"kill -9 1",
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
it.each(blocked)("blocks: %s", (command) => {
|
|
64
|
+
expect(isReadOnlyCommand(command)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ask feature — read-only bash sandbox.
|
|
3
|
+
*
|
|
4
|
+
* Delegates to @dreki-gg/pi-command-sandbox: proper shell-quote tokenization,
|
|
5
|
+
* per-segment validation (&& | ; etc.), command-substitution rejection and
|
|
6
|
+
* deny-by-default for unmodeled operators.
|
|
7
|
+
*
|
|
8
|
+
* On top of that, this wrapper adds write-intent detection for known blind
|
|
9
|
+
* spots: allowlisted commands whose arguments can write files (curl -o/-O,
|
|
10
|
+
* wget -O, dd of=, tee). Heuristic only — not a security boundary.
|
|
11
|
+
*/
|
|
12
|
+
import { isSafeCommand } from "@dreki-gg/pi-command-sandbox";
|
|
13
|
+
|
|
14
|
+
const WRITE_INTENT_PATTERNS: RegExp[] = [
|
|
15
|
+
// curl --output FILE
|
|
16
|
+
/\bcurl\b[^|;&]*\s--output\b/i,
|
|
17
|
+
// curl -o FILE (curl -o - / -o- = stdout, allowed)
|
|
18
|
+
/\bcurl\b[^|;&]*\s-o\s+(?!-)\S+/,
|
|
19
|
+
// curl -O (uppercase: save to disk under remote name)
|
|
20
|
+
/\bcurl\b[^|;&]*\s-O\b/,
|
|
21
|
+
// wget --output-document FILE
|
|
22
|
+
/\bwget\b[^|;&]*\s--output-document\b/i,
|
|
23
|
+
// wget -O FILE (wget -O - / -qO- = stdout, allowed)
|
|
24
|
+
/\bwget\b[^|;&]*\s-O\s+(?!-)\S+/,
|
|
25
|
+
// dd of=FILE
|
|
26
|
+
/\bdd\b[^|;&]*\sof=/i,
|
|
27
|
+
// tee FILE (writes to stdout AND file)
|
|
28
|
+
/\btee\b/i,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function hasWriteIntent(command: string): boolean {
|
|
32
|
+
return WRITE_INTENT_PATTERNS.some((pattern) => pattern.test(command));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True when `command` is considered read-only by the sandbox. */
|
|
36
|
+
export function isReadOnlyCommand(command: string): boolean {
|
|
37
|
+
if (hasWriteIntent(command)) return false;
|
|
38
|
+
return isSafeCommand(command);
|
|
39
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ask feature — constants (tool names, UI keys, labels).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Tools hard-removed from the active set while ask mode is on. */
|
|
6
|
+
export const ASK_BLOCKED_TOOLS: ReadonlySet<string> = new Set([
|
|
7
|
+
"write",
|
|
8
|
+
"edit",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
/** Footer status key (extension-scoped) and label. */
|
|
12
|
+
export const ASK_STATUS_KEY = "ask-mode";
|
|
13
|
+
export const ASK_STATUS_LABEL = "ask";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ask feature — read-only Q&A mode.
|
|
3
|
+
*
|
|
4
|
+
* Design notes (see PLAN.md):
|
|
5
|
+
* - Hard guarantee: write/edit are removed from the active tool set via
|
|
6
|
+
* pi.setActiveTools(); pi's runtime rejects calls to non-active tools
|
|
7
|
+
* ("Tool not found") so nothing executes.
|
|
8
|
+
* - bash stays available but is gated by a read-only sandbox
|
|
9
|
+
* (see bash-sandbox.ts) via the tool_call hook.
|
|
10
|
+
* - Mode banner appended to the system prompt via before_agent_start
|
|
11
|
+
* chained systemPrompt (see prompt.ts).
|
|
12
|
+
*/
|
|
13
|
+
import type {
|
|
14
|
+
ExtensionAPI,
|
|
15
|
+
ExtensionContext,
|
|
16
|
+
} from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import {
|
|
18
|
+
restoreActiveTools,
|
|
19
|
+
snapshotActiveTools,
|
|
20
|
+
withoutTools,
|
|
21
|
+
} from "../../lib/tools.js";
|
|
22
|
+
import {
|
|
23
|
+
ASK_BLOCKED_TOOLS,
|
|
24
|
+
ASK_STATUS_KEY,
|
|
25
|
+
ASK_STATUS_LABEL,
|
|
26
|
+
} from "./constants.js";
|
|
27
|
+
import { isReadOnlyCommand } from "./bash-sandbox.js";
|
|
28
|
+
import { appendAskBanner } from "./prompt.js";
|
|
29
|
+
|
|
30
|
+
export function registerAsk(pi: ExtensionAPI): void {
|
|
31
|
+
let enabled = false;
|
|
32
|
+
/** Active tools before ask mode was entered (for exact restore on exit). */
|
|
33
|
+
let snapshot: string[] = [];
|
|
34
|
+
|
|
35
|
+
/** Gray footer status while ask mode is active; cleared on exit. */
|
|
36
|
+
function updateStatus(ctx: ExtensionContext): void {
|
|
37
|
+
ctx.ui.setStatus(
|
|
38
|
+
ASK_STATUS_KEY,
|
|
39
|
+
enabled ? ctx.ui.theme.fg("muted", ASK_STATUS_LABEL) : undefined,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function enter(ctx: ExtensionContext): void {
|
|
44
|
+
if (enabled) return;
|
|
45
|
+
enabled = true;
|
|
46
|
+
snapshot = snapshotActiveTools(pi);
|
|
47
|
+
pi.setActiveTools(withoutTools(snapshot, ASK_BLOCKED_TOOLS));
|
|
48
|
+
updateStatus(ctx);
|
|
49
|
+
ctx.ui.notify(
|
|
50
|
+
"Ask mode ON — read-only (write/edit disabled). /ask to exit.",
|
|
51
|
+
"info",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function exit(ctx: ExtensionContext): void {
|
|
56
|
+
if (!enabled) return;
|
|
57
|
+
enabled = false;
|
|
58
|
+
restoreActiveTools(pi, snapshot);
|
|
59
|
+
snapshot = [];
|
|
60
|
+
updateStatus(ctx);
|
|
61
|
+
ctx.ui.notify("Ask mode OFF — full tool access restored.", "info");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toggle(ctx: ExtensionContext): void {
|
|
65
|
+
if (enabled) {
|
|
66
|
+
exit(ctx);
|
|
67
|
+
} else {
|
|
68
|
+
enter(ctx);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
pi.registerCommand("ask", {
|
|
73
|
+
description: "Toggle ask mode (read-only Q&A)",
|
|
74
|
+
handler: async (_args, ctx) => {
|
|
75
|
+
toggle(ctx);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Defense-in-depth gate while ask mode is active:
|
|
80
|
+
// - write/edit are already removed from the active set (runtime rejects
|
|
81
|
+
// them), this blocks them again in case anything re-enables them.
|
|
82
|
+
// - bash is kept available but only read-only commands pass the sandbox.
|
|
83
|
+
pi.on("tool_call", (event) => {
|
|
84
|
+
if (!enabled) return;
|
|
85
|
+
|
|
86
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
87
|
+
return {
|
|
88
|
+
block: true,
|
|
89
|
+
reason:
|
|
90
|
+
"Ask mode: write/edit are disabled. Use /ask to exit ask mode first.",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (event.toolName === "bash") {
|
|
95
|
+
const command = event.input.command as string;
|
|
96
|
+
if (!isReadOnlyCommand(command)) {
|
|
97
|
+
return {
|
|
98
|
+
block: true,
|
|
99
|
+
reason: `Ask mode: only read-only bash commands are allowed.\nCommand: ${command}\nUse /ask to exit ask mode first.`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Mode banner: chained append to the current system prompt while enabled.
|
|
106
|
+
pi.on("before_agent_start", (event) => {
|
|
107
|
+
if (!enabled) return;
|
|
108
|
+
// event.systemPrompt is the current (possibly already modified by other
|
|
109
|
+
// extensions) system prompt — append, don't replace.
|
|
110
|
+
return { systemPrompt: appendAskBanner(event.systemPrompt) };
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ask feature — system prompt banner.
|
|
3
|
+
*
|
|
4
|
+
* Appended to the system prompt (chained append, not replacement) while
|
|
5
|
+
* ask mode is active so the model knows the current mode and its
|
|
6
|
+
* restrictions. Do NOT use the removed `systemPromptAppend` field or
|
|
7
|
+
* wholesale systemPrompt replacement (see PLAN.md).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const ASK_BANNER = `[ASK MODE]
|
|
11
|
+
You are in ask mode — a read-only Q&A mode. You may read, search, and analyze the codebase, but you MUST NOT modify anything.
|
|
12
|
+
|
|
13
|
+
Restrictions:
|
|
14
|
+
- write and edit are disabled (hard-blocked; calls will fail)
|
|
15
|
+
- bash is restricted to read-only commands (ls, cat, grep, git status/log/diff, etc.)
|
|
16
|
+
|
|
17
|
+
Answer the user's question directly. If a question requires changing files, say so and suggest exiting ask mode with /ask.`;
|
|
18
|
+
|
|
19
|
+
/** Append the ask-mode banner to an existing system prompt. */
|
|
20
|
+
export function appendAskBanner(systemPrompt: string): string {
|
|
21
|
+
return `${systemPrompt}\n\n${ASK_BANNER}`;
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @andares/pi-toolkits — integrated pi extension entry point.
|
|
3
|
+
*
|
|
4
|
+
* Registers every feature module. Add new features by creating
|
|
5
|
+
* src/features/<name>/ with a registerXxx(pi) export and calling it here.
|
|
6
|
+
*
|
|
7
|
+
* Current features:
|
|
8
|
+
* - ask — read-only Q&A mode (/ask)
|
|
9
|
+
*/
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { registerAsk } from "./features/ask/index.js";
|
|
12
|
+
|
|
13
|
+
export default function piToolkits(pi: ExtensionAPI): void {
|
|
14
|
+
registerAsk(pi);
|
|
15
|
+
// registerXxx(pi); // future features
|
|
16
|
+
}
|
package/src/lib/tools.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared tool-set management helpers (feature-agnostic).
|
|
3
|
+
*/
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
/** Snapshot the currently active tool names (for later restore). */
|
|
7
|
+
export function snapshotActiveTools(pi: ExtensionAPI): string[] {
|
|
8
|
+
return [...pi.getActiveTools()];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Restore a previously snapshotted tool set. No-op when the snapshot is empty. */
|
|
12
|
+
export function restoreActiveTools(pi: ExtensionAPI, snapshot: string[]): void {
|
|
13
|
+
if (snapshot.length === 0) return;
|
|
14
|
+
pi.setActiveTools([...snapshot]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Return `active` minus the tools in `removed` (order preserved). */
|
|
18
|
+
export function withoutTools(
|
|
19
|
+
active: readonly string[],
|
|
20
|
+
removed: ReadonlySet<string>,
|
|
21
|
+
): string[] {
|
|
22
|
+
return active.filter((name) => !removed.has(name));
|
|
23
|
+
}
|