@piagent/platform 1.2.8 → 1.2.10

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 (44) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +4 -4
  3. package/catalog/capabilities.json +2 -2
  4. package/docs/command-reference-vietnamese.md +7 -4
  5. package/docs/context-engine.md +30 -1
  6. package/docs/distribution-standard.md +7 -7
  7. package/docs/mcp-and-tools.md +7 -0
  8. package/docs/operator-manual-vietnamese.md +27 -11
  9. package/docs/publishing-for-teams.md +2 -0
  10. package/docs/release-install-policy.md +6 -6
  11. package/docs/security-threat-model.md +7 -1
  12. package/docs/subagents-and-multiagent.md +1 -1
  13. package/docs/team-onboarding.md +2 -2
  14. package/docs-site/architecture.html +1 -1
  15. package/docs-site/commands.html +1 -1
  16. package/docs-site/content/context-engine.html +8 -1
  17. package/docs-site/content/security.html +1 -0
  18. package/docs-site/context-engine.html +9 -2
  19. package/docs-site/index.html +1 -1
  20. package/docs-site/mcp-approval.html +1 -1
  21. package/docs-site/mcp-auth.html +1 -1
  22. package/docs-site/mcp-servers.html +1 -1
  23. package/docs-site/mcp.html +1 -1
  24. package/docs-site/permissions.html +1 -1
  25. package/docs-site/profiles.html +1 -1
  26. package/docs-site/quickstart.html +1 -1
  27. package/docs-site/runtime.html +1 -1
  28. package/docs-site/scope.html +1 -1
  29. package/docs-site/security.html +2 -1
  30. package/docs-site/team.html +1 -1
  31. package/docs-site/workflows.html +1 -1
  32. package/package.json +1 -1
  33. package/packages/piagent-core/README.md +6 -1
  34. package/packages/piagent-core/capabilities/capability-core.js +3 -0
  35. package/packages/piagent-core/extensions/context-engine.js +263 -120
  36. package/packages/piagent-core/extensions/context-index-policy.js +136 -0
  37. package/packages/piagent-core/extensions/piagent-guard.ts +258 -101
  38. package/packages/piagent-core/mcp/mcp-config-layers.js +52 -6
  39. package/packages/piagent-core/mcp/mcp-tool-naming.js +12 -1
  40. package/packages/piagent-core/package.json +1 -1
  41. package/packages/piagent-core/subagents/piagent-reviewer.md +3 -3
  42. package/scripts/context-engine.mjs +22 -20
  43. package/scripts/update-global.mjs +1 -1
  44. package/scripts/verify-local.sh +2 -0
