@piagent/platform 1.2.0 → 1.2.2

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 (64) hide show
  1. package/CHANGELOG.md +149 -1
  2. package/README.md +1 -1
  3. package/catalog/capabilities.json +4 -4
  4. package/docs/command-reference-vietnamese.md +15 -4
  5. package/docs/context-window-policy.md +1 -0
  6. package/docs/distribution-standard.md +7 -7
  7. package/docs/mcp-and-tools.md +49 -3
  8. package/docs/operator-manual-vietnamese.md +52 -11
  9. package/docs/publishing-for-teams.md +2 -0
  10. package/docs/quickstart-vietnamese.md +4 -2
  11. package/docs/release-install-policy.md +6 -6
  12. package/docs/security-threat-model.md +4 -3
  13. package/docs/team-onboarding.md +3 -3
  14. package/docs/usage-observability.md +49 -0
  15. package/docs-site/architecture.html +1 -1
  16. package/docs-site/commands.html +1 -1
  17. package/docs-site/content/mcp-approval.html +54 -2
  18. package/docs-site/content/quickstart.html +1 -1
  19. package/docs-site/index.html +1 -1
  20. package/docs-site/mcp-approval.html +55 -3
  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 +2 -2
  27. package/docs-site/runtime.html +1 -1
  28. package/docs-site/scope.html +1 -1
  29. package/docs-site/security.html +1 -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 +2 -0
  34. package/packages/piagent-core/capabilities/capability-core.js +41 -4
  35. package/packages/piagent-core/capabilities/capability-sources.js +73 -12
  36. package/packages/piagent-core/extensions/piagent-guard.ts +539 -70
  37. package/packages/piagent-core/extensions/policy-core.js +1586 -98
  38. package/packages/piagent-core/mcp/mcp-approval-store.js +56 -15
  39. package/packages/piagent-core/mcp/mcp-auth-readiness.js +31 -3
  40. package/packages/piagent-core/mcp/mcp-command-actions.js +139 -30
  41. package/packages/piagent-core/mcp/mcp-config-layers.js +339 -6
  42. package/packages/piagent-core/mcp/mcp-import-kinds.js +240 -0
  43. package/packages/piagent-core/mcp/mcp-server-entry.js +51 -0
  44. package/packages/piagent-core/mcp/mcp-session-view.js +55 -4
  45. package/packages/piagent-core/mcp/mcp-tool-naming.js +90 -0
  46. package/packages/piagent-core/package.json +1 -1
  47. package/packages/piagent-core/prompts/be-to-fe.md +1 -0
  48. package/packages/piagent-core/prompts/piagent-commands.md +1 -0
  49. package/packages/piagent-core/prompts/platform-improve.md +1 -0
  50. package/packages/piagent-core/prompts/review.md +1 -0
  51. package/packages/piagent-core/prompts/scout.md +1 -0
  52. package/packages/piagent-core/prompts/task.md +1 -0
  53. package/scripts/configure-model-scope.sh +19 -3
  54. package/scripts/configure-subagents.sh +17 -4
  55. package/scripts/init-project.sh +15 -8
  56. package/scripts/mcp-manage.mjs +111 -22
  57. package/scripts/pi-model-catalog.sh +11 -1
  58. package/scripts/pi-session-stats.sh +27 -1
  59. package/scripts/pi-usage-history.mjs +567 -0
  60. package/scripts/quality-benchmark.sh +37 -9
  61. package/scripts/setup.sh +21 -13
  62. package/scripts/uninstall-global.sh +10 -4
  63. package/scripts/verify-local.sh +19 -0
  64. package/scripts/verify-release-identity.mjs +29 -0
