@oyasmi/pipiclaw 0.5.4 → 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.
Files changed (47) hide show
  1. package/README.md +38 -2
  2. package/dist/agent/channel-runner.d.ts +1 -0
  3. package/dist/agent/channel-runner.js +3 -0
  4. package/dist/agent/prompt-builder.js +13 -11
  5. package/dist/agent/runner-factory.d.ts +2 -0
  6. package/dist/agent/runner-factory.js +6 -0
  7. package/dist/agent/types.d.ts +1 -0
  8. package/dist/agent/workspace-resources.d.ts +2 -3
  9. package/dist/agent/workspace-resources.js +5 -15
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.js +1 -0
  12. package/dist/memory/lifecycle.d.ts +2 -1
  13. package/dist/memory/lifecycle.js +19 -1
  14. package/dist/paths.js +1 -1
  15. package/dist/runtime/bootstrap.d.ts +22 -1
  16. package/dist/runtime/bootstrap.js +94 -26
  17. package/dist/runtime/channel-paths.d.ts +3 -0
  18. package/dist/runtime/channel-paths.js +13 -0
  19. package/dist/runtime/dingtalk.js +2 -1
  20. package/dist/runtime/store.js +3 -6
  21. package/dist/security/command-guard.d.ts +16 -0
  22. package/dist/security/command-guard.js +447 -0
  23. package/dist/security/config.d.ts +4 -0
  24. package/dist/security/config.js +82 -0
  25. package/dist/security/logger.d.ts +2 -0
  26. package/dist/security/logger.js +18 -0
  27. package/dist/security/path-guard.d.ts +2 -0
  28. package/dist/security/path-guard.js +237 -0
  29. package/dist/security/types.d.ts +66 -0
  30. package/dist/security/types.js +1 -0
  31. package/dist/subagents/tool.d.ts +2 -0
  32. package/dist/subagents/tool.js +31 -7
  33. package/dist/tools/attach.d.ts +7 -1
  34. package/dist/tools/attach.js +36 -1
  35. package/dist/tools/bash.d.ts +4 -0
  36. package/dist/tools/bash.js +38 -0
  37. package/dist/tools/edit.d.ts +7 -1
  38. package/dist/tools/edit.js +42 -2
  39. package/dist/tools/index.d.ts +7 -1
  40. package/dist/tools/index.js +29 -3
  41. package/dist/tools/read.d.ts +7 -1
  42. package/dist/tools/read.js +36 -1
  43. package/dist/tools/write-content.d.ts +5 -0
  44. package/dist/tools/write-content.js +32 -11
  45. package/dist/tools/write.d.ts +7 -1
  46. package/dist/tools/write.js +10 -3
  47. package/package.json +2 -1