@@ -0,0 +1,136 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { resolveProjectProfileDocument } from "../capabilities/project-profile.js";
6
+
7
+ const DEFAULT_CONTEXT_INDEX_PATH = ".pi/context-index.json";
8
+ const CONTEXT_ENGINE_STATE_PATTERN = ".pi/piagent-state/**";
9
+ const EXCLUDE_POLICY_VERSION = 2;
10
+
11
+ function stringArray(value) {
12
+ return Array.isArray(value)
13
+ ? value.filter((item) => typeof item === "string" && item.trim().length > 0)
14
+ : [];
15
+ }
16
+
17
+ function uniqueStrings(values) {
18
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
19
+ }
20
+
21
+ function compareCodePoints(left, right) {
22
+ return left < right ? -1 : left > right ? 1 : 0;
23
+ }
24
+
25
+ function normalizePattern(value) {
26
+ return String(value ?? "")
27
+ .trim()
28
+ .replaceAll("\\", "/")
29
+ .replace(/^\.\/+/, "")
30
+ .replace(/\/+/g, "/");
31
+ }
32
+
33
+ function readJsonObject(file, label, { optional = false } = {}) {
34
+ if (!fs.existsSync(file)) {
35
+ if (optional) return undefined;
36
+ throw new Error(`${label} is missing at ${file}`);
37
+ }
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
41
+ } catch {
42
+ throw new Error(`${label} is not valid JSON: ${file}`);
43
+ }
44
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
45
+ throw new Error(`${label} must contain a JSON object: ${file}`);
46
+ }
47
+ return parsed;
48
+ }
49
+
50
+ export function contextIndexStatePath(profile) {
51
+ const configured = profile?.contextIndex?.path;
52
+ return typeof configured === "string" && configured.trim()
53
+ ? configured.trim()
54
+ : DEFAULT_CONTEXT_INDEX_PATH;
55
+ }
56
+
57
+ export function effectiveProtectedPaths(policy = {}, profile = {}) {
58
+ const policyProtectedPaths = stringArray(policy.protectedPaths);
59
+ const baseReadProtectedPaths = Array.isArray(policy.shellProtectedPaths)
60
+ ? stringArray(policy.shellProtectedPaths)
61
+ : policyProtectedPaths;
62
+ const profileProtectedPaths = stringArray(profile.protectedPaths);
63
+ const profileShellProtectedPaths = Array.isArray(profile.shellProtectedPaths)
64
+ ? stringArray(profile.shellProtectedPaths)
65
+ : profileProtectedPaths;
66
+ const readOnlyPaths = stringArray(profile.readOnlyPaths);
67
+ const contextIndexPath = contextIndexStatePath(profile);
68
+ return {
69
+ readProtectedPaths: uniqueStrings([
70
+ ...baseReadProtectedPaths,
71
+ ...profileProtectedPaths,
72
+ contextIndexPath
73
+ ]),
74
+ writeProtectedPaths: uniqueStrings([
75
+ ...policyProtectedPaths,
76
+ ...profileProtectedPaths,
77
+ ...readOnlyPaths,
78
+ contextIndexPath
79
+ ]),
80
+ shellProtectedPaths: uniqueStrings([
81
+ ...baseReadProtectedPaths,
82
+ ...profileShellProtectedPaths,
83
+ ...readOnlyPaths,
84
+ contextIndexPath
85
+ ]),
86
+ readOnlyPaths: uniqueStrings(readOnlyPaths)
87
+ };
88
+ }
89
+
90
+ export function normalizeContextIndexExcludePatterns(patterns) {
91
+ return uniqueStrings(stringArray(patterns).map(normalizePattern)).sort(compareCodePoints);
92
+ }
93
+
94
+ export function contextIndexExcludePatterns(policy, profile) {
95
+ const effective = effectiveProtectedPaths(policy, profile);
96
+ return normalizeContextIndexExcludePatterns([
97
+ ...effective.readProtectedPaths,
98
+ ...effective.writeProtectedPaths,
99
+ ...effective.shellProtectedPaths,
100
+ CONTEXT_ENGINE_STATE_PATTERN
101
+ ]);
102
+ }
103
+
104
+ export function contextIndexExcludeDigest(patterns) {
105
+ return crypto
106
+ .createHash("sha256")
107
+ .update(JSON.stringify({
108
+ version: EXCLUDE_POLICY_VERSION,
109
+ patterns: normalizeContextIndexExcludePatterns(patterns)
110
+ }))
111
+ .digest("hex");
112
+ }
113
+
114
+ export function contextIndexExcludePolicyVersion() {
115
+ return EXCLUDE_POLICY_VERSION;
116
+ }
117
+
118
+ export function loadProjectContextIndexPolicy(platformRoot, projectPath, options = {}) {
119
+ const policyPath = path.join(platformRoot, "packages", "piagent-core", "policies", "base-policy.json");
120
+ const policy = readJsonObject(policyPath, "Piagent base policy");
121
+ const explicitProfilePath = typeof options.profilePath === "string" && options.profilePath.trim()
122
+ ? path.resolve(options.profilePath)
123
+ : undefined;
124
+ const profilePath = explicitProfilePath ?? path.join(projectPath, ".pi", "piagent-profile.json");
125
+ const stored = readJsonObject(profilePath, "Piagent project profile", { optional: !explicitProfilePath });
126
+ const profile = stored
127
+ ? resolveProjectProfileDocument(platformRoot, stored).profile
128
+ : {};
129
+ return {
130
+ policy,
131
+ profile,
132
+ profilePath,
133
+ profileSource: explicitProfilePath ? "env" : stored ? "project" : "fallback",
134
+ excludePatterns: contextIndexExcludePatterns(policy, profile)
135
+ };
136
+ }
@@ -81,10 +81,15 @@ import {
81
81
  buildTestImpact,
82
82
  classifyContextTask,
83
83
  contextIndexV2Status,
84
+ ensureContextIndexV2,
84
85
  estimateContextTokens,
85
86
  searchContextIndexV2,
86
87
  toolResultFingerprint
87
88
  } from "./context-engine.js";
89
+ import {
90
+ contextIndexExcludePatterns,
91
+ effectiveProtectedPaths
92
+ } from "./context-index-policy.js";
88
93
 
89
94
  import type { ActionClassification } from "./guard-shell-analysis.ts";
90
95
 