@@ -238,8 +238,13 @@ export function validateCapabilityPack(document, options = {}) {
238
238
 
239
239
  if (rejectUnknownKeys(spec.activation, new Set(["mode", "profiles", "triggers"]), `${source}.spec.activation`, errors)) {
240
240
  if (!ACTIVATION_MODES.has(spec.activation.mode)) pushError(errors, `${source}.spec.activation.mode`, "must be explicit, profile, or trigger");
241
- validateStringArray(spec.activation.profiles ?? [], `${source}.spec.activation.profiles`, errors, { maxItems: 64, pattern: NAME_PATTERN });
241
+ const profiles = validateStringArray(spec.activation.profiles ?? [], `${source}.spec.activation.profiles`, errors, { maxItems: 64, pattern: NAME_PATTERN });
242
242
  const triggers = validateStringArray(spec.activation.triggers ?? [], `${source}.spec.activation.triggers`, errors, { maxItems: 64 });
243
+ // Both modes name the thing that switches them on, so both are checked for
244
+ // it. Only `trigger` was, which let a pack declare profile activation and
245
+ // list no profiles: resolution then read `.includes` off an absent array
246
+ // and died with a TypeError where it should have refused with a reason.
247
+ if (spec.activation.mode === "profile" && profiles.length === 0) pushError(errors, `${source}.spec.activation.profiles`, "must not be empty for profile activation");
243
248
  if (spec.activation.mode === "trigger" && triggers.length === 0) pushError(errors, `${source}.spec.activation.triggers`, "must not be empty for trigger activation");
244
249
  }
245
250
 
@@ -859,7 +864,10 @@ export function resolveCapabilityProfileDocument(root, profile, options = {}) {
859
864
  const key = `${selector.name}@${selector.version}`;
860
865
  const directPack = byKey.get(key);
861
866
  if (!directPack) throw new CapabilityValidationError(`profile selects unknown capability pack ${key}`);
862
- if (directPack.activation.mode === "profile" && !directPack.activation.profiles.includes(profile.mode)) throw new CapabilityValidationError(`${key} is not enabled for profile mode ${profile.mode}`);
867
+ // Defaulted rather than assumed present. The validator now requires the list
868
+ // for this mode, but this line runs against whatever is on disk, and reading
869
+ // through to a TypeError would turn a refusal into a crash.
870
+ if (directPack.activation.mode === "profile" && !(directPack.activation.profiles ?? []).includes(profile.mode)) throw new CapabilityValidationError(`${key} is not enabled for profile mode ${profile.mode}`);
863
871
  select(key);
864
872
  }
865
873
 
@@ -995,22 +1003,51 @@ export function resolveCapabilityProfile(root, profilePath, options = {}) {
995
1003
  const GRANTING_PERMISSIONS = ["capabilities", "filesystemRead", "filesystemWrite", "networkDomains", "externalActions"];
996
1004
  const RESTRICTING_PERMISSIONS = ["protectedPaths", "readOnlyPaths", "shellProtectedPaths"];
997
1005
 
1006
+ // Everything above is about artifacts the platform ships. A pack vendored from
1007
+ // somewhere else is a different question, and gets a different answer.
1008
+ //
1009
+ // None of the reasons for leaving artifact content on the build side apply to
1010
+ // it. Its bytes change when that source decides they change, not when this
1011
+ // project updates. The manifest digest pins which artifacts a pack declares, not
1012
+ // what is in them, so a rewritten prompt or policy file keeps the same manifest.
1013
+ // And the argument that the platform can rewrite its own verifier is an argument
1014
+ // about the platform: an external source cannot, which is exactly why its
1015
+ // content is worth pinning. Prompts and policy files are what a hostile source
1016
+ // would edit, and it would rather not be asked again.
1017
+ //
1018
+ // `workspace` is the platform's own origin and is a reserved source name, so a
1019
+ // vendored source cannot claim it.
1020
+ function externalArtifacts(document) {
1021
+ const external = new Set(
1022
+ (document?.packs ?? [])
1023
+ .filter((pack) => pack?.origin && pack.origin !== "workspace")
1024
+ .map((pack) => `${pack.name}@${pack.version}`)
1025
+ );
1026
+ if (external.size === 0) return undefined;
1027
+ return (document?.artifacts ?? []).filter((artifact) => external.has(artifact?.pack));
1028
+ }
1029
+
998
1030
  function consentedShape(document) {
999
1031
  return {
1000
1032
  schemaVersion: document?.schemaVersion,
1001
1033
  apiVersion: document?.core?.apiVersion,
1002
1034
  packageSource: document?.core?.packageSource,
1003
1035
  profile: document?.profile,
1004
- packs: document?.packs
1036
+ packs: document?.packs,
1037
+ externalArtifacts: externalArtifacts(document)
1005
1038
  };
1006
1039
  }
1007
1040
 
1041
+ const CONSENT_DRIFT_REASONS = {
1042
+ externalArtifacts: "an artifact shipped by a vendored capability source changed content"
1043
+ };
1044
+
1008
1045
  function consentDrift(expected, actual) {
1009
1046
  const before = consentedShape(actual);
1010
1047
  const after = consentedShape(expected);
1011
1048
  return Object.keys(after)
1012
1049
  .filter((key) => stableJson(after[key]) !== stableJson(before[key]))
1013
- .map((key) => `${key} no longer matches the locked value`);
1050
+ .map((key) => CONSENT_DRIFT_REASONS[key] ?? `${key} no longer matches the locked value`);
1014
1051
  }
1015
1052
 
1016
1053
  function permissionDrift(expected, actual) {
@@ -110,6 +110,39 @@ function assertContainedDirectory(projectRoot, target, label) {
110
110
  return targetReal;
111
111
  }
112
112
 
113
+ /**
114
+ * Verify a path this code is about to create, empty or write sits inside the
115
+ * project, without requiring it to exist yet.
116
+ *
117
+ * The read path can lstat its target and be done with it. A write path cannot:
118
+ * the destination is normally absent, and the failure that matters is an
119
+ * *ancestor* leading somewhere else. A repository shipping `.pi` as a symlink is
120
+ * the whole attack — `mkdir -p` follows it, the directory that appears below is
121
+ * a real directory, so a check on the immediate parent sees nothing wrong, and
122
+ * the recursive delete that comes next lands outside the project.
123
+ *
124
+ * @param {string} rootReal a project root already through realpath
125
+ * @param {string} target
126
+ * @param {string} label
127
+ */
128
+ function assertContainedWritePath(rootReal, target, label) {
129
+ const relative = path.relative(rootReal, path.resolve(rootReal, target));
130
+ if (relative.startsWith("..") || path.isAbsolute(relative)) fail(`${label} resolves outside the project`);
131
+ let current = rootReal;
132
+ for (const segment of relative.split(path.sep)) {
133
+ if (!segment) continue;
134
+ current = path.join(current, segment);
135
+ let stat;
136
+ try {
137
+ stat = fs.lstatSync(current);
138
+ } catch {
139
+ // Absent from here down, so there is nothing further to follow.
140
+ return;
141
+ }
142
+ if (stat.isSymbolicLink()) fail(`${label} must not resolve through a symbolic link (${segment})`);
143
+ }
144
+ }
145
+
113
146
  function assertNoSymbolicLinks(root, label, budget = { remaining: MAX_VENDOR_ENTRIES }) {
114
147
  for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
115
148
  if (budget.remaining-- <= 0) fail(`${label} contains more than ${MAX_VENDOR_ENTRIES} entries`);
@@ -212,27 +245,55 @@ export function vendorRemoteSource(projectRoot, entry) {
212
245
  const [validated] = validateCapabilitySources([entry]);
213
246
  if (!validated.source) fail(`capability source ${validated.name} is a local path and does not need vendoring`);
214
247
 
215
- const destination = vendorDirectoryFor(projectRoot, validated.name);
248
+ // Resolved the same way the read path resolves it, so the containment check
249
+ // below compares two paths of the same form.
250
+ const rootReal = fs.realpathSync(projectRoot);
251
+ const destination = vendorDirectoryFor(rootReal, validated.name);
252
+ const label = `capability source ${validated.name}`;
253
+
254
+ // Before anything is created and well before anything is removed: every call
255
+ // after this line writes or deletes, and the point of the check is to have
256
+ // happened first.
257
+ assertContainedWritePath(rootReal, destination, label);
216
258
  const parent = path.dirname(destination);
217
259
  fs.mkdirSync(parent, { recursive: true, mode: 0o755 });
218
- if (fs.lstatSync(parent).isSymbolicLink()) fail(`${parent} must not be a symbolic link`);
219
- fs.rmSync(destination, { recursive: true, force: true });
220
- fs.mkdirSync(destination, { mode: 0o755 });
260
+ // Re-checked after the directories exist. The first pass ran against a path
261
+ // that was mostly absent, so it proved nothing about the components this call
262
+ // just created or about one swapped underneath in between.
263
+ assertContainedWritePath(rootReal, destination, label);
264
+
265
+ // Fetched beside the destination and moved into place at the end, so a fetch
266
+ // that fails leaves the reviewed tree that was already there. Emptying the
267
+ // destination first meant a network error or a hostile archive destroyed a
268
+ // working vendor directory and left the project with neither version.
269
+ const staging = `${destination}.incoming.${process.pid}`;
270
+ assertContainedWritePath(rootReal, staging, label);
271
+ for (const sibling of fs.readdirSync(parent)) {
272
+ // Debris from a run that died between creating this and moving it.
273
+ if (sibling.startsWith(`${validated.name}.incoming.`)) fs.rmSync(path.join(parent, sibling), { recursive: true, force: true });
274
+ }
275
+ fs.mkdirSync(staging, { mode: 0o755 });
221
276
 
222
277
  try {
223
- if (validated.source.startsWith("npm:")) fetchNpmSource(validated.source.slice("npm:".length), destination);
224
- else fetchGitSource(validated.source, destination);
278
+ if (validated.source.startsWith("npm:")) fetchNpmSource(validated.source.slice("npm:".length), staging);
279
+ else fetchGitSource(validated.source, staging);
225
280
 
226
281
  // The fetched tree is untrusted until this passes. Checking here means a
227
- // hostile archive is rejected before anything reads a manifest out of it.
228
- assertNoSymbolicLinks(destination, `capability source ${validated.name}`);
229
- if (!fs.existsSync(path.join(destination, "packs"))) {
282
+ // hostile archive is rejected before anything reads a manifest out of it,
283
+ // and before it can replace the tree already in place.
284
+ assertNoSymbolicLinks(staging, `capability source ${validated.name}`);
285
+ if (!fs.existsSync(path.join(staging, "packs"))) {
230
286
  fail(`capability source ${validated.name} provides no packs directory`);
231
287
  }
232
- } catch (error) {
233
- // A half-written vendor directory would resolve on the next run and hide
234
- // the failure, so remove it.
288
+
289
+ // Everything above succeeded, so the old tree can go.
235
290
  fs.rmSync(destination, { recursive: true, force: true });
291
+ fs.renameSync(staging, destination);
292
+ } catch (error) {
293
+ // Only the half-written fetch is removed. The destination is either
294
+ // untouched, because the failure happened before the swap, or already
295
+ // replaced by a tree that passed every check.
296
+ fs.rmSync(staging, { recursive: true, force: true });
236
297
  throw error;
237
298
  }
238
299