@oyasmi/pipiclaw 0.5.5 → 0.5.6
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 +35 -0
- package/dist/agent/channel-runner.d.ts +1 -0
- package/dist/agent/channel-runner.js +3 -0
- package/dist/agent/runner-factory.d.ts +2 -0
- package/dist/agent/runner-factory.js +6 -0
- package/dist/agent/types.d.ts +1 -0
- package/dist/memory/lifecycle.d.ts +2 -1
- package/dist/memory/lifecycle.js +19 -1
- package/dist/paths.js +1 -1
- package/dist/runtime/bootstrap.d.ts +22 -1
- package/dist/runtime/bootstrap.js +72 -26
- package/dist/security/command-guard.d.ts +16 -0
- package/dist/security/command-guard.js +447 -0
- package/dist/security/config.d.ts +4 -0
- package/dist/security/config.js +82 -0
- package/dist/security/logger.d.ts +2 -0
- package/dist/security/logger.js +18 -0
- package/dist/security/path-guard.d.ts +2 -0
- package/dist/security/path-guard.js +237 -0
- package/dist/security/types.d.ts +66 -0
- package/dist/security/types.js +1 -0
- package/dist/subagents/tool.d.ts +2 -0
- package/dist/subagents/tool.js +31 -7
- package/dist/tools/attach.d.ts +7 -1
- package/dist/tools/attach.js +36 -1
- package/dist/tools/bash.d.ts +4 -0
- package/dist/tools/bash.js +38 -0
- package/dist/tools/edit.d.ts +7 -1
- package/dist/tools/edit.js +42 -2
- package/dist/tools/index.d.ts +7 -1
- package/dist/tools/index.js +29 -3
- package/dist/tools/read.d.ts +7 -1
- package/dist/tools/read.js +36 -1
- package/dist/tools/write-content.d.ts +5 -0
- package/dist/tools/write-content.js +32 -11
- package/dist/tools/write.d.ts +7 -1
- package/dist/tools/write.js +10 -3
- package/package.json +2 -1
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
const WHITESPACE = /\s+/;
|
|
3
|
+
function stripNullAndNormalize(text) {
|
|
4
|
+
return text.replace(/\0/g, "").normalize("NFKC");
|
|
5
|
+
}
|
|
6
|
+
function stripComments(command) {
|
|
7
|
+
let result = "";
|
|
8
|
+
let inSingle = false;
|
|
9
|
+
let inDouble = false;
|
|
10
|
+
let escaped = false;
|
|
11
|
+
for (let i = 0; i < command.length; i++) {
|
|
12
|
+
const char = command[i];
|
|
13
|
+
if (escaped) {
|
|
14
|
+
result += char;
|
|
15
|
+
escaped = false;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (!inSingle && char === "\\") {
|
|
19
|
+
result += char;
|
|
20
|
+
escaped = true;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (!inDouble && char === "'") {
|
|
24
|
+
inSingle = !inSingle;
|
|
25
|
+
result += char;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (!inSingle && char === '"') {
|
|
29
|
+
inDouble = !inDouble;
|
|
30
|
+
result += char;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!inSingle && !inDouble && char === "#") {
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
result += char;
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
function splitCommandChain(command) {
|
|
41
|
+
const atoms = [];
|
|
42
|
+
const normalized = stripComments(stripNullAndNormalize(command));
|
|
43
|
+
function pushAtom(atom) {
|
|
44
|
+
const trimmed = atom.trim();
|
|
45
|
+
if (trimmed) {
|
|
46
|
+
atoms.push(trimmed);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function walk(input) {
|
|
50
|
+
let buffer = "";
|
|
51
|
+
let inSingle = false;
|
|
52
|
+
let inDouble = false;
|
|
53
|
+
let escaped = false;
|
|
54
|
+
let parenDepth = 0;
|
|
55
|
+
for (let i = 0; i < input.length; i++) {
|
|
56
|
+
const char = input[i];
|
|
57
|
+
const next = input[i + 1];
|
|
58
|
+
if (escaped) {
|
|
59
|
+
buffer += char;
|
|
60
|
+
escaped = false;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (char === "\\") {
|
|
64
|
+
buffer += char;
|
|
65
|
+
escaped = true;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!inDouble && char === "'") {
|
|
69
|
+
inSingle = !inSingle;
|
|
70
|
+
buffer += char;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!inSingle && char === '"') {
|
|
74
|
+
inDouble = !inDouble;
|
|
75
|
+
buffer += char;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!inSingle && char === "`") {
|
|
79
|
+
let j = i + 1;
|
|
80
|
+
let inner = "";
|
|
81
|
+
let innerEscaped = false;
|
|
82
|
+
while (j < input.length) {
|
|
83
|
+
const innerChar = input[j];
|
|
84
|
+
if (innerEscaped) {
|
|
85
|
+
inner += innerChar;
|
|
86
|
+
innerEscaped = false;
|
|
87
|
+
j++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (innerChar === "\\") {
|
|
91
|
+
inner += innerChar;
|
|
92
|
+
innerEscaped = true;
|
|
93
|
+
j++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (innerChar === "`") {
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
inner += innerChar;
|
|
100
|
+
j++;
|
|
101
|
+
}
|
|
102
|
+
if (j < input.length) {
|
|
103
|
+
walk(inner);
|
|
104
|
+
buffer += "`subshell`";
|
|
105
|
+
i = j;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!inSingle && char === "$" && next === "(") {
|
|
110
|
+
let j = i + 2;
|
|
111
|
+
let inner = "";
|
|
112
|
+
let depth = 1;
|
|
113
|
+
let innerSingle = false;
|
|
114
|
+
let innerDouble = false;
|
|
115
|
+
let innerEscaped = false;
|
|
116
|
+
while (j < input.length) {
|
|
117
|
+
const innerChar = input[j];
|
|
118
|
+
if (innerEscaped) {
|
|
119
|
+
inner += innerChar;
|
|
120
|
+
innerEscaped = false;
|
|
121
|
+
j++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (innerChar === "\\") {
|
|
125
|
+
inner += innerChar;
|
|
126
|
+
innerEscaped = true;
|
|
127
|
+
j++;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (!innerDouble && innerChar === "'") {
|
|
131
|
+
innerSingle = !innerSingle;
|
|
132
|
+
inner += innerChar;
|
|
133
|
+
j++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!innerSingle && innerChar === '"') {
|
|
137
|
+
innerDouble = !innerDouble;
|
|
138
|
+
inner += innerChar;
|
|
139
|
+
j++;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!innerSingle && !innerDouble && innerChar === "(") {
|
|
143
|
+
depth++;
|
|
144
|
+
inner += innerChar;
|
|
145
|
+
j++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (!innerSingle && !innerDouble && innerChar === ")") {
|
|
149
|
+
depth--;
|
|
150
|
+
if (depth === 0) {
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
inner += innerChar;
|
|
154
|
+
j++;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
inner += innerChar;
|
|
158
|
+
j++;
|
|
159
|
+
}
|
|
160
|
+
if (depth === 0) {
|
|
161
|
+
walk(inner);
|
|
162
|
+
buffer += "$(subshell)";
|
|
163
|
+
i = j;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (!inSingle && !inDouble) {
|
|
168
|
+
if (char === "(") {
|
|
169
|
+
parenDepth++;
|
|
170
|
+
}
|
|
171
|
+
else if (char === ")" && parenDepth > 0) {
|
|
172
|
+
parenDepth--;
|
|
173
|
+
}
|
|
174
|
+
const separator = parenDepth === 0 &&
|
|
175
|
+
(char === ";" ||
|
|
176
|
+
char === "\n" ||
|
|
177
|
+
(char === "|" && next === "|") ||
|
|
178
|
+
(char === "&" && next === "&") ||
|
|
179
|
+
char === "|");
|
|
180
|
+
if (separator) {
|
|
181
|
+
pushAtom(buffer);
|
|
182
|
+
buffer = "";
|
|
183
|
+
if ((char === "|" && next === "|") || (char === "&" && next === "&")) {
|
|
184
|
+
i++;
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
buffer += char;
|
|
190
|
+
}
|
|
191
|
+
pushAtom(buffer);
|
|
192
|
+
}
|
|
193
|
+
walk(normalized);
|
|
194
|
+
return atoms;
|
|
195
|
+
}
|
|
196
|
+
function parseShellWords(command) {
|
|
197
|
+
const words = [];
|
|
198
|
+
let buffer = "";
|
|
199
|
+
let inSingle = false;
|
|
200
|
+
let inDouble = false;
|
|
201
|
+
let escaped = false;
|
|
202
|
+
function pushWord() {
|
|
203
|
+
if (buffer) {
|
|
204
|
+
words.push(buffer);
|
|
205
|
+
buffer = "";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (let i = 0; i < command.length; i++) {
|
|
209
|
+
const char = command[i];
|
|
210
|
+
if (escaped) {
|
|
211
|
+
buffer += char;
|
|
212
|
+
escaped = false;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (!inSingle && char === "\\") {
|
|
216
|
+
escaped = true;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!inDouble && char === "'") {
|
|
220
|
+
inSingle = !inSingle;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (!inSingle && char === '"') {
|
|
224
|
+
inDouble = !inDouble;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (!inSingle && !inDouble && WHITESPACE.test(char)) {
|
|
228
|
+
pushWord();
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
buffer += char;
|
|
232
|
+
}
|
|
233
|
+
pushWord();
|
|
234
|
+
return words;
|
|
235
|
+
}
|
|
236
|
+
function parseCommand(command) {
|
|
237
|
+
const words = parseShellWords(command);
|
|
238
|
+
if (words.length === 0) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
const normalizedCommand = basename(words[0]).toLowerCase();
|
|
242
|
+
const args = words.slice(1);
|
|
243
|
+
return {
|
|
244
|
+
command: normalizedCommand,
|
|
245
|
+
args,
|
|
246
|
+
normalized: [normalizedCommand, ...args].join(" ").trim(),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function hasAnyArg(args, values) {
|
|
250
|
+
return args.some((arg) => values.includes(arg));
|
|
251
|
+
}
|
|
252
|
+
function hasRecursiveFlag(args) {
|
|
253
|
+
return args.some((arg) => /^-[^-]*r/.test(arg) || arg === "-R" || arg === "--recursive");
|
|
254
|
+
}
|
|
255
|
+
function hasForceFlag(args) {
|
|
256
|
+
return args.some((arg) => /^-[^-]*f/.test(arg) || arg === "--force");
|
|
257
|
+
}
|
|
258
|
+
function hasDangerousDeletionTarget(args) {
|
|
259
|
+
return args.some((arg) => ["/", "/*", "~", "~/", "*", "./*", ".", "..", "../", "$HOME", "$HOME/"].includes(arg));
|
|
260
|
+
}
|
|
261
|
+
function joinedArgs(parsed) {
|
|
262
|
+
return parsed.args.join(" ");
|
|
263
|
+
}
|
|
264
|
+
function matchRule(parsed, config) {
|
|
265
|
+
const argsText = joinedArgs(parsed);
|
|
266
|
+
const normalized = parsed.normalized;
|
|
267
|
+
if (parsed.command === "rm" &&
|
|
268
|
+
hasRecursiveFlag(parsed.args) &&
|
|
269
|
+
(hasForceFlag(parsed.args) || hasDangerousDeletionTarget(parsed.args))) {
|
|
270
|
+
return {
|
|
271
|
+
category: "destructive-file-op",
|
|
272
|
+
rule: "rm-recursive-force",
|
|
273
|
+
reason: "Recursive deletion with force or dangerous targets is not allowed",
|
|
274
|
+
matchedText: normalized,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
if (parsed.command === "find" && (argsText.includes(" -delete") || /\s-exec\s+rm(?:\s|$)/.test(` ${argsText}`))) {
|
|
278
|
+
return {
|
|
279
|
+
category: "destructive-file-op",
|
|
280
|
+
rule: "find-delete",
|
|
281
|
+
reason: "Destructive find operations are not allowed",
|
|
282
|
+
matchedText: normalized,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (["shred", "mkfs", "wipefs"].includes(parsed.command)) {
|
|
286
|
+
return {
|
|
287
|
+
category: "destructive-file-op",
|
|
288
|
+
rule: parsed.command,
|
|
289
|
+
reason: "Irreversible destructive commands are not allowed",
|
|
290
|
+
matchedText: normalized,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (["shutdown", "reboot", "halt", "poweroff"].includes(parsed.command)) {
|
|
294
|
+
return {
|
|
295
|
+
category: "system-manipulation",
|
|
296
|
+
rule: parsed.command,
|
|
297
|
+
reason: "System power control commands are not allowed",
|
|
298
|
+
matchedText: normalized,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
if ((parsed.command === "systemctl" && hasAnyArg(parsed.args, ["stop", "disable", "mask", "reboot", "poweroff"])) ||
|
|
302
|
+
(parsed.command === "service" && hasAnyArg(parsed.args, ["stop", "restart"])) ||
|
|
303
|
+
(parsed.command === "launchctl" && hasAnyArg(parsed.args, ["unload", "remove", "bootout"])) ||
|
|
304
|
+
(parsed.command === "sysctl" && parsed.args.includes("-w"))) {
|
|
305
|
+
return {
|
|
306
|
+
category: "system-manipulation",
|
|
307
|
+
rule: parsed.command,
|
|
308
|
+
reason: "System service or kernel mutation commands are not allowed",
|
|
309
|
+
matchedText: normalized,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (parsed.command === "sudo" ||
|
|
313
|
+
(parsed.command === "su" && parsed.args[0] === "root") ||
|
|
314
|
+
["passwd", "visudo", "setcap"].includes(parsed.command) ||
|
|
315
|
+
(parsed.command === "chmod" && parsed.args.some((arg) => arg.includes("+s"))) ||
|
|
316
|
+
(parsed.command === "chown" && parsed.args.some((arg) => arg.startsWith("root")))) {
|
|
317
|
+
return {
|
|
318
|
+
category: "privilege-escalation",
|
|
319
|
+
rule: parsed.command,
|
|
320
|
+
reason: "Privilege escalation and account mutation commands are not allowed",
|
|
321
|
+
matchedText: normalized,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
if ((parsed.command === "kill" &&
|
|
325
|
+
(parsed.args.includes("-9") || parsed.args.includes("-KILL")) &&
|
|
326
|
+
parsed.args.includes("1")) ||
|
|
327
|
+
["killall", "pkill"].includes(parsed.command) ||
|
|
328
|
+
(parsed.command === "history" && hasAnyArg(parsed.args, ["-c", "--clear"])) ||
|
|
329
|
+
(parsed.command === "unset" && parsed.args.includes("HISTFILE")) ||
|
|
330
|
+
normalized.includes("export HISTSIZE=0")) {
|
|
331
|
+
return {
|
|
332
|
+
category: "process-manipulation",
|
|
333
|
+
rule: parsed.command,
|
|
334
|
+
reason: "Process-kill or history tampering commands are not allowed",
|
|
335
|
+
matchedText: normalized,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
if ((parsed.command === "curl" && parsed.args.includes("--upload-file")) ||
|
|
339
|
+
(parsed.command === "wget" && parsed.args.includes("--post-file")) ||
|
|
340
|
+
(parsed.command === "nc" && parsed.args.some((arg) => /^-[^-]*l/.test(arg) || arg === "--listen")) ||
|
|
341
|
+
(parsed.command === "socat" && /\bexec\b/i.test(argsText)) ||
|
|
342
|
+
normalized.includes("/dev/tcp/") ||
|
|
343
|
+
(normalized.includes("mkfifo") && normalized.includes(" nc")) ||
|
|
344
|
+
(parsed.command === "bash" && parsed.args.includes("-i") && /[>&]/.test(argsText))) {
|
|
345
|
+
return {
|
|
346
|
+
category: "network-abuse",
|
|
347
|
+
rule: parsed.command,
|
|
348
|
+
reason: "Network exfiltration or listener setup commands are not allowed",
|
|
349
|
+
matchedText: normalized,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
if (parsed.command === "nsenter" ||
|
|
353
|
+
(parsed.command === "docker" &&
|
|
354
|
+
((parsed.args[0] === "run" && parsed.args.includes("--privileged")) ||
|
|
355
|
+
(parsed.args[0] === "exec" && parsed.args.includes("--privileged")) ||
|
|
356
|
+
(parsed.args[0] === "run" && parsed.args.some((arg) => arg.includes("/:")))))) {
|
|
357
|
+
return {
|
|
358
|
+
category: "container-escape",
|
|
359
|
+
rule: parsed.command,
|
|
360
|
+
reason: "Container escape and privileged container commands are not allowed",
|
|
361
|
+
matchedText: normalized,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (config.blockObfuscation) {
|
|
365
|
+
if (/\bbase64\b.*(?:-d|--decode).*?\|\s*(bash|sh|exec|eval)\b/i.test(normalized) ||
|
|
366
|
+
/\beval\s+\$\(/i.test(normalized) ||
|
|
367
|
+
((parsed.command === "python" || parsed.command === "python3") &&
|
|
368
|
+
parsed.args.includes("-c") &&
|
|
369
|
+
/(os\.system|subprocess|exec\(|eval\()/i.test(argsText)) ||
|
|
370
|
+
(parsed.command === "perl" && parsed.args.includes("-e") && /\b(system|exec)\b/.test(argsText)) ||
|
|
371
|
+
(parsed.command === "ruby" && parsed.args.includes("-e") && /\b(system|exec)\b/.test(argsText)) ||
|
|
372
|
+
(parsed.command === "node" && parsed.args.includes("-e") && /\b(child_process|exec|spawn)\b/.test(argsText)) ||
|
|
373
|
+
/\\x[0-9a-f]{2}/i.test(normalized) ||
|
|
374
|
+
/\$'/.test(normalized)) {
|
|
375
|
+
return {
|
|
376
|
+
category: "obfuscation",
|
|
377
|
+
rule: parsed.command,
|
|
378
|
+
reason: "Obfuscated command execution is not allowed",
|
|
379
|
+
matchedText: normalized,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
for (const pattern of config.additionalDenyPatterns) {
|
|
384
|
+
try {
|
|
385
|
+
const regex = new RegExp(pattern, "i");
|
|
386
|
+
if (regex.test(normalized)) {
|
|
387
|
+
return {
|
|
388
|
+
category: "configured-command-deny",
|
|
389
|
+
rule: pattern,
|
|
390
|
+
reason: "Command matched a configured deny pattern",
|
|
391
|
+
matchedText: normalized,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
// Ignore invalid user patterns.
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
export function guardCommand(command, config) {
|
|
402
|
+
if (!config.enabled) {
|
|
403
|
+
return { allowed: true };
|
|
404
|
+
}
|
|
405
|
+
const atoms = splitCommandChain(command);
|
|
406
|
+
const normalizedWhole = stripNullAndNormalize(command);
|
|
407
|
+
for (const allowPattern of config.allowPatterns) {
|
|
408
|
+
if (normalizedWhole.includes(allowPattern)) {
|
|
409
|
+
return { allowed: true };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (config.blockObfuscation &&
|
|
413
|
+
(/\bbase64\b.*(?:-d|--decode).*?\|\s*(bash|sh|exec|eval)\b/i.test(normalizedWhole) ||
|
|
414
|
+
/\beval\s+\$\(/i.test(normalizedWhole) ||
|
|
415
|
+
/\\x[0-9a-f]{2}/i.test(normalizedWhole) ||
|
|
416
|
+
/\$'/.test(normalizedWhole))) {
|
|
417
|
+
return {
|
|
418
|
+
allowed: false,
|
|
419
|
+
category: "obfuscation",
|
|
420
|
+
rule: "obfuscation-whole-command",
|
|
421
|
+
reason: "Obfuscated command execution is not allowed",
|
|
422
|
+
matchedText: normalizedWhole,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
for (const atom of atoms) {
|
|
426
|
+
const parsed = parseCommand(atom);
|
|
427
|
+
if (!parsed) {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const match = matchRule(parsed, config);
|
|
431
|
+
if (match) {
|
|
432
|
+
return {
|
|
433
|
+
allowed: false,
|
|
434
|
+
category: match.category,
|
|
435
|
+
rule: match.rule,
|
|
436
|
+
reason: match.reason,
|
|
437
|
+
matchedText: match.matchedText,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return { allowed: true };
|
|
442
|
+
}
|
|
443
|
+
export const internalCommandGuard = {
|
|
444
|
+
parseShellWords,
|
|
445
|
+
parseCommand,
|
|
446
|
+
splitCommandChain,
|
|
447
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SecurityConfig } from "./types.js";
|
|
2
|
+
export declare const DEFAULT_SECURITY_CONFIG: SecurityConfig;
|
|
3
|
+
export declare function getSecurityConfigPath(appHomeDir?: string): string;
|
|
4
|
+
export declare function loadSecurityConfig(appHomeDir?: string): SecurityConfig;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { APP_HOME_DIR } from "../paths.js";
|
|
4
|
+
import { isRecord } from "../shared/type-guards.js";
|
|
5
|
+
export const DEFAULT_SECURITY_CONFIG = {
|
|
6
|
+
enabled: true,
|
|
7
|
+
commandGuard: {
|
|
8
|
+
enabled: true,
|
|
9
|
+
additionalDenyPatterns: [],
|
|
10
|
+
allowPatterns: [],
|
|
11
|
+
blockObfuscation: true,
|
|
12
|
+
},
|
|
13
|
+
pathGuard: {
|
|
14
|
+
enabled: true,
|
|
15
|
+
readAllow: [],
|
|
16
|
+
readDeny: [],
|
|
17
|
+
writeAllow: [],
|
|
18
|
+
writeDeny: [],
|
|
19
|
+
resolveSymlinks: true,
|
|
20
|
+
},
|
|
21
|
+
audit: {
|
|
22
|
+
logBlocked: true,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
function asStringArray(value) {
|
|
26
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
27
|
+
}
|
|
28
|
+
function asOptionalString(value) {
|
|
29
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
30
|
+
}
|
|
31
|
+
function mergeSecurityConfig(source) {
|
|
32
|
+
if (!isRecord(source)) {
|
|
33
|
+
return DEFAULT_SECURITY_CONFIG;
|
|
34
|
+
}
|
|
35
|
+
const commandGuard = isRecord(source.commandGuard) ? source.commandGuard : {};
|
|
36
|
+
const pathGuard = isRecord(source.pathGuard) ? source.pathGuard : {};
|
|
37
|
+
const audit = isRecord(source.audit) ? source.audit : {};
|
|
38
|
+
return {
|
|
39
|
+
enabled: typeof source.enabled === "boolean" ? source.enabled : DEFAULT_SECURITY_CONFIG.enabled,
|
|
40
|
+
commandGuard: {
|
|
41
|
+
enabled: typeof commandGuard.enabled === "boolean"
|
|
42
|
+
? commandGuard.enabled
|
|
43
|
+
: DEFAULT_SECURITY_CONFIG.commandGuard.enabled,
|
|
44
|
+
additionalDenyPatterns: asStringArray(commandGuard.additionalDenyPatterns),
|
|
45
|
+
allowPatterns: asStringArray(commandGuard.allowPatterns),
|
|
46
|
+
blockObfuscation: typeof commandGuard.blockObfuscation === "boolean"
|
|
47
|
+
? commandGuard.blockObfuscation
|
|
48
|
+
: DEFAULT_SECURITY_CONFIG.commandGuard.blockObfuscation,
|
|
49
|
+
},
|
|
50
|
+
pathGuard: {
|
|
51
|
+
enabled: typeof pathGuard.enabled === "boolean" ? pathGuard.enabled : DEFAULT_SECURITY_CONFIG.pathGuard.enabled,
|
|
52
|
+
readAllow: asStringArray(pathGuard.readAllow),
|
|
53
|
+
readDeny: asStringArray(pathGuard.readDeny),
|
|
54
|
+
writeAllow: asStringArray(pathGuard.writeAllow),
|
|
55
|
+
writeDeny: asStringArray(pathGuard.writeDeny),
|
|
56
|
+
resolveSymlinks: typeof pathGuard.resolveSymlinks === "boolean"
|
|
57
|
+
? pathGuard.resolveSymlinks
|
|
58
|
+
: DEFAULT_SECURITY_CONFIG.pathGuard.resolveSymlinks,
|
|
59
|
+
},
|
|
60
|
+
audit: {
|
|
61
|
+
logBlocked: typeof audit.logBlocked === "boolean" ? audit.logBlocked : DEFAULT_SECURITY_CONFIG.audit.logBlocked,
|
|
62
|
+
logFile: asOptionalString(audit.logFile),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function getSecurityConfigPath(appHomeDir = APP_HOME_DIR) {
|
|
67
|
+
return join(appHomeDir, "security.json");
|
|
68
|
+
}
|
|
69
|
+
export function loadSecurityConfig(appHomeDir = APP_HOME_DIR) {
|
|
70
|
+
const configPath = getSecurityConfigPath(appHomeDir);
|
|
71
|
+
if (!existsSync(configPath)) {
|
|
72
|
+
return DEFAULT_SECURITY_CONFIG;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
76
|
+
return mergeSecurityConfig(raw);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
console.warn(`Failed to load security config from ${configPath}: ${error}`);
|
|
80
|
+
return DEFAULT_SECURITY_CONFIG;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
function getLogPath(workspaceDir, config) {
|
|
4
|
+
return config.audit.logFile?.trim() ? config.audit.logFile : join(workspaceDir, ".pipiclaw", "security.log");
|
|
5
|
+
}
|
|
6
|
+
export function logSecurityEvent(workspaceDir, config, event) {
|
|
7
|
+
if (!config.audit.logBlocked) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const logPath = getLogPath(workspaceDir, config);
|
|
11
|
+
try {
|
|
12
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
13
|
+
appendFileSync(logPath, `${JSON.stringify({ date: new Date().toISOString(), ...event })}\n`, "utf-8");
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Audit logging must never break the tool path.
|
|
17
|
+
}
|
|
18
|
+
}
|