@@ -99,7 +104,6 @@ import type {
99
104
  ContextIndexNodeKind,
100
105
  ContextIndexSettings,
101
106
  ContextPreflight,
102
- EffectiveProtectedPaths,
103
107
  ExecPolicyConfig,
104
108
  ExternalActionPolicyConfig,
105
109
  FinalGateConfig,
@@ -259,6 +263,14 @@ type ChatImageAttachmentResult = {
259
263
  skipped: Array<{ path: string; reason: string }>;
260
264
  };
261
265
 
266
+ type ChatImageAccessPolicy = {
267
+ roots: ReturnType<typeof resolveDocumentRoots>;
268
+ readProtectedPaths: string[];
269
+ filesystemRead?: string[];
270
+ enforceFilesystemRead: boolean;
271
+ onImageInspected?: (path: string) => void;
272
+ };
273
+
262
274
  const DEFAULT_MEMORY_SETTINGS: Required<MemorySettings> = {
263
275
  enabled: true,
264
276
  mode: "manual",
@@ -1921,34 +1933,6 @@ function uniqueStrings(values: string[]): string[] {
1921
1933
  return [...new Set(values.filter((value) => typeof value === "string" && value.trim().length > 0))];
1922
1934
  }
1923
1935
 
1924
- function effectiveProtectedPaths(policy: BasePolicy, profile: ProjectProfile): EffectiveProtectedPaths {
1925
- const baseReadProtectedPaths = policy.shellProtectedPaths ?? policy.protectedPaths;
1926
- const profileProtectedPaths = profile.protectedPaths ?? [];
1927
- const profileShellProtectedPaths = profile.shellProtectedPaths ?? profile.protectedPaths ?? [];
1928
- const readOnlyPaths = profile.readOnlyPaths ?? [];
1929
- const contextIndexStatePath = resolveContextIndexSettings(profile).path;
1930
- return {
1931
- readProtectedPaths: uniqueStrings([
1932
- ...baseReadProtectedPaths,
1933
- ...profileProtectedPaths,
1934
- contextIndexStatePath
1935
- ]),
1936
- writeProtectedPaths: uniqueStrings([
1937
- ...policy.protectedPaths,
1938
- ...profileProtectedPaths,
1939
- ...readOnlyPaths,
1940
- contextIndexStatePath
1941
- ]),
1942
- shellProtectedPaths: uniqueStrings([
1943
- ...baseReadProtectedPaths,
1944
- ...profileShellProtectedPaths,
1945
- ...readOnlyPaths,
1946
- contextIndexStatePath
1947
- ]),
1948
- readOnlyPaths: uniqueStrings(readOnlyPaths)
1949
- };
1950
- }
1951
-
1952
1936
  function classifyExternalAction(toolName: string, input: Record<string, unknown>, policy: BasePolicy): {
1953
1937
  decision: "not-external" | "safe-read" | "confirm";
1954
1938
  provider?: string;
@@ -4009,29 +3993,55 @@ function escapeRegExp(input: string): string {
4009
3993
  return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4010
3994
  }
4011
3995
 
4012
- function supportedImageMimeType(filePath: string, bytes: Buffer): string | undefined {
4013
- if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
3996
+ function supportedImageMimeType(bytes: Buffer): string | undefined {
3997
+ if (
3998
+ bytes.length >= 24
3999
+ && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
4000
+ && bytes.readUInt32BE(8) === 13
4001
+ && bytes.subarray(12, 16).toString("ascii") === "IHDR"
4002
+ && bytes.readUInt32BE(16) > 0
4003
+ && bytes.readUInt32BE(20) > 0
4004
+ ) {
4014
4005
  return "image/png";
4015
4006
  }
4016
- if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
4007
+ if (
4008
+ bytes.length >= 4
4009
+ && bytes[0] === 0xff
4010
+ && bytes[1] === 0xd8
4011
+ && bytes[2] === 0xff
4012
+ && bytes[bytes.length - 2] === 0xff
4013
+ && bytes[bytes.length - 1] === 0xd9
4014
+ ) {
4017
4015
  return "image/jpeg";
4018
4016
  }
4019
- if (bytes.length >= 6) {
4017
+ if (bytes.length >= 10) {
4020
4018
  const head = bytes.subarray(0, 6).toString("ascii");
4021
- if (head === "GIF87a" || head === "GIF89a") return "image/gif";
4019
+ if (
4020
+ (head === "GIF87a" || head === "GIF89a")
4021
+ && bytes.readUInt16LE(6) > 0
4022
+ && bytes.readUInt16LE(8) > 0
4023
+ ) {
4024
+ return "image/gif";
4025
+ }
4022
4026
  }
4023
- if (bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP") {
4027
+ if (
4028
+ bytes.length >= 16
4029
+ && bytes.subarray(0, 4).toString("ascii") === "RIFF"
4030
+ && bytes.subarray(8, 12).toString("ascii") === "WEBP"
4031
+ && ["VP8 ", "VP8L", "VP8X"].includes(bytes.subarray(12, 16).toString("ascii"))
4032
+ && bytes.readUInt32LE(4) + 8 <= bytes.length
4033
+ ) {
4024
4034
  return "image/webp";
4025
4035
  }
4026
- if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) {
4036
+ if (
4037
+ bytes.length >= 26
4038
+ && bytes[0] === 0x42
4039
+ && bytes[1] === 0x4d
4040
+ && bytes.readUInt32LE(2) <= bytes.length
4041
+ && bytes.readUInt32LE(14) >= 12
4042
+ ) {
4027
4043
  return "image/bmp";
4028
4044
  }
4029
- const ext = path.extname(filePath).toLowerCase().replace(/^\./, "");
4030
- if (ext === "png") return "image/png";
4031
- if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
4032
- if (ext === "gif") return "image/gif";
4033
- if (ext === "webp") return "image/webp";
4034
- if (ext === "bmp") return "image/bmp";
4035
4045
  return undefined;
4036
4046
  }
4037
4047
 
@@ -4085,9 +4095,131 @@ function extractLocalImagePathCandidates(text: string, cwd: string): string[] {
4085
4095
  return [...candidates];
4086
4096
  }
4087
4097
 
4088
- function attachLocalImagesFromText(text: string, existingImages: unknown[] | undefined, cwd: string): ChatImageAttachmentResult | undefined {
4098
+ type ResolvedChatImage =
4099
+ | { status: "ok"; absolutePath: string; bytes: Buffer; mimeType: string }
4100
+ | { status: "error"; reason: string };
4101
+
4102
+ function pathContainedBy(root: string, candidate: string): boolean {
4103
+ if (candidate === root) return true;
4104
+ const relative = path.relative(root, candidate);
4105
+ return relative.length > 0
4106
+ && relative !== ".."
4107
+ && !relative.startsWith(`..${path.sep}`)
4108
+ && !path.isAbsolute(relative);
4109
+ }
4110
+
4111
+ export function readChatImage(
4112
+ requestedPath: string,
4113
+ cwd: string,
4114
+ access: ChatImageAccessPolicy
4115
+ ): ResolvedChatImage {
4116
+ const lexicalRequestedPath = path.isAbsolute(requestedPath)
4117
+ ? path.normalize(requestedPath)
4118
+ : path.resolve(cwd, requestedPath);
4119
+ const lexicalProjectRoot = path.resolve(cwd);
4120
+ const requestedProjectLexicalRelative = pathContainedBy(lexicalProjectRoot, lexicalRequestedPath)
4121
+ ? path.relative(lexicalProjectRoot, lexicalRequestedPath).split(path.sep).join("/") || "."
4122
+ : undefined;
4123
+ let canonicalPath: string;
4124
+ let inspected: fs.Stats;
4125
+ try {
4126
+ canonicalPath = fs.realpathSync.native(requestedPath);
4127
+ inspected = fs.statSync(canonicalPath);
4128
+ } catch {
4129
+ return { status: "error", reason: "file does not exist or cannot be inspected" };
4130
+ }
4131
+ if (!inspected.isFile()) return { status: "error", reason: "not a regular file" };
4132
+ if (inspected.size <= 0) return { status: "error", reason: "empty file" };
4133
+ if (inspected.size > MAX_CHAT_IMAGE_BYTES) {
4134
+ return {
4135
+ status: "error",
4136
+ reason: `image is ${formatCount(inspected.size)} bytes > ${formatCount(MAX_CHAT_IMAGE_BYTES)} byte limit`
4137
+ };
4138
+ }
4139
+
4140
+ const root = access.roots.find((candidate) => pathContainedBy(candidate.path, canonicalPath));
4141
+ if (!root) return { status: "error", reason: "outside the project and every granted additionalReadRoots directory" };
4142
+
4143
+ const rootRelative = path.relative(root.path, canonicalPath).split(path.sep).join("/") || ".";
4144
+ const projectRoot = access.roots.find((candidate) => candidate.source === "project");
4145
+ let canonicalRequestedLocation = requestedPath;
4146
+ try {
4147
+ canonicalRequestedLocation = path.join(
4148
+ fs.realpathSync.native(path.dirname(requestedPath)),
4149
+ path.basename(requestedPath)
4150
+ );
4151
+ } catch {
4152
+ // The full target already resolved. This location form only preserves the
4153
+ // final symlink's policy position, so failure falls back to the raw path.
4154
+ }
4155
+ const requestedProjectRelative = projectRoot && pathContainedBy(projectRoot.path, canonicalRequestedLocation)
4156
+ ? path.relative(projectRoot.path, canonicalRequestedLocation).split(path.sep).join("/") || "."
4157
+ : undefined;
4158
+ const protectedMatch = matchesProtectedPath(canonicalPath, access.readProtectedPaths)
4159
+ ?? matchesProtectedPath(rootRelative, access.readProtectedPaths)
4160
+ ?? matchesProtectedPath(canonicalRequestedLocation, access.readProtectedPaths)
4161
+ ?? matchesProtectedPath(requestedPath, access.readProtectedPaths)
4162
+ ?? (requestedProjectLexicalRelative
4163
+ ? matchesProtectedPath(requestedProjectLexicalRelative, access.readProtectedPaths)
4164
+ : undefined)
4165
+ ?? (requestedProjectRelative ? matchesProtectedPath(requestedProjectRelative, access.readProtectedPaths) : undefined);
4166
+ if (protectedMatch) return { status: "error", reason: `matches protected path ${protectedMatch}` };
4167
+
4168
+ if (
4169
+ root.source === "project"
4170
+ && access.enforceFilesystemRead
4171
+ && access.filesystemRead
4172
+ && !matchesAnyPath(rootRelative, access.filesystemRead)
4173
+ ) {
4174
+ return {
4175
+ status: "error",
4176
+ reason: `outside the resolved filesystem read scope (${access.filesystemRead.join(", ")})`
4177
+ };
4178
+ }
4179
+
4180
+ access.onImageInspected?.(canonicalPath);
4181
+ const openFlags = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | fs.constants.O_NOFOLLOW;
4182
+ let fd: number;
4183
+ try {
4184
+ fd = fs.openSync(canonicalPath, openFlags);
4185
+ } catch {
4186
+ return { status: "error", reason: "file changed before it could be opened" };
4187
+ }
4188
+ try {
4189
+ const opened = fs.fstatSync(fd);
4190
+ if (
4191
+ !opened.isFile()
4192
+ || opened.dev !== inspected.dev
4193
+ || opened.ino !== inspected.ino
4194
+ || opened.size !== inspected.size
4195
+ ) {
4196
+ return { status: "error", reason: "file changed between the safety checks and the read" };
4197
+ }
4198
+ const bytes = Buffer.alloc(opened.size);
4199
+ let offset = 0;
4200
+ while (offset < bytes.length) {
4201
+ const read = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
4202
+ if (read <= 0) break;
4203
+ offset += read;
4204
+ }
4205
+ if (offset !== bytes.length) return { status: "error", reason: "file changed while it was being read" };
4206
+ const mimeType = supportedImageMimeType(bytes);
4207
+ if (!mimeType) return { status: "error", reason: "file bytes are not a supported image" };
4208
+ return { status: "ok", absolutePath: canonicalPath, bytes, mimeType };
4209
+ } finally {
4210
+ fs.closeSync(fd);
4211
+ }
4212
+ }
4213
+
4214
+ function attachLocalImagesFromText(
4215
+ text: string,
4216
+ existingImages: unknown[] | undefined,
4217
+ cwd: string,
4218
+ resolveAccess: () => ChatImageAccessPolicy
4219
+ ): ChatImageAttachmentResult | undefined {
4089
4220
  const imagePaths = extractLocalImagePathCandidates(text, cwd);
4090
4221
  if (imagePaths.length === 0) return undefined;
4222
+ const access = resolveAccess();
4091
4223
 
4092
4224
  const existing = Array.isArray(existingImages) ? existingImages : [];
4093
4225
  const images: ChatImageAttachmentResult["images"] = [];
@@ -4101,28 +4233,14 @@ function attachLocalImagesFromText(text: string, existingImages: unknown[] | und
4101
4233
  continue;
4102
4234
  }
4103
4235
  try {
4104
- const stat = fs.statSync(imagePath);
4105
- if (!stat.isFile()) {
4106
- skipped.push({ path: imagePath, reason: "not a file" });
4107
- continue;
4108
- }
4109
- if (stat.size <= 0) {
4110
- skipped.push({ path: imagePath, reason: "empty file" });
4111
- continue;
4112
- }
4113
- if (stat.size > MAX_CHAT_IMAGE_BYTES) {
4114
- skipped.push({ path: imagePath, reason: `image is ${formatCount(stat.size)} bytes > ${formatCount(MAX_CHAT_IMAGE_BYTES)} byte limit; use read on the file so Pi can resize it` });
4115
- continue;
4116
- }
4117
- const bytes = fs.readFileSync(imagePath);
4118
- const mimeType = supportedImageMimeType(imagePath, bytes);
4119
- if (!mimeType) {
4120
- skipped.push({ path: imagePath, reason: "unsupported image type" });
4236
+ const resolved = readChatImage(imagePath, cwd, access);
4237
+ if (resolved.status === "error") {
4238
+ skipped.push({ path: imagePath, reason: resolved.reason });
4121
4239
  continue;
4122
4240
  }
4123
4241
  const marker = `[image${existing.length + images.length + 1}]`;
4124
- images.push({ type: "image", mimeType, data: bytes.toString("base64") });
4125
- attached.push({ marker, path: imagePath, mimeType, bytes: stat.size });
4242
+ images.push({ type: "image", mimeType: resolved.mimeType, data: resolved.bytes.toString("base64") });
4243
+ attached.push({ marker, path: imagePath, mimeType: resolved.mimeType, bytes: resolved.bytes.length });
4126
4244
  nextText = nextText.replace(new RegExp(escapeRegExp(imagePath), "g"), marker);
4127
4245
  nextText = nextText.replace(new RegExp(escapeRegExp(`file://${imagePath}`), "g"), marker);
4128
4246
  } catch (error) {
@@ -4480,7 +4598,9 @@ export default function piagentGuard(pi: ExtensionAPI) {
4480
4598
  if (updateNotice) ctx.ui.notify(updateNotice, "info");
4481
4599
  let engineStatus: unknown;
4482
4600
  try {
4483
- engineStatus = await contextIndexV2Status(ctx.cwd);
4601
+ engineStatus = await contextIndexV2Status(ctx.cwd, {
4602
+ excludePatterns: contextIndexExcludePatterns(policy, profile)
4603
+ });
4484
4604
  } catch (error) {
4485
4605
  engineStatus = { exists: false, error: error instanceof Error ? error.message : String(error) };
4486
4606
  }
@@ -4506,7 +4626,22 @@ export default function piagentGuard(pi: ExtensionAPI) {
4506
4626
  termCount: taskSignal.terms.length
4507
4627
  });
4508
4628
 
4509
- const imageAttachment = attachLocalImagesFromText(text, event.images, ctx.cwd);
4629
+ const imageAttachment = attachLocalImagesFromText(text, event.images, ctx.cwd, () => {
4630
+ const projectTrusted = ctx.isProjectTrusted();
4631
+ const profile = loadProfileFromContext(ctx);
4632
+ const permissionProfile = resolvePermissionProfile(profile, policy, permissionOverrideFromContext(ctx));
4633
+ const capabilityState = verifyProjectCapabilityState(extensionDir, ctx.cwd, projectTrusted);
4634
+ return {
4635
+ roots: resolveDocumentRoots({
4636
+ cwd: ctx.cwd,
4637
+ profileRoots: profile.additionalReadRoots,
4638
+ environmentRoots: process.env.PIAGENT_ADDITIONAL_READ_ROOTS
4639
+ }),
4640
+ readProtectedPaths: effectiveProtectedPaths(policy, profile).readProtectedPaths,
4641
+ filesystemRead: capabilityState.filesystemRead,
4642
+ enforceFilesystemRead: permissionProfile.mode !== "trusted-full-access"
4643
+ };
4644
+ });
4510
4645
  if (imageAttachment?.attached.length) {
4511
4646
  ctx.ui.notify(`Piagent image input: attached ${imageAttachment.attached.map((item) => item.marker).join(", ")}`, "info");
4512
4647
  } else if (imageAttachment?.skipped.length) {
@@ -4590,7 +4725,12 @@ export default function piagentGuard(pi: ExtensionAPI) {
4590
4725
  autoPackedPrompts.add(signal.promptHash);
4591
4726
  if (autoPackedPrompts.size > 50) autoPackedPrompts.delete(autoPackedPrompts.values().next().value as string);
4592
4727
  try {
4593
- const status = await contextIndexV2Status(ctx.cwd);
4728
+ const excludePatterns = contextIndexExcludePatterns(policy, loadProfileFromContext(ctx));
4729
+ const ensured = await ensureContextIndexV2(ctx.cwd, {
4730
+ excludePatterns,
4731
+ rebuildMissing: false
4732
+ });
4733
+ const status = ensured.status;
4594
4734
  if (!status.exists || status.stale) {
4595
4735
  telemetry(ctx, {
4596
4736
  event: "context_pack",
@@ -4603,7 +4743,12 @@ export default function piagentGuard(pi: ExtensionAPI) {
4603
4743
  return;
4604
4744
  }
4605
4745
  const budgetTokens = signal.lane === "high-risk" ? 1_200 : signal.lane === "tiny" ? 500 : 900;
4606
- const pack = await buildContextPack(ctx.cwd, query, { budgetTokens, includeCode: false, limit: 12 });
4746
+ const pack = await buildContextPack(ctx.cwd, query, {
4747
+ budgetTokens,
4748
+ includeCode: false,
4749
+ limit: 12,
4750
+ excludePatterns
4751
+ });
4607
4752
  telemetry(ctx, {
4608
4753
  event: "context_pack",
4609
4754
  queryHash: pack.queryHash,
@@ -5029,17 +5174,11 @@ export default function piagentGuard(pi: ExtensionAPI) {
5029
5174
  }),
5030
5175
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
5031
5176
  const profile = loadProfileFromContext(ctx);
5032
- const pathPolicy = effectiveProtectedPaths(policy, profile);
5033
- const excludePatterns = Array.from(new Set([
5034
- ...pathPolicy.readProtectedPaths,
5035
- ...pathPolicy.writeProtectedPaths,
5036
- ".pi/context-index.json",
5037
- ".pi/piagent-state/**"
5038
- ]));
5177
+ const excludePatterns = contextIndexExcludePatterns(policy, profile);
5039
5178
  let result: unknown;
5040
5179
  let text: string;
5041
5180
  if (params.action === "status") {
5042
- result = await contextIndexV2Status(ctx.cwd);
5181
+ result = await contextIndexV2Status(ctx.cwd, { excludePatterns });
5043
5182
  const status = result as Awaited<ReturnType<typeof contextIndexV2Status>>;
5044
5183
  text = [
5045
5184
  `indexV2: ${status.exists ? "ready" : "missing"}`,
@@ -5062,12 +5201,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
5062
5201
  ].join("\n");
5063
5202
  } else if (params.action === "search") {
5064
5203
  if (!params.query?.trim()) throw new Error("query is required for context search");
5065
- let status = await contextIndexV2Status(ctx.cwd);
5066
- if (params.refresh || !status.exists) {
5067
- await buildContextIndexV2(ctx.cwd, { excludePatterns });
5068
- status = await contextIndexV2Status(ctx.cwd);
5069
- }
5070
- result = await searchContextIndexV2(ctx.cwd, params.query, { limit: 15 });
5204
+ const ensured = await ensureContextIndexV2(ctx.cwd, {
5205
+ excludePatterns,
5206
+ refresh: params.refresh,
5207
+ rebuildMissing: true
5208
+ });
5209
+ const status = ensured.status;
5210
+ result = await searchContextIndexV2(ctx.cwd, params.query, { limit: 15, excludePatterns });
5071
5211
  const search = result as Awaited<ReturnType<typeof searchContextIndexV2>>;
5072
5212
  text = [
5073
5213
  `confidence: ${search.confidence}`,
@@ -5076,20 +5216,23 @@ export default function piagentGuard(pi: ExtensionAPI) {
5076
5216
  ].join("\n");
5077
5217
  } else if (params.action === "pack") {
5078
5218
  if (!params.query?.trim()) throw new Error("query is required for a context pack");
5079
- let status = await contextIndexV2Status(ctx.cwd);
5080
- if (params.refresh || !status.exists) {
5081
- await buildContextIndexV2(ctx.cwd, { excludePatterns });
5082
- status = await contextIndexV2Status(ctx.cwd);
5083
- }
5219
+ const ensured = await ensureContextIndexV2(ctx.cwd, {
5220
+ excludePatterns,
5221
+ refresh: params.refresh,
5222
+ rebuildMissing: true
5223
+ });
5224
+ const status = ensured.status;
5084
5225
  const pack = await buildContextPack(ctx.cwd, params.query, {
5085
5226
  budgetTokens: params.budgetTokens ?? 6_000,
5086
5227
  includeCode: true,
5087
- limit: 18
5228
+ limit: 18,
5229
+ excludePatterns
5088
5230
  });
5089
5231
  result = { ...pack, text: undefined, status };
5090
5232
  text = pack.text;
5091
5233
  } else if (params.action === "impact") {
5092
- result = await buildTestImpact(ctx.cwd, params.files ?? []);
5234
+ await ensureContextIndexV2(ctx.cwd, { excludePatterns, rebuildMissing: false });
5235
+ result = await buildTestImpact(ctx.cwd, params.files ?? [], { excludePatterns });
5093
5236
  const impact = result as Awaited<ReturnType<typeof buildTestImpact>>;
5094
5237
  text = [
5095
5238
  `changed: ${impact.changedFiles.join(", ") || "none"}`,
@@ -5139,7 +5282,9 @@ export default function piagentGuard(pi: ExtensionAPI) {
5139
5282
  const contextIndex = buildContextIndexStatus(ctx.cwd, profile);
5140
5283
  let contextEngine: Awaited<ReturnType<typeof contextIndexV2Status>> | { exists: false; warnings: string[] };
5141
5284
  try {
5142
- contextEngine = await contextIndexV2Status(ctx.cwd);
5285
+ contextEngine = await contextIndexV2Status(ctx.cwd, {
5286
+ excludePatterns: contextIndexExcludePatterns(policy, profile)
5287
+ });
5143
5288
  } catch (error) {
5144
5289
  contextEngine = { exists: false, warnings: [error instanceof Error ? error.message : String(error)] };
5145
5290
  }
@@ -7232,7 +7377,9 @@ export default function piagentGuard(pi: ExtensionAPI) {
7232
7377
 
7233
7378
  async function emitContextEngineStatus(ctx: ExtensionContext): Promise<void> {
7234
7379
  try {
7235
- const status = await contextIndexV2Status(ctx.cwd);
7380
+ const status = await contextIndexV2Status(ctx.cwd, {
7381
+ excludePatterns: contextIndexExcludePatterns(policy, loadProfileFromContext(ctx))
7382
+ });
7236
7383
  emitRuntimeMessage(ctx, "piagent-context-engine-status", [
7237
7384
  `contextEngine: ${status.exists ? "ready" : "missing"}`,
7238
7385
  `path: ${status.path}`,
@@ -7251,15 +7398,10 @@ export default function piagentGuard(pi: ExtensionAPI) {
7251
7398
 
7252
7399
  async function rebuildContextEngine(ctx: ExtensionContext): Promise<void> {
7253
7400
  const profile = loadProfileFromContext(ctx);
7254
- const pathPolicy = effectiveProtectedPaths(policy, profile);
7401
+ const excludePatterns = contextIndexExcludePatterns(policy, profile);
7255
7402
  try {
7256
7403
  const result = await buildContextIndexV2(ctx.cwd, {
7257
- excludePatterns: Array.from(new Set([
7258
- ...pathPolicy.readProtectedPaths,
7259
- ...pathPolicy.writeProtectedPaths,
7260
- ".pi/context-index.json",
7261
- ".pi/piagent-state/**"
7262
- ]))
7404
+ excludePatterns
7263
7405
  });
7264
7406
  telemetry(ctx, { event: "context_engine_action", action: "rebuild", ...result });
7265
7407
  emitRuntimeMessage(ctx, "piagent-context-engine-rebuild", [
@@ -7275,9 +7417,13 @@ export default function piagentGuard(pi: ExtensionAPI) {
7275
7417
  }
7276
7418
 
7277
7419
  async function emitContextEngineSearch(ctx: ExtensionContext, query: string): Promise<boolean> {
7278
- const status = await contextIndexV2Status(ctx.cwd);
7420
+ const excludePatterns = contextIndexExcludePatterns(policy, loadProfileFromContext(ctx));
7421
+ const { status } = await ensureContextIndexV2(ctx.cwd, {
7422
+ excludePatterns,
7423
+ rebuildMissing: false
7424
+ });
7279
7425
  if (!status.exists || !query.trim()) return false;
7280
- const search = await searchContextIndexV2(ctx.cwd, query, { limit: 12 });
7426
+ const search = await searchContextIndexV2(ctx.cwd, query, { limit: 12, excludePatterns });
7281
7427
  emitRuntimeMessage(ctx, "piagent-context-engine-search", [
7282
7428
  `confidence: ${search.confidence}`,
7283
7429
  `stale: ${status.stale ? "yes" : "no"}`,
@@ -7291,12 +7437,21 @@ export default function piagentGuard(pi: ExtensionAPI) {
7291
7437
  emitRuntimeMessage(ctx, "piagent-context-pack-help", "Usage: /context pack <task or symbol>");
7292
7438
  return;
7293
7439
  }
7294
- const status = await contextIndexV2Status(ctx.cwd);
7440
+ const excludePatterns = contextIndexExcludePatterns(policy, loadProfileFromContext(ctx));
7441
+ const { status } = await ensureContextIndexV2(ctx.cwd, {
7442
+ excludePatterns,
7443
+ rebuildMissing: false
7444
+ });
7295
7445
  if (!status.exists) {
7296
7446
  emitRuntimeMessage(ctx, "piagent-context-pack-help", "Context Engine index is missing. Run /context rebuild first.");
7297
7447
  return;
7298
7448
  }
7299
- const pack = await buildContextPack(ctx.cwd, query, { budgetTokens: 1_500, includeCode: false, limit: 15 });
7449
+ const pack = await buildContextPack(ctx.cwd, query, {
7450
+ budgetTokens: 1_500,
7451
+ includeCode: false,
7452
+ limit: 15,
7453
+ excludePatterns
7454
+ });
7300
7455
  telemetry(ctx, {
7301
7456
  event: "context_pack",
7302
7457
  queryHash: pack.queryHash,
@@ -7318,7 +7473,9 @@ export default function piagentGuard(pi: ExtensionAPI) {
7318
7473
 
7319
7474
  async function emitTestImpact(ctx: ExtensionContext, raw: string): Promise<void> {
7320
7475
  const files = raw.split(/\s+/).map((file) => file.trim()).filter(Boolean);
7321
- const impact = await buildTestImpact(ctx.cwd, files);
7476
+ const excludePatterns = contextIndexExcludePatterns(policy, loadProfileFromContext(ctx));
7477
+ await ensureContextIndexV2(ctx.cwd, { excludePatterns, rebuildMissing: false });
7478
+ const impact = await buildTestImpact(ctx.cwd, files, { excludePatterns });
7322
7479
  emitRuntimeMessage(ctx, "piagent-context-impact", [
7323
7480
  `changed: ${impact.changedFiles.join(", ") || "none"}`,
7324
7481
  `impacted: ${impact.impactedFiles.map((item) => `${item.path} via ${item.via}`).join(", ") || "none"}`,