@andares/pi-toolkits 0.7.3 → 0.9.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/README.md +78 -6
- package/README.zh-CN.md +77 -6
- package/package.json +13 -7
- package/src/features/ask/index.test.ts +118 -0
- package/src/features/ask/index.ts +31 -13
- package/src/features/ask/reasons.ts +2 -2
- package/src/features/autostart/autostart.test.ts +45 -2
- package/src/features/autostart/autostart.ts +4 -2
- package/src/features/autostart/index.ts +100 -48
- package/src/features/compact/index.ts +3 -3
- package/src/features/compact/prompt.ts +2 -2
- package/src/features/favorites/constants.ts +3 -3
- package/src/features/favorites/patch-cycle.ts +6 -6
- package/src/features/favorites/patch-selector.ts +4 -4
- package/src/features/permission/constants.test.ts +60 -0
- package/src/features/permission/constants.ts +158 -0
- package/src/features/permission/index.test.ts +340 -0
- package/src/features/permission/index.ts +333 -0
- package/src/features/permission/modes.test.ts +66 -0
- package/src/features/permission/modes.ts +82 -0
- package/src/features/permission/panel.ts +118 -0
- package/src/features/permission/paths.test.ts +111 -0
- package/src/features/permission/paths.ts +126 -0
- package/src/features/permission/policy.test.ts +237 -0
- package/src/features/permission/policy.ts +338 -0
- package/src/features/permission/prompt.ts +146 -0
- package/src/features/permission/session-rules.ts +99 -0
- package/src/features/permission/units.test.ts +205 -0
- package/src/features/permission/units.ts +995 -0
- package/src/features/stash/stash.ts +1 -1
- package/src/index.ts +15 -2
- package/src/lib/gates.test.ts +171 -0
- package/src/lib/gates.ts +153 -0
- package/src/lib/model-config.ts +2 -2
- package/src/lib/model-picker.ts +2 -2
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash decomposition: segments, wrappers, substitution, cd folding, redirects,
|
|
3
|
+
* read/write classification, and the fail-closed cases.
|
|
4
|
+
*/
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { parseCommandUnits, stripHeredocs, type CommandUnit } from "./units.js";
|
|
8
|
+
|
|
9
|
+
const CWD = "/proj";
|
|
10
|
+
const HOME = homedir();
|
|
11
|
+
|
|
12
|
+
function summary(command: string) {
|
|
13
|
+
return parseCommandUnits(command, CWD).map((unit) => ({
|
|
14
|
+
command: unit.command,
|
|
15
|
+
head: unit.headWord,
|
|
16
|
+
effect: unit.effect,
|
|
17
|
+
opaque: unit.opaque,
|
|
18
|
+
base: unit.base,
|
|
19
|
+
targets: unit.targets.map((target) => `${target.effect}:${target.path}`),
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function first(command: string): CommandUnit {
|
|
24
|
+
return parseCommandUnits(command, CWD)[0];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("segments and cd folding", () => {
|
|
28
|
+
it("splits on && and folds cd into the following command", () => {
|
|
29
|
+
const units = summary("cd /tmp && rm x");
|
|
30
|
+
expect(units).toHaveLength(2);
|
|
31
|
+
expect(units[0]).toMatchObject({ command: "cd", base: CWD, targets: ["read:/tmp"] });
|
|
32
|
+
expect(units[1]).toMatchObject({ command: "rm", base: "/tmp", targets: ["write:/tmp/x"] });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("splits pipelines and keeps quoted operators as data", () => {
|
|
36
|
+
expect(summary("cat a | grep x")).toHaveLength(2);
|
|
37
|
+
expect(summary('echo "a && b"')).toHaveLength(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("strips leading VAR=value assignments", () => {
|
|
41
|
+
expect(first("FOO=bar rm x").command).toBe("rm");
|
|
42
|
+
expect(first("FOO=bar rm x").headWord).toBe("rm");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns no units for an empty command", () => {
|
|
46
|
+
expect(parseCommandUnits(" ", CWD)).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("wrappers and nested execution", () => {
|
|
51
|
+
it("peels sudo and keeps the wrapper visible", () => {
|
|
52
|
+
const unit = first("sudo -u root rm -rf /");
|
|
53
|
+
expect(unit).toMatchObject({ command: "rm", headWord: "sudo" });
|
|
54
|
+
expect(unit.targets).toEqual([{ path: "/", effect: "write", source: "operand" }]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("peels xargs and env", () => {
|
|
58
|
+
expect(first("xargs -I{} rm {}").command).toBe("rm");
|
|
59
|
+
expect(first("env FOO=bar rm x").command).toBe("rm");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("recurses into bash -c payloads", () => {
|
|
63
|
+
const unit = first("bash -c 'rm -rf /tmp/x'");
|
|
64
|
+
expect(unit).toMatchObject({ command: "rm", opaque: false });
|
|
65
|
+
expect(unit.targets[0]?.path).toBe("/tmp/x");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("finds a substitution inside a quoted word", () => {
|
|
69
|
+
const units = summary('echo "x$(rm y)"');
|
|
70
|
+
expect(units[0]).toMatchObject({ command: "rm", targets: ["write:/proj/y"] });
|
|
71
|
+
expect(units[1]?.command).toBe("echo");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("recurses into command substitution", () => {
|
|
75
|
+
const units = summary("echo $(rm -rf /tmp/x)");
|
|
76
|
+
expect(units[0]).toMatchObject({
|
|
77
|
+
command: "rm",
|
|
78
|
+
effect: "write",
|
|
79
|
+
targets: ["write:/tmp/x"],
|
|
80
|
+
});
|
|
81
|
+
expect(units[1]).toMatchObject({ command: "echo", effect: "read" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("recurses into find -exec", () => {
|
|
85
|
+
const units = summary("find . -name x -exec rm {} \\;");
|
|
86
|
+
expect(units.map((unit) => unit.command)).toContain("rm");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("redirects and heredocs", () => {
|
|
91
|
+
it("classifies redirect targets and ignores /dev/null", () => {
|
|
92
|
+
expect(summary("ls 2>/dev/null")[0]?.targets).toEqual([]);
|
|
93
|
+
expect(summary("echo hi > /tmp/x")[0]?.targets).toEqual(["write:/tmp/x"]);
|
|
94
|
+
expect(summary("cat < input > output")[0]?.targets).toEqual([
|
|
95
|
+
"read:/proj/input",
|
|
96
|
+
"write:/proj/output",
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("treats a redirect to a credential path as a write to it", () => {
|
|
101
|
+
expect(summary("echo a > ~/.ssh/authorized_keys")[0]?.targets).toEqual([
|
|
102
|
+
`write:${HOME}/.ssh/authorized_keys`,
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("keeps the redirect target as the target, not the command name", () => {
|
|
107
|
+
expect(summary("ls > out.txt")[0]?.targets).toEqual(["write:/proj/out.txt"]);
|
|
108
|
+
expect(summary("ls >> out.txt")[0]?.targets).toEqual(["write:/proj/out.txt"]);
|
|
109
|
+
expect(summary("ls 2> err.txt")[0]?.targets).toEqual(["write:/proj/err.txt"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("strips heredoc bodies and keeps the redirect", () => {
|
|
113
|
+
const unit = first("cat > f <<EOF\nhello\nEOF");
|
|
114
|
+
expect(unit).toMatchObject({ command: "cat", effect: "write" });
|
|
115
|
+
expect(unit.targets).toEqual([{ path: "/proj/f", effect: "write", source: "redirect" }]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("marks an unterminated heredoc opaque", () => {
|
|
119
|
+
expect(stripHeredocs("cat <<EOF\nunterminated")).toBeUndefined();
|
|
120
|
+
expect(first("cat <<EOF\nunterminated").opaque).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("read/write classification", () => {
|
|
125
|
+
it("knows read and write commands", () => {
|
|
126
|
+
expect(first("git log").effect).toBe("read");
|
|
127
|
+
expect(first("git push").effect).toBe("write");
|
|
128
|
+
expect(first("pnpm view react").effect).toBe("read");
|
|
129
|
+
expect(first("pnpm install").effect).toBe("write");
|
|
130
|
+
expect(first("cat notes.txt").effect).toBe("read");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("withdraws a read claim for write-capable options", () => {
|
|
134
|
+
const sed = first("sed -i s/a/b/ f.txt");
|
|
135
|
+
expect(sed.effect).toBe("write");
|
|
136
|
+
expect(sed.targets).toEqual([
|
|
137
|
+
{ path: "/proj/f.txt", effect: "write", source: "operand" },
|
|
138
|
+
]);
|
|
139
|
+
expect(first("sort -o out.txt f").targets).toEqual([
|
|
140
|
+
{ path: "/proj/out.txt", effect: "write", source: "operand" },
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("treats unknown commands as writes", () => {
|
|
145
|
+
expect(first("frobnicate x").effect).toBe("write");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("paths", () => {
|
|
150
|
+
it("treats rm/mv/cp operands as targets even when bare", () => {
|
|
151
|
+
expect(summary("rm notes.txt")[0]?.targets).toEqual(["write:/proj/notes.txt"]);
|
|
152
|
+
expect(summary("cp a b")[0]?.targets).toEqual(["write:/proj/a", "write:/proj/b"]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("resolves git -C against the -C directory, not the base", () => {
|
|
156
|
+
expect(summary("git -C ../other log")[0]?.targets).toEqual([
|
|
157
|
+
"read:/other",
|
|
158
|
+
]);
|
|
159
|
+
expect(summary("git -C /tmp log")[0]?.targets).toEqual(["read:/tmp"]);
|
|
160
|
+
expect(summary("git -C \"$DIR\" log")[0]?.opaque).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("collapses a glob to the directory it expands in", () => {
|
|
164
|
+
expect(summary("rm -rf *")[0]?.targets).toEqual([`write:${CWD}`]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("does not treat URLs as paths", () => {
|
|
168
|
+
expect(summary("curl -o /tmp/x https://example.com/a")[0]?.targets).toEqual([
|
|
169
|
+
"write:/tmp/x",
|
|
170
|
+
]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("expands ~ while parsing", () => {
|
|
174
|
+
expect(summary("tee ~/x")[0]?.targets).toEqual([`write:${HOME}/x`]);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("fail closed", () => {
|
|
179
|
+
it("marks an unresolved variable in a path position opaque", () => {
|
|
180
|
+
expect(summary("echo $UNKNOWN/x")).toEqual([
|
|
181
|
+
expect.objectContaining({ opaque: true, targets: [] }),
|
|
182
|
+
]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("marks a non-literal cd and its following relative command opaque", () => {
|
|
186
|
+
const units = summary('cd "$DIR" && rm x');
|
|
187
|
+
expect(units[0]?.opaque).toBe(true);
|
|
188
|
+
expect(units[1]?.opaque).toBe(true);
|
|
189
|
+
expect(units[1]?.base).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("ignores comments, including apostrophes inside them", () => {
|
|
193
|
+
expect(first("echo hi # it's fine").opaque).toBe(false);
|
|
194
|
+
expect(first("echo hi # a 'quote").opaque).toBe(false);
|
|
195
|
+
expect(first("echo hi # x '").targets).toEqual([]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("marks unbalanced quoting opaque", () => {
|
|
199
|
+
expect(first('echo "unclosed').opaque).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("marks a non-literal shell payload opaque", () => {
|
|
203
|
+
expect(first('bash -c "$CMD"').opaque).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
});
|