@@ -0,0 +1,237 @@
1
+ import { existsSync, lstatSync, realpathSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { basename, dirname, isAbsolute, normalize, resolve } from "node:path";
4
+ const PRIVATE_KEY_EXTENSIONS = new Set([".pem", ".key", ".p12", ".pfx"]);
5
+ const PRIVATE_KEY_NAME_HINTS = /(id_rsa|id_ed25519|private|secret|credentials)/i;
6
+ const PROC_MEM_PATH = /^\/proc\/\d+\/mem(?:\/|$)/;
7
+ const HOME_SENSITIVE_PREFIXES = [
8
+ "~/.ssh/",
9
+ "~/.gnupg/",
10
+ "~/.gpg/",
11
+ "~/.aws/",
12
+ "~/.azure/",
13
+ "~/.gcloud/",
14
+ "~/.config/gcloud/",
15
+ "~/.kube/",
16
+ "~/.docker/",
17
+ "~/Library/Keychains/",
18
+ "~/.local/share/keyrings/",
19
+ "~/Library/Application Support/Google/Chrome/",
20
+ "~/Library/Application Support/Firefox/",
21
+ "~/.config/google-chrome/",
22
+ "~/.mozilla/firefox/",
23
+ ];
24
+ const HOME_SENSITIVE_FILES = ["~/.netrc", "~/.npmrc", "~/.pypirc", "~/.bash_history", "~/.zsh_history"];
25
+ const WRITE_DENY_HOME_FILES = ["~/.bashrc", "~/.zshrc", "~/.profile", "~/.bash_profile", "~/.config/fish/config.fish"];
26
+ const SYSTEM_SENSITIVE_PREFIXES = ["/etc/sudoers.d/", "/var/run/secrets/"];
27
+ const SYSTEM_SENSITIVE_FILES = ["/etc/shadow", "/etc/gshadow", "/etc/sudoers", "/proc/kcore"];
28
+ const TEMP_PREFIXES = ["/tmp/", "/var/tmp/", "/private/tmp/"];
29
+ const SYSTEM_DENY_PREFIXES = [
30
+ "/etc/",
31
+ "/usr/",
32
+ "/bin/",
33
+ "/sbin/",
34
+ "/lib/",
35
+ "/lib64/",
36
+ "/boot/",
37
+ "/dev/",
38
+ "/proc/",
39
+ "/sys/",
40
+ "/opt/",
41
+ "/System/",
42
+ "/Library/",
43
+ "/var/",
44
+ ];
45
+ function stripNullAndNormalize(text) {
46
+ return text.replace(/\0/g, "").normalize("NFKC");
47
+ }
48
+ function withTrailingSlash(path) {
49
+ return path.endsWith("/") ? path : `${path}/`;
50
+ }
51
+ function startsWithPathPrefix(path, prefix) {
52
+ return path === prefix || path.startsWith(withTrailingSlash(prefix));
53
+ }
54
+ function maybeExpandHome(path, homeDir) {
55
+ if (path === "~") {
56
+ return homeDir;
57
+ }
58
+ if (path.startsWith("~/")) {
59
+ return resolve(homeDir, path.slice(2));
60
+ }
61
+ return path;
62
+ }
63
+ function resolveHomeConfiguredPath(rawPath, homeDir) {
64
+ return normalize(maybeExpandHome(stripNullAndNormalize(rawPath), homeDir));
65
+ }
66
+ function translateRuntimeWorkspacePath(path, ctx) {
67
+ if (!isAbsolute(path)) {
68
+ return path;
69
+ }
70
+ if (!ctx.workspacePath || ctx.workspacePath === ctx.workspaceDir) {
71
+ return path;
72
+ }
73
+ if (path === ctx.workspacePath) {
74
+ return ctx.workspaceDir;
75
+ }
76
+ if (path.startsWith(withTrailingSlash(ctx.workspacePath))) {
77
+ return resolve(ctx.workspaceDir, path.slice(ctx.workspacePath.length + 1));
78
+ }
79
+ return path;
80
+ }
81
+ function resolveConfiguredPath(rawPath, ctx) {
82
+ const homeDir = ctx.homeDir ?? homedir();
83
+ const normalized = stripNullAndNormalize(rawPath);
84
+ const expanded = maybeExpandHome(normalized, homeDir);
85
+ const translated = translateRuntimeWorkspacePath(expanded, ctx);
86
+ if (isAbsolute(translated)) {
87
+ return normalize(translated);
88
+ }
89
+ return resolve(ctx.workspaceDir, translated);
90
+ }
91
+ function resolveTargetPath(rawPath, ctx) {
92
+ const homeDir = ctx.homeDir ?? homedir();
93
+ const cwd = ctx.cwd ?? process.cwd();
94
+ const normalized = stripNullAndNormalize(rawPath);
95
+ const expanded = maybeExpandHome(normalized, homeDir);
96
+ const translated = translateRuntimeWorkspacePath(expanded, ctx);
97
+ if (isAbsolute(translated)) {
98
+ return normalize(translated);
99
+ }
100
+ return resolve(cwd, translated);
101
+ }
102
+ function resolveExistingAncestor(path) {
103
+ let current = normalize(path);
104
+ while (true) {
105
+ if (existsSync(current)) {
106
+ return realpathSync(current);
107
+ }
108
+ const parent = dirname(current);
109
+ if (parent === current) {
110
+ return current;
111
+ }
112
+ current = parent;
113
+ }
114
+ }
115
+ function resolveForGuard(path, ctx) {
116
+ const normalized = normalize(path);
117
+ const resolveSymlinks = ctx.config.resolveSymlinks !== false;
118
+ if (!resolveSymlinks) {
119
+ return normalized;
120
+ }
121
+ if (existsSync(normalized)) {
122
+ return realpathSync(normalized);
123
+ }
124
+ const parentDir = dirname(normalized);
125
+ const parentRealPath = resolveExistingAncestor(parentDir);
126
+ return resolve(parentRealPath, basename(normalized));
127
+ }
128
+ function matchesAnyPath(path, exactPaths, prefixes) {
129
+ return exactPaths.includes(path) || prefixes.some((prefix) => startsWithPathPrefix(path, prefix));
130
+ }
131
+ function matchesSensitiveReadPath(path, homeDir) {
132
+ const sensitiveHomePrefixes = HOME_SENSITIVE_PREFIXES.map((item) => resolveHomeConfiguredPath(item, homeDir));
133
+ const sensitiveHomeFiles = HOME_SENSITIVE_FILES.map((item) => resolveHomeConfiguredPath(item, homeDir));
134
+ const sensitiveSystemPrefixes = SYSTEM_SENSITIVE_PREFIXES.map((item) => normalize(item));
135
+ const sensitiveSystemFiles = SYSTEM_SENSITIVE_FILES.map((item) => normalize(item));
136
+ if (matchesAnyPath(path, sensitiveHomeFiles, sensitiveHomePrefixes)) {
137
+ return true;
138
+ }
139
+ if (matchesAnyPath(path, sensitiveSystemFiles, sensitiveSystemPrefixes)) {
140
+ return true;
141
+ }
142
+ if (PROC_MEM_PATH.test(path)) {
143
+ return true;
144
+ }
145
+ const lowerBase = basename(path).toLowerCase();
146
+ const extension = lowerBase.includes(".") ? lowerBase.slice(lowerBase.lastIndexOf(".")) : "";
147
+ if (PRIVATE_KEY_EXTENSIONS.has(extension) && PRIVATE_KEY_NAME_HINTS.test(lowerBase)) {
148
+ return true;
149
+ }
150
+ return PRIVATE_KEY_NAME_HINTS.test(lowerBase) && lowerBase.startsWith("id_");
151
+ }
152
+ function matchesSensitiveWritePath(path, homeDir) {
153
+ if (matchesSensitiveReadPath(path, homeDir)) {
154
+ return true;
155
+ }
156
+ const writeDenyHomeFiles = WRITE_DENY_HOME_FILES.map((item) => resolveHomeConfiguredPath(item, homeDir));
157
+ return writeDenyHomeFiles.includes(path);
158
+ }
159
+ function isWithinTemp(path) {
160
+ const configuredPrefixes = TEMP_PREFIXES.map((prefix) => normalize(prefix));
161
+ const runtimeTmpDir = normalize(tmpdir());
162
+ const runtimePrefixes = existsSync(runtimeTmpDir)
163
+ ? [runtimeTmpDir, resolveExistingAncestor(runtimeTmpDir)]
164
+ : [runtimeTmpDir];
165
+ return [...configuredPrefixes, ...runtimePrefixes].some((prefix) => startsWithPathPrefix(path, prefix));
166
+ }
167
+ function isWithinHome(path, homeDir) {
168
+ return startsWithPathPrefix(path, normalize(homeDir));
169
+ }
170
+ function isWithinWorkspace(path, workspaceDir) {
171
+ return startsWithPathPrefix(path, normalize(workspaceDir));
172
+ }
173
+ function isDeniedSystemPath(path) {
174
+ if (isWithinTemp(path)) {
175
+ return false;
176
+ }
177
+ return SYSTEM_DENY_PREFIXES.some((prefix) => startsWithPathPrefix(path, normalize(prefix)));
178
+ }
179
+ function matchesConfiguredPath(path, entries, ctx) {
180
+ return entries.map((entry) => resolveConfiguredPath(entry, ctx)).some((entry) => startsWithPathPrefix(path, entry));
181
+ }
182
+ function pathAllowedByDefaults(path, ctx) {
183
+ const homeDir = ctx.homeDir ?? homedir();
184
+ return isWithinWorkspace(path, ctx.workspaceDir) || isWithinTemp(path) || isWithinHome(path, homeDir);
185
+ }
186
+ function formatBlockedResult(operation, rawPath, resolvedPath, category, reason) {
187
+ return {
188
+ allowed: false,
189
+ operation,
190
+ rawPath,
191
+ resolvedPath,
192
+ category,
193
+ reason,
194
+ };
195
+ }
196
+ export function guardPath(rawPath, operation, ctx) {
197
+ if (!ctx.config.enabled) {
198
+ return { allowed: true, operation, rawPath };
199
+ }
200
+ const homeDir = ctx.homeDir ?? homedir();
201
+ const effectiveCtx = {
202
+ ...ctx,
203
+ workspaceDir: resolveForGuard(ctx.workspaceDir, ctx),
204
+ homeDir: resolveForGuard(homeDir, ctx),
205
+ };
206
+ const resolvedTarget = resolveTargetPath(rawPath, ctx);
207
+ const guardedPath = resolveForGuard(resolvedTarget, ctx);
208
+ if (matchesConfiguredPath(guardedPath, operation === "read" ? effectiveCtx.config.readDeny : effectiveCtx.config.writeDeny, effectiveCtx)) {
209
+ return formatBlockedResult(operation, rawPath, guardedPath, "configured-deny", "Path is denied by security config");
210
+ }
211
+ if (operation === "read" && matchesSensitiveReadPath(guardedPath, effectiveCtx.homeDir ?? homeDir)) {
212
+ return formatBlockedResult(operation, rawPath, guardedPath, "sensitive-read-path", "Reading sensitive paths is not allowed");
213
+ }
214
+ if (operation === "write" && matchesSensitiveWritePath(guardedPath, effectiveCtx.homeDir ?? homeDir)) {
215
+ return formatBlockedResult(operation, rawPath, guardedPath, "sensitive-write-path", "Writing sensitive paths is not allowed");
216
+ }
217
+ if (operation === "write" && existsSync(resolvedTarget)) {
218
+ try {
219
+ if (lstatSync(resolvedTarget).isSymbolicLink()) {
220
+ return formatBlockedResult(operation, rawPath, guardedPath, "symlink-write", "Writing through symbolic links is not allowed");
221
+ }
222
+ }
223
+ catch {
224
+ // Ignore lstat races and continue with resolved-path checks.
225
+ }
226
+ }
227
+ if (matchesConfiguredPath(guardedPath, operation === "read" ? effectiveCtx.config.readAllow : effectiveCtx.config.writeAllow, effectiveCtx)) {
228
+ return { allowed: true, operation, rawPath, resolvedPath: guardedPath };
229
+ }
230
+ if (pathAllowedByDefaults(guardedPath, effectiveCtx)) {
231
+ return { allowed: true, operation, rawPath, resolvedPath: guardedPath };
232
+ }
233
+ if (isDeniedSystemPath(guardedPath)) {
234
+ return formatBlockedResult(operation, rawPath, guardedPath, "system-path", `${operation === "read" ? "Reading" : "Writing"} system paths is not allowed by default`);
235
+ }
236
+ return formatBlockedResult(operation, rawPath, guardedPath, "outside-allowed-roots", `${operation === "read" ? "Reading" : "Writing"} outside workspace, home, and temp paths is not allowed`);
237
+ }
@@ -0,0 +1,66 @@
1
+ export interface SecurityConfig {
2
+ enabled: boolean;
3
+ commandGuard: {
4
+ enabled: boolean;
5
+ additionalDenyPatterns: string[];
6
+ allowPatterns: string[];
7
+ blockObfuscation: boolean;
8
+ };
9
+ pathGuard: {
10
+ enabled: boolean;
11
+ readAllow: string[];
12
+ readDeny: string[];
13
+ writeAllow: string[];
14
+ writeDeny: string[];
15
+ resolveSymlinks: boolean;
16
+ };
17
+ audit: {
18
+ logBlocked: boolean;
19
+ logFile?: string;
20
+ };
21
+ }
22
+ export interface SecurityRuntimeContext {
23
+ workspaceDir: string;
24
+ workspacePath: string;
25
+ cwd?: string;
26
+ homeDir?: string;
27
+ }
28
+ export interface PathGuardContext extends SecurityRuntimeContext {
29
+ config: SecurityConfig["pathGuard"];
30
+ }
31
+ export interface PathGuardResult {
32
+ allowed: boolean;
33
+ operation: "read" | "write";
34
+ category?: string;
35
+ reason?: string;
36
+ rawPath: string;
37
+ resolvedPath?: string;
38
+ }
39
+ export interface CommandGuardResult {
40
+ allowed: boolean;
41
+ category?: string;
42
+ rule?: string;
43
+ reason?: string;
44
+ matchedText?: string;
45
+ }
46
+ export interface SecurityLogEventBase {
47
+ tool: string;
48
+ channelId?: string;
49
+ }
50
+ export interface BlockedPathLogEvent extends SecurityLogEventBase {
51
+ type: "path";
52
+ rawPath: string;
53
+ operation: "read" | "write";
54
+ resolvedPath?: string;
55
+ category?: string;
56
+ reason?: string;
57
+ }
58
+ export interface BlockedCommandLogEvent extends SecurityLogEventBase {
59
+ type: "command";
60
+ command: string;
61
+ category?: string;
62
+ rule?: string;
63
+ reason?: string;
64
+ matchedText?: string;
65
+ }
66
+ export type SecurityLogEvent = BlockedPathLogEvent | BlockedCommandLogEvent;
@@ -0,0 +1 @@
1
+ export {};
@@ -1,6 +1,7 @@
1
1
  import { type AgentEvent, type AgentMessage, type AgentTool } from "@mariozechner/pi-agent-core";
2
2
  import type { Api, Model } from "@mariozechner/pi-ai";
3
3
  import type { Executor } from "../sandbox.js";
4
+ import type { SecurityConfig } from "../security/types.js";
4
5
  import type { PipiclawMemoryRecallSettings } from "../settings.js";
5
6
  import type { UsageTotals } from "../shared/types.js";
6
7
  import { type ResolvedSubAgentConfig, type SubAgentDiscoveryResult } from "./discovery.js";
@@ -42,6 +43,7 @@ export interface SubAgentToolOptions {
42
43
  channelDir: string;
43
44
  getSubAgentDiscovery?: () => SubAgentDiscoveryResult;
44
45
  getMemoryRecallSettings?: () => PipiclawMemoryRecallSettings;
46
+ securityConfig?: SecurityConfig;
45
47
  runtimeContext: {
46
48
  workspacePath: string;
47
49
  channelId: string;
@@ -5,6 +5,7 @@ import { createMemoryCandidateCache } from "../memory/candidates.js";
5
5
  import { readChannelSession } from "../memory/files.js";
6
6
  import { recallRelevantMemory } from "../memory/recall.js";
7
7
  import { formatModelReference } from "../models/utils.js";
8
+ import { DEFAULT_SECURITY_CONFIG } from "../security/config.js";
8
9
  import { splitH1Sections } from "../shared/markdown-sections.js";
9
10
  import { clipText, extractAssistantText, extractLabelFromArgs, HAN_REGEX } from "../shared/text-utils.js";
10
11
  import { createBashTool } from "../tools/bash.js";
@@ -86,12 +87,35 @@ function buildStoppedText(config, reason, finalText) {
86
87
  }
87
88
  return `[Sub-agent ${config.name} stopped: ${reason}]\n\n${trimmedFinalText}`;
88
89
  }
89
- function createToolSet(executor, bashTimeoutSec) {
90
+ function createToolSet(executor, bashTimeoutSec, options) {
91
+ const securityConfig = options.securityConfig ?? DEFAULT_SECURITY_CONFIG;
92
+ const securityContext = {
93
+ workspaceDir: options.workspaceDir,
94
+ workspacePath: options.runtimeContext.workspacePath,
95
+ cwd: process.cwd(),
96
+ };
90
97
  return [
91
- createReadTool(executor),
92
- createBashTool(executor, { defaultTimeoutSeconds: bashTimeoutSec }),
93
- createEditTool(executor),
94
- createWriteTool(executor),
98
+ createReadTool(executor, {
99
+ securityConfig,
100
+ securityContext,
101
+ channelId: options.runtimeContext.channelId,
102
+ }),
103
+ createBashTool(executor, {
104
+ defaultTimeoutSeconds: bashTimeoutSec,
105
+ securityConfig,
106
+ securityContext,
107
+ channelId: options.runtimeContext.channelId,
108
+ }),
109
+ createEditTool(executor, {
110
+ securityConfig,
111
+ securityContext,
112
+ channelId: options.runtimeContext.channelId,
113
+ }),
114
+ createWriteTool(executor, {
115
+ securityConfig,
116
+ securityContext,
117
+ channelId: options.runtimeContext.channelId,
118
+ }),
95
119
  ];
96
120
  }
97
121
  function buildSubAgentTask(task, config, runtimeContext, contextBlocks) {
@@ -267,14 +291,14 @@ export function createSubAgentTool(options) {
267
291
  const worker = options.createWorker?.({
268
292
  subAgent: config,
269
293
  apiKey,
270
- tools: filterToolsByName(createToolSet(options.executor, config.bashTimeoutSec), config.tools),
294
+ tools: filterToolsByName(createToolSet(options.executor, config.bashTimeoutSec, options), config.tools),
271
295
  }) ??
272
296
  new Agent({
273
297
  initialState: {
274
298
  systemPrompt: config.systemPrompt,
275
299
  model: config.model,
276
300
  thinkingLevel: "off",
277
- tools: filterToolsByName(createToolSet(options.executor, config.bashTimeoutSec), config.tools),
301
+ tools: filterToolsByName(createToolSet(options.executor, config.bashTimeoutSec, options), config.tools),
278
302
  },
279
303
  convertToLlm,
280
304
  getApiKey: async () => apiKey,
@@ -1,13 +1,19 @@
1
1
  import type { AgentTool } from "@mariozechner/pi-agent-core";
2
+ import type { SecurityConfig, SecurityRuntimeContext } from "../security/types.js";
2
3
  declare const attachSchema: import("@sinclair/typebox").TObject<{
3
4
  label: import("@sinclair/typebox").TString;
4
5
  path: import("@sinclair/typebox").TString;
5
6
  title: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
6
7
  }>;
7
8
  export type UploadFunction = (filePath: string, title?: string) => Promise<void>;
9
+ export interface AttachToolOptions {
10
+ securityConfig?: SecurityConfig;
11
+ securityContext?: SecurityRuntimeContext;
12
+ channelId?: string;
13
+ }
8
14
  /**
9
15
  * Create the attach tool. If no uploadFn is provided, the tool will throw
10
16
  * an informative error guiding the LLM to use alternative approaches.
11
17
  */
12
- export declare function createAttachTool(uploadFn?: UploadFunction): AgentTool<typeof attachSchema>;
18
+ export declare function createAttachTool(uploadFn?: UploadFunction, options?: AttachToolOptions): AgentTool<typeof attachSchema>;
13
19
  export {};
@@ -1,5 +1,8 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { basename, resolve as resolvePath } from "path";
3
+ import { DEFAULT_SECURITY_CONFIG } from "../security/config.js";
4
+ import { logSecurityEvent } from "../security/logger.js";
5
+ import { guardPath } from "../security/path-guard.js";
3
6
  const attachSchema = Type.Object({
4
7
  label: Type.String({ description: "Brief description of what you're sharing (shown to user)" }),
5
8
  path: Type.String({ description: "Path to the file to attach" }),
@@ -9,7 +12,13 @@ const attachSchema = Type.Object({
9
12
  * Create the attach tool. If no uploadFn is provided, the tool will throw
10
13
  * an informative error guiding the LLM to use alternative approaches.
11
14
  */
12
- export function createAttachTool(uploadFn) {
15
+ export function createAttachTool(uploadFn, options = {}) {
16
+ const securityConfig = options.securityConfig ?? DEFAULT_SECURITY_CONFIG;
17
+ const securityContext = options.securityContext ?? {
18
+ workspaceDir: process.cwd(),
19
+ workspacePath: process.cwd(),
20
+ cwd: process.cwd(),
21
+ };
13
22
  return {
14
23
  name: "attach",
15
24
  label: "attach",
@@ -23,6 +32,32 @@ export function createAttachTool(uploadFn) {
23
32
  throw new Error("Operation aborted");
24
33
  }
25
34
  const absolutePath = resolvePath(path);
35
+ if (securityConfig.enabled && securityConfig.pathGuard.enabled) {
36
+ const readGuard = guardPath(path, "read", { ...securityContext, config: securityConfig.pathGuard });
37
+ if (!readGuard.allowed) {
38
+ logSecurityEvent(securityContext.workspaceDir, securityConfig, {
39
+ type: "path",
40
+ tool: "attach",
41
+ channelId: options.channelId,
42
+ rawPath: path,
43
+ operation: "read",
44
+ resolvedPath: readGuard.resolvedPath,
45
+ category: readGuard.category,
46
+ reason: readGuard.reason,
47
+ });
48
+ throw new Error([
49
+ `Path blocked${readGuard.category ? ` [${readGuard.category}]` : ""}`,
50
+ readGuard.reason ? `Reason: ${readGuard.reason}` : "",
51
+ readGuard.resolvedPath ? `Resolved path: ${readGuard.resolvedPath}` : "",
52
+ ]
53
+ .filter(Boolean)
54
+ .join("\n"));
55
+ }
56
+ const workspaceRoot = resolvePath(securityContext.workspaceDir);
57
+ if (absolutePath !== workspaceRoot && !absolutePath.startsWith(`${workspaceRoot}/`)) {
58
+ throw new Error("Attach is limited to files inside the workspace directory.");
59
+ }
60
+ }
26
61
  const fileName = title || basename(absolutePath);
27
62
  await uploadFn(absolutePath, fileName);
28
63
  return {
@@ -1,5 +1,6 @@
1
1
  import type { AgentTool } from "@mariozechner/pi-agent-core";
2
2
  import type { Executor } from "../sandbox.js";
3
+ import type { SecurityConfig, SecurityRuntimeContext } from "../security/types.js";
3
4
  declare const bashSchema: import("@sinclair/typebox").TObject<{
4
5
  label: import("@sinclair/typebox").TString;
5
6
  command: import("@sinclair/typebox").TString;
@@ -7,6 +8,9 @@ declare const bashSchema: import("@sinclair/typebox").TObject<{
7
8
  }>;
8
9
  export interface BashToolOptions {
9
10
  defaultTimeoutSeconds?: number;
11
+ securityConfig?: SecurityConfig;
12
+ securityContext?: SecurityRuntimeContext;
13
+ channelId?: string;
10
14
  }
11
15
  export declare function createBashTool(executor: Executor, options?: BashToolOptions): AgentTool<typeof bashSchema>;
12
16
  export {};
@@ -3,6 +3,9 @@ import { createWriteStream } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { Type } from "@sinclair/typebox";
6
+ import { guardCommand } from "../security/command-guard.js";
7
+ import { DEFAULT_SECURITY_CONFIG } from "../security/config.js";
8
+ import { logSecurityEvent } from "../security/logger.js";
6
9
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateTail } from "./truncate.js";
7
10
  /**
8
11
  * Generate a unique temp file path for bash output
@@ -16,13 +19,48 @@ const bashSchema = Type.Object({
16
19
  command: Type.String({ description: "Bash command to execute" }),
17
20
  timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
18
21
  });
22
+ function formatCommandBlockMessage(command, category, reason, matchedText) {
23
+ const lines = [`Command blocked${category ? ` [${category}]` : ""}`];
24
+ if (reason) {
25
+ lines.push(`Reason: ${reason}`);
26
+ }
27
+ if (matchedText) {
28
+ lines.push(`Matched: ${matchedText}`);
29
+ }
30
+ else {
31
+ lines.push(`Command: ${command}`);
32
+ }
33
+ return lines.join("\n");
34
+ }
19
35
  export function createBashTool(executor, options = {}) {
36
+ const securityConfig = options.securityConfig ?? DEFAULT_SECURITY_CONFIG;
37
+ const securityContext = options.securityContext ?? {
38
+ workspaceDir: process.cwd(),
39
+ workspacePath: process.cwd(),
40
+ cwd: process.cwd(),
41
+ };
20
42
  return {
21
43
  name: "bash",
22
44
  label: "bash",
23
45
  description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
24
46
  parameters: bashSchema,
25
47
  execute: async (_toolCallId, { command, timeout }, signal) => {
48
+ if (securityConfig.enabled && securityConfig.commandGuard.enabled) {
49
+ const guardResult = guardCommand(command, securityConfig.commandGuard);
50
+ if (!guardResult.allowed) {
51
+ logSecurityEvent(securityContext.workspaceDir, securityConfig, {
52
+ type: "command",
53
+ tool: "bash",
54
+ channelId: options.channelId,
55
+ command,
56
+ category: guardResult.category,
57
+ rule: guardResult.rule,
58
+ reason: guardResult.reason,
59
+ matchedText: guardResult.matchedText,
60
+ });
61
+ throw new Error(formatCommandBlockMessage(command, guardResult.category, guardResult.reason, guardResult.matchedText));
62
+ }
63
+ }
26
64
  // Track output for potential temp file writing
27
65
  let tempFilePath;
28
66
  let tempFileStream;
@@ -1,10 +1,16 @@
1
1
  import type { AgentTool } from "@mariozechner/pi-agent-core";
2
2
  import type { Executor } from "../sandbox.js";
3
+ import type { SecurityConfig, SecurityRuntimeContext } from "../security/types.js";
3
4
  declare const editSchema: import("@sinclair/typebox").TObject<{
4
5
  label: import("@sinclair/typebox").TString;
5
6
  path: import("@sinclair/typebox").TString;
6
7
  oldText: import("@sinclair/typebox").TString;
7
8
  newText: import("@sinclair/typebox").TString;
8
9
  }>;
9
- export declare function createEditTool(executor: Executor): AgentTool<typeof editSchema>;
10
+ export interface EditToolOptions {
11
+ securityConfig?: SecurityConfig;
12
+ securityContext?: SecurityRuntimeContext;
13
+ channelId?: string;
14
+ }
15
+ export declare function createEditTool(executor: Executor, options?: EditToolOptions): AgentTool<typeof editSchema>;
10
16
  export {};
@@ -1,5 +1,8 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import * as Diff from "diff";
3
+ import { DEFAULT_SECURITY_CONFIG } from "../security/config.js";
4
+ import { logSecurityEvent } from "../security/logger.js";
5
+ import { guardPath } from "../security/path-guard.js";
3
6
  import { shellEscape } from "../shared/shell-escape.js";
4
7
  import { writeContent } from "./write-content.js";
5
8
  /**
@@ -80,13 +83,45 @@ const editSchema = Type.Object({
80
83
  oldText: Type.String({ description: "Exact text to find and replace (must match exactly)" }),
81
84
  newText: Type.String({ description: "New text to replace the old text with" }),
82
85
  });
83
- export function createEditTool(executor) {
86
+ function formatPathBlockMessage(resolvedPath, category, reason) {
87
+ const lines = [`Path blocked${category ? ` [${category}]` : ""}`];
88
+ if (reason) {
89
+ lines.push(`Reason: ${reason}`);
90
+ }
91
+ if (resolvedPath) {
92
+ lines.push(`Resolved path: ${resolvedPath}`);
93
+ }
94
+ return lines.join("\n");
95
+ }
96
+ export function createEditTool(executor, options = {}) {
97
+ const securityConfig = options.securityConfig ?? DEFAULT_SECURITY_CONFIG;
98
+ const securityContext = options.securityContext ?? {
99
+ workspaceDir: process.cwd(),
100
+ workspacePath: process.cwd(),
101
+ cwd: process.cwd(),
102
+ };
84
103
  return {
85
104
  name: "edit",
86
105
  label: "edit",
87
106
  description: "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
88
107
  parameters: editSchema,
89
108
  execute: async (_toolCallId, { path, oldText, newText }, signal) => {
109
+ if (securityConfig.enabled && securityConfig.pathGuard.enabled) {
110
+ const readGuard = guardPath(path, "read", { ...securityContext, config: securityConfig.pathGuard });
111
+ if (!readGuard.allowed) {
112
+ logSecurityEvent(securityContext.workspaceDir, securityConfig, {
113
+ type: "path",
114
+ tool: "edit",
115
+ channelId: options.channelId,
116
+ rawPath: path,
117
+ operation: "read",
118
+ resolvedPath: readGuard.resolvedPath,
119
+ category: readGuard.category,
120
+ reason: readGuard.reason,
121
+ });
122
+ throw new Error(formatPathBlockMessage(readGuard.resolvedPath, readGuard.category, readGuard.reason));
123
+ }
124
+ }
90
125
  // Read the file
91
126
  const readResult = await executor.exec(`cat ${shellEscape(path)}`, { signal });
92
127
  if (readResult.code !== 0) {
@@ -109,7 +144,12 @@ export function createEditTool(executor) {
109
144
  throw new Error(`No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`);
110
145
  }
111
146
  // Write the file back
112
- await writeContent(executor, path, newContent, signal);
147
+ await writeContent(executor, path, newContent, signal, {
148
+ securityConfig,
149
+ securityContext,
150
+ channelId: options.channelId,
151
+ toolName: "edit",
152
+ });
113
153
  return {
114
154
  content: [
115
155
  {
@@ -1,6 +1,7 @@
1
1
  import type { AgentTool } from "@mariozechner/pi-agent-core";
2
2
  import type { Api, Model } from "@mariozechner/pi-ai";
3
3
  import type { Executor, SandboxConfig } from "../sandbox.js";
4
+ import type { SecurityConfig, SecurityRuntimeContext } from "../security/types.js";
4
5
  import type { PipiclawMemoryRecallSettings } from "../settings.js";
5
6
  import type { SubAgentDiscoveryResult } from "../subagents/discovery.js";
6
7
  export interface CreatePipiclawToolsOptions {
@@ -16,5 +17,10 @@ export interface CreatePipiclawToolsOptions {
16
17
  getSubAgentDiscovery: () => SubAgentDiscoveryResult;
17
18
  getMemoryRecallSettings: () => PipiclawMemoryRecallSettings;
18
19
  }
19
- export declare function createPipiclawBaseTools(executor: Executor): AgentTool<any>[];
20
+ export interface CreatePipiclawBaseToolsOptions {
21
+ securityConfig?: SecurityConfig;
22
+ securityContext?: SecurityRuntimeContext;
23
+ channelId?: string;
24
+ }
25
+ export declare function createPipiclawBaseTools(executor: Executor, options?: CreatePipiclawBaseToolsOptions): AgentTool<any>[];
20
26
  export declare function createPipiclawTools(options: CreatePipiclawToolsOptions): AgentTool<any>[];