@piagent/platform 1.2.9 → 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 (40) hide show
  1. package/CHANGELOG.md +42 -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 +23 -4
  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 +1 -0
  10. package/docs/release-install-policy.md +6 -6
  11. package/docs/security-threat-model.md +7 -2
  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/extensions/context-engine.js +134 -69
  35. package/packages/piagent-core/extensions/context-index-policy.js +1 -1
  36. package/packages/piagent-core/extensions/piagent-guard.ts +191 -34
  37. package/packages/piagent-core/mcp/mcp-config-layers.js +52 -6
  38. package/packages/piagent-core/mcp/mcp-tool-naming.js +12 -1
  39. package/packages/piagent-core/package.json +1 -1
  40. package/packages/piagent-core/subagents/piagent-reviewer.md +3 -3
@@ -263,6 +263,14 @@ type ChatImageAttachmentResult = {
263
263
  skipped: Array<{ path: string; reason: string }>;
264
264
  };
265
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
+
266
274
  const DEFAULT_MEMORY_SETTINGS: Required<MemorySettings> = {
267
275
  enabled: true,
268
276
  mode: "manual",
@@ -3985,29 +3993,55 @@ function escapeRegExp(input: string): string {
3985
3993
  return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3986
3994
  }
3987
3995
 
3988
- function supportedImageMimeType(filePath: string, bytes: Buffer): string | undefined {
3989
- 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
+ ) {
3990
4005
  return "image/png";
3991
4006
  }
3992
- 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
+ ) {
3993
4015
  return "image/jpeg";
3994
4016
  }
3995
- if (bytes.length >= 6) {
4017
+ if (bytes.length >= 10) {
3996
4018
  const head = bytes.subarray(0, 6).toString("ascii");
3997
- 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
+ }
3998
4026
  }
3999
- 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
+ ) {
4000
4034
  return "image/webp";
4001
4035
  }
4002
- 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
+ ) {
4003
4043
  return "image/bmp";
4004
4044
  }
4005
- const ext = path.extname(filePath).toLowerCase().replace(/^\./, "");
4006
- if (ext === "png") return "image/png";
4007
- if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
4008
- if (ext === "gif") return "image/gif";
4009
- if (ext === "webp") return "image/webp";
4010
- if (ext === "bmp") return "image/bmp";
4011
4045
  return undefined;
4012
4046
  }
4013
4047
 
@@ -4061,9 +4095,131 @@ function extractLocalImagePathCandidates(text: string, cwd: string): string[] {
4061
4095
  return [...candidates];
4062
4096
  }
4063
4097
 
4064
- 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 {
4065
4220
  const imagePaths = extractLocalImagePathCandidates(text, cwd);
4066
4221
  if (imagePaths.length === 0) return undefined;
4222
+ const access = resolveAccess();
4067
4223
 
4068
4224
  const existing = Array.isArray(existingImages) ? existingImages : [];
4069
4225
  const images: ChatImageAttachmentResult["images"] = [];
@@ -4077,28 +4233,14 @@ function attachLocalImagesFromText(text: string, existingImages: unknown[] | und
4077
4233
  continue;
4078
4234
  }
4079
4235
  try {
4080
- const stat = fs.statSync(imagePath);
4081
- if (!stat.isFile()) {
4082
- skipped.push({ path: imagePath, reason: "not a file" });
4083
- continue;
4084
- }
4085
- if (stat.size <= 0) {
4086
- skipped.push({ path: imagePath, reason: "empty file" });
4087
- continue;
4088
- }
4089
- if (stat.size > MAX_CHAT_IMAGE_BYTES) {
4090
- 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` });
4091
- continue;
4092
- }
4093
- const bytes = fs.readFileSync(imagePath);
4094
- const mimeType = supportedImageMimeType(imagePath, bytes);
4095
- if (!mimeType) {
4096
- 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 });
4097
4239
  continue;
4098
4240
  }
4099
4241
  const marker = `[image${existing.length + images.length + 1}]`;
4100
- images.push({ type: "image", mimeType, data: bytes.toString("base64") });
4101
- 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 });
4102
4244
  nextText = nextText.replace(new RegExp(escapeRegExp(imagePath), "g"), marker);
4103
4245
  nextText = nextText.replace(new RegExp(escapeRegExp(`file://${imagePath}`), "g"), marker);
4104
4246
  } catch (error) {
@@ -4484,7 +4626,22 @@ export default function piagentGuard(pi: ExtensionAPI) {
4484
4626
  termCount: taskSignal.terms.length
4485
4627
  });
4486
4628
 
4487
- 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
+ });
4488
4645
  if (imageAttachment?.attached.length) {
4489
4646
  ctx.ui.notify(`Piagent image input: attached ${imageAttachment.attached.map((item) => item.marker).join(", ")}`, "info");
4490
4647
  } else if (imageAttachment?.skipped.length) {
@@ -8,7 +8,7 @@ import {
8
8
  importedServers,
9
9
  unreadableImportTargets
10
10
  } from "./mcp-import-kinds.js";
11
- import { erasesToolOrigin } from "./mcp-tool-naming.js";
11
+ import { directToolsEnabled, erasesToolOrigin } from "./mcp-tool-naming.js";
12
12
 
13
13
  // Four files can define MCP servers, and which one a change belongs in is the
14
14
  // first thing anyone gets wrong. The scope names are the vocabulary the CLI
@@ -253,13 +253,29 @@ export function mergedServerEntry(options) {
253
253
  } catch {
254
254
  continue;
255
255
  }
256
- const entry = config.mcpServers[options.name];
257
- if (!isRecord(entry)) continue;
258
- merged = { ...merged, ...entry };
256
+ let layerEntry = {};
257
+ let layerSeen = false;
258
+ // The adapter expands imports inside each layer before applying that
259
+ // layer's direct server map. First imported definition wins; the direct
260
+ // entry then overrides it field by field.
261
+ for (const kind of config.imports) {
262
+ const imported = importedServers(kind, options).find((server) => server.name === options.name);
263
+ if (!imported) continue;
264
+ layerEntry = { ...imported.entry };
265
+ layerSeen = true;
266
+ break;
267
+ }
268
+ const directEntry = config.mcpServers[options.name];
269
+ if (isRecord(directEntry)) {
270
+ layerEntry = { ...layerEntry, ...directEntry };
271
+ layerSeen = true;
272
+ }
273
+ if (!layerSeen) continue;
274
+ merged = { ...merged, ...layerEntry };
259
275
  seen = true;
260
276
  }
261
- // A server reached through `imports` is in no layer's `mcpServers`, so the
262
- // entry the caller already has is the whole definition.
277
+ // A server supplied directly by a caller outside the configured layers keeps
278
+ // its own definition as the fallback.
263
279
  return seen ? merged : { ...(options.entry ?? {}) };
264
280
  }
265
281
 
@@ -430,6 +446,36 @@ export function unverifiableMcpConfig(options = {}) {
430
446
  });
431
447
  }
432
448
 
449
+ if (merged.settings.toolPrefix === "none" && !erasesToolOrigin(merged.settings)) {
450
+ const prefixScope = merged.contributors.toolPrefix;
451
+ const prefixFile = prefixScope
452
+ ? configPathForScope(prefixScope, options)
453
+ : configPathForScope("global", options);
454
+ const affected = new Map();
455
+ for (const server of collectServers(options)) {
456
+ if (!server.requiresApproval || affected.has(server.name)) continue;
457
+ const effective = mergedServerEntry({
458
+ ...options,
459
+ name: server.name,
460
+ entry: server.entry
461
+ });
462
+ if (!directToolsEnabled(effective.directTools)) continue;
463
+ affected.set(server.name, server.file);
464
+ }
465
+ if (affected.size > 0) {
466
+ const serverFiles = [...new Set(affected.values())];
467
+ const names = [...affected.keys()].sort();
468
+ problems.push({
469
+ scope: "repository direct tools",
470
+ file: serverFiles[0] ?? prefixFile,
471
+ detail: `${serverFiles.join(" and ")} ${serverFiles.length === 1 ? "enables" : "enable"} effective directTools ` +
472
+ `for repository MCP server(s) ${names.join(", ")} ` +
473
+ `while ${prefixFile} sets toolPrefix "none". This strips the server name off those tools and leaves ` +
474
+ "nothing to check an approval against. Disable directTools for those servers or use a server-bearing tool prefix."
475
+ });
476
+ }
477
+ }
478
+
433
479
  for (const scope of REPOSITORY_SCOPES) {
434
480
  const file = configPathForScope(scope, options);
435
481
  let config;
@@ -77,6 +77,17 @@ export function attributeDirectTool(toolName, serverNames) {
77
77
  return best;
78
78
  }
79
79
 
80
+ /**
81
+ * Whether a global or per-server direct-tool selection registers at least one
82
+ * direct tool. The adapter accepts `true` or a non-empty list on server entries.
83
+ * @param {unknown} value
84
+ * @returns {boolean}
85
+ */
86
+ export function directToolsEnabled(value) {
87
+ return value === true
88
+ || (Array.isArray(value) && value.some((item) => typeof item === "string" && item.trim().length > 0));
89
+ }
90
+
80
91
  /**
81
92
  * Whether a config's settings put tools on the model under names that cannot be
82
93
  * traced to a server. Both halves are required: without `directTools` every call
@@ -86,5 +97,5 @@ export function attributeDirectTool(toolName, serverNames) {
86
97
  */
87
98
  export function erasesToolOrigin(settings) {
88
99
  if (!settings || typeof settings !== "object") return false;
89
- return settings.directTools === true && settings.toolPrefix === "none";
100
+ return directToolsEnabled(settings.directTools) && settings.toolPrefix === "none";
90
101
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.2.9",
3
+ "version": "1.2.10",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: piagent-reviewer
3
3
  description: Piagent reviewer for implementation diff, policy, tests, and scope drift
4
- tools: read, grep, find, ls, bash, edit, write, contact_supervisor
4
+ tools: read, grep, find, ls
5
5
  thinking: high
6
6
  systemPromptMode: replace
7
7
  inheritProjectContext: true
@@ -16,7 +16,7 @@ maxSubagentDepth: 0
16
16
 
17
17
  You are `piagent-reviewer`, a disciplined review subagent for Pi Agent Platform projects.
18
18
 
19
- Your job is to review evidence, not to invent issues. Make small corrective edits only when the parent explicitly asks for autofix.
19
+ Your job is to review evidence, not to invent issues. Never edit files or run mutation commands. Report actionable findings so the parent can assign fixes to an explicit writer.
20
20
 
21
21
  Use explicit review lenses from the parent task when provided. If no lenses are provided, cover correctness, tests/verification, and scope drift first; add security/release/package only when relevant to the change.
22
22
 
@@ -33,7 +33,7 @@ Rules:
33
33
  - Do not run destructive commands.
34
34
  - Treat Field Guide/memory as advisory, not as proof.
35
35
  - If review-only conflicts with progress-writing or artifact-writing instructions, review-only wins.
36
- - If a blocker needs a decision, use `contact_supervisor` with `reason: "need_decision"`.
36
+ - Put blockers that need a decision in the final review so the parent can route them.
37
37
  - Do not spawn other subagents.
38
38
 
39
39
  Final output: