@piagent/platform 1.3.0 → 1.3.1

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 (52) hide show
  1. package/CHANGELOG.md +17 -3
  2. package/README.md +1 -1
  3. package/docs/capability-packs.md +9 -2
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +7 -7
  6. package/docs/operator-manual-vietnamese.md +7 -7
  7. package/docs/release-install-policy.md +10 -7
  8. package/docs/security-threat-model.md +1 -1
  9. package/docs/team-onboarding.md +2 -2
  10. package/docs-site/architecture.html +1 -1
  11. package/docs-site/benchmark.html +1 -1
  12. package/docs-site/commands.html +1 -1
  13. package/docs-site/context-engine.html +1 -1
  14. package/docs-site/en/architecture.html +1 -1
  15. package/docs-site/en/benchmark.html +1 -1
  16. package/docs-site/en/commands.html +1 -1
  17. package/docs-site/en/context-engine.html +1 -1
  18. package/docs-site/en/index.html +1 -1
  19. package/docs-site/en/mcp-approval.html +1 -1
  20. package/docs-site/en/mcp-auth.html +1 -1
  21. package/docs-site/en/mcp-servers.html +1 -1
  22. package/docs-site/en/mcp.html +1 -1
  23. package/docs-site/en/permissions.html +1 -1
  24. package/docs-site/en/profiles.html +1 -1
  25. package/docs-site/en/quickstart.html +1 -1
  26. package/docs-site/en/runtime.html +1 -1
  27. package/docs-site/en/scope.html +1 -1
  28. package/docs-site/en/security.html +1 -1
  29. package/docs-site/en/team.html +1 -1
  30. package/docs-site/en/whats-new.html +1 -1
  31. package/docs-site/en/workflows.html +1 -1
  32. package/docs-site/index.html +1 -1
  33. package/docs-site/mcp-approval.html +1 -1
  34. package/docs-site/mcp-auth.html +1 -1
  35. package/docs-site/mcp-servers.html +1 -1
  36. package/docs-site/mcp.html +1 -1
  37. package/docs-site/permissions.html +1 -1
  38. package/docs-site/profiles.html +1 -1
  39. package/docs-site/quickstart.html +1 -1
  40. package/docs-site/runtime.html +1 -1
  41. package/docs-site/scope.html +1 -1
  42. package/docs-site/security.html +1 -1
  43. package/docs-site/team.html +1 -1
  44. package/docs-site/whats-new.html +1 -1
  45. package/docs-site/workflows.html +1 -1
  46. package/package.json +1 -1
  47. package/packages/piagent-core/capabilities/capability-verification-cache.js +280 -0
  48. package/packages/piagent-core/capabilities/runtime-integrity.js +10 -1
  49. package/packages/piagent-core/extensions/document-intake.ts +24 -10
  50. package/packages/piagent-core/extensions/piagent-guard.ts +28 -36
  51. package/packages/piagent-core/extensions/state-retention.js +77 -44
  52. package/packages/piagent-core/package.json +1 -1
@@ -30,7 +30,7 @@
30
30
  <span>Pi Agent Platform</span>
31
31
  </a>
32
32
  <div class="top-actions">
33
- <span class="pill"><span class="pill-dot"></span>v1.3.0 docs · VI</span>
33
+ <span class="pill"><span class="pill-dot"></span>v1.3.1 docs · VI</span>
34
34
  <nav class="language-switch" aria-label="Ngôn ngữ">
35
35
  <a class="language-option current" href="/runtime" hreflang="vi" lang="vi" aria-current="true">VI</a>
36
36
  <a class="language-option" href="/en/runtime" hreflang="en" lang="en">EN</a>
@@ -30,7 +30,7 @@
30
30
  <span>Pi Agent Platform</span>
31
31
  </a>
32
32
  <div class="top-actions">
33
- <span class="pill"><span class="pill-dot"></span>v1.3.0 docs · VI</span>
33
+ <span class="pill"><span class="pill-dot"></span>v1.3.1 docs · VI</span>
34
34
  <nav class="language-switch" aria-label="Ngôn ngữ">
35
35
  <a class="language-option current" href="/scope" hreflang="vi" lang="vi" aria-current="true">VI</a>
36
36
  <a class="language-option" href="/en/scope" hreflang="en" lang="en">EN</a>
@@ -30,7 +30,7 @@
30
30
  <span>Pi Agent Platform</span>
31
31
  </a>
32
32
  <div class="top-actions">
33
- <span class="pill"><span class="pill-dot"></span>v1.3.0 docs · VI</span>
33
+ <span class="pill"><span class="pill-dot"></span>v1.3.1 docs · VI</span>
34
34
  <nav class="language-switch" aria-label="Ngôn ngữ">
35
35
  <a class="language-option current" href="/security" hreflang="vi" lang="vi" aria-current="true">VI</a>
36
36
  <a class="language-option" href="/en/security" hreflang="en" lang="en">EN</a>
@@ -30,7 +30,7 @@
30
30
  <span>Pi Agent Platform</span>
31
31
  </a>
32
32
  <div class="top-actions">
33
- <span class="pill"><span class="pill-dot"></span>v1.3.0 docs · VI</span>
33
+ <span class="pill"><span class="pill-dot"></span>v1.3.1 docs · VI</span>
34
34
  <nav class="language-switch" aria-label="Ngôn ngữ">
35
35
  <a class="language-option current" href="/team" hreflang="vi" lang="vi" aria-current="true">VI</a>
36
36
  <a class="language-option" href="/en/team" hreflang="en" lang="en">EN</a>
@@ -30,7 +30,7 @@
30
30
  <span>Pi Agent Platform</span>
31
31
  </a>
32
32
  <div class="top-actions">
33
- <span class="pill"><span class="pill-dot"></span>v1.3.0 docs · VI</span>
33
+ <span class="pill"><span class="pill-dot"></span>v1.3.1 docs · VI</span>
34
34
  <nav class="language-switch" aria-label="Ngôn ngữ">
35
35
  <a class="language-option current" href="/whats-new" hreflang="vi" lang="vi" aria-current="true">VI</a>
36
36
  <a class="language-option" href="/en/whats-new" hreflang="en" lang="en">EN</a>
@@ -30,7 +30,7 @@
30
30
  <span>Pi Agent Platform</span>
31
31
  </a>
32
32
  <div class="top-actions">
33
- <span class="pill"><span class="pill-dot"></span>v1.3.0 docs · VI</span>
33
+ <span class="pill"><span class="pill-dot"></span>v1.3.1 docs · VI</span>
34
34
  <nav class="language-switch" aria-label="Ngôn ngữ">
35
35
  <a class="language-option current" href="/workflows" hreflang="vi" lang="vi" aria-current="true">VI</a>
36
36
  <a class="language-option" href="/en/workflows" hreflang="en" lang="en">EN</a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/platform",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -0,0 +1,280 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { sha256, stableJson, verifyCapabilityLock } from "./capability-core.js";
5
+ import { resolveProjectProfileDocument } from "./project-profile.js";
6
+
7
+ const compare = (left, right) => String(left).localeCompare(String(right), "en");
8
+ const MAX_PACK_ENTRIES = 256;
9
+ const MAX_ARTIFACTS_PER_KIND = 128;
10
+ const MAX_JSON_BYTES = 256 * 1024;
11
+
12
+ // Full capability resolution deliberately hashes every executable runtime file.
13
+ // That is the right cold-path check, but not useful per-call work. This cache
14
+ // remembers only a `current` full verification. Reuse still walks strong
15
+ // metadata for every input that produced it; any change or error falls back to
16
+ // the cryptographic verifier.
17
+ export function createCapabilityVerificationCache() {
18
+ return new Map();
19
+ }
20
+
21
+ function cacheRootForPack(rootReal, pack, extraRoots) {
22
+ return pack.origin === "workspace"
23
+ ? rootReal
24
+ : extraRoots.find((entry) => entry.origin === pack.origin)?.root;
25
+ }
26
+
27
+ function addTarget(targets, target, kind, optional = false) {
28
+ const absolute = path.resolve(target);
29
+ const previous = targets.get(absolute);
30
+ if (!previous || previous.kind !== "directory") targets.set(absolute, { path: absolute, kind, optional });
31
+ }
32
+
33
+ function containedTarget(root, relativePath) {
34
+ if (typeof relativePath !== "string" || path.isAbsolute(relativePath)) return undefined;
35
+ const absolute = path.resolve(root, relativePath);
36
+ const relative = path.relative(root, absolute);
37
+ return relative.startsWith("..") || path.isAbsolute(relative) ? undefined : absolute;
38
+ }
39
+
40
+ function readBoundedJson(file) {
41
+ const stat = fs.lstatSync(file);
42
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_JSON_BYTES) return undefined;
43
+ try {
44
+ return JSON.parse(fs.readFileSync(file, "utf8"));
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ }
49
+
50
+ function addEvalInputs(targets, packRoot, artifactPath) {
51
+ const artifact = readBoundedJson(artifactPath);
52
+ const profile = containedTarget(packRoot, artifact?.spec?.profile);
53
+ const fixture = containedTarget(packRoot, artifact?.spec?.fixture?.path);
54
+ if (!profile || !fixture) return false;
55
+ addTarget(targets, profile, "file");
56
+ addTarget(targets, fixture, "file");
57
+ return true;
58
+ }
59
+
60
+ function addCatalogTargets(targets, packRoot) {
61
+ const packsDirectory = path.join(packRoot, "packs");
62
+ addTarget(targets, packsDirectory, "directory");
63
+ const entries = fs.readdirSync(packsDirectory, { withFileTypes: true })
64
+ .filter((entry) => !entry.name.startsWith("."));
65
+ if (entries.length > MAX_PACK_ENTRIES) return false;
66
+ for (const entry of entries) {
67
+ if (!entry.isDirectory() || entry.isSymbolicLink?.()) return false;
68
+ const packDirectory = path.join(packsDirectory, entry.name);
69
+ addTarget(targets, packDirectory, "directory");
70
+ const manifestPath = path.join(packDirectory, "pack.json");
71
+ addTarget(targets, manifestPath, "file");
72
+ const manifest = readBoundedJson(manifestPath);
73
+ const provides = manifest?.spec?.provides;
74
+ if (!provides || typeof provides !== "object" || Array.isArray(provides)) return false;
75
+ for (const [kind, artifacts] of Object.entries(provides)) {
76
+ if (!Array.isArray(artifacts) || artifacts.length > MAX_ARTIFACTS_PER_KIND) return false;
77
+ for (const artifact of artifacts) {
78
+ const target = containedTarget(packRoot, artifact?.path);
79
+ if (!target) return false;
80
+ addTarget(targets, target, "file");
81
+ if (kind === "evals" && !addEvalInputs(targets, packRoot, target)) return false;
82
+ }
83
+ }
84
+ }
85
+ return true;
86
+ }
87
+
88
+ function verificationTargets(root, profilePath, lockPath, document, options = {}) {
89
+ const rootReal = fs.realpathSync(root);
90
+ const targets = new Map();
91
+ addTarget(targets, profilePath, "file");
92
+ addTarget(targets, lockPath, "file");
93
+ for (const target of options.identityFiles ?? []) addTarget(targets, target, "file", true);
94
+
95
+ const extraRoots = options.extraRoots ?? [];
96
+ const packRoots = new Map();
97
+ for (const pack of document?.packs ?? []) {
98
+ const packRoot = cacheRootForPack(rootReal, pack, extraRoots);
99
+ if (!packRoot) continue;
100
+ packRoots.set(`${pack.name}@${pack.version}`, packRoot);
101
+ addTarget(targets, path.join(packRoot, "packs"), "directory");
102
+ addTarget(targets, path.join(packRoot, "packs", pack.name), "directory");
103
+ addTarget(targets, path.join(packRoot, "packs", pack.name, "pack.json"), "file");
104
+ }
105
+ for (const entry of extraRoots) {
106
+ addTarget(targets, entry.root, "directory");
107
+ addTarget(targets, path.join(entry.root, "packs"), "directory");
108
+ }
109
+ for (const packRoot of [rootReal, ...extraRoots.map((entry) => entry.root)]) {
110
+ if (!addCatalogTargets(targets, packRoot)) return undefined;
111
+ }
112
+ for (const entry of document?.core?.runtimeFiles ?? []) {
113
+ const target = containedTarget(rootReal, entry.path);
114
+ if (!target) return undefined;
115
+ addTarget(targets, target, "file");
116
+ }
117
+ for (const artifact of document?.artifacts ?? []) {
118
+ const artifactRoot = packRoots.get(artifact.pack);
119
+ if (!artifactRoot) continue;
120
+ const target = containedTarget(artifactRoot, artifact.path);
121
+ if (!target) return undefined;
122
+ addTarget(targets, target, "file");
123
+ }
124
+ return [...targets.values()].sort((left, right) => compare(left.path, right.path));
125
+ }
126
+
127
+ function statIdentity(stat, includeTimes) {
128
+ const values = [stat.dev, stat.ino, stat.mode, stat.nlink, stat.uid, stat.gid, stat.rdev, stat.size];
129
+ if (includeTimes) values.push(stat.mtimeNs, stat.ctimeNs);
130
+ return values.map(String).join(":");
131
+ }
132
+
133
+ function ancestorIdentity(stat) {
134
+ return [stat.dev, stat.ino, stat.mode, stat.uid, stat.gid, stat.rdev]
135
+ .map(String)
136
+ .join(":");
137
+ }
138
+
139
+ function lexicalAncestors(target) {
140
+ const parsed = path.parse(target);
141
+ const segments = target.slice(parsed.root.length).split(path.sep).filter(Boolean);
142
+ const ancestors = [];
143
+ let current = parsed.root;
144
+ for (const segment of segments.slice(0, -1)) {
145
+ current = path.join(current, segment);
146
+ ancestors.push(current);
147
+ }
148
+ return ancestors;
149
+ }
150
+
151
+ function metadataSignature(targets) {
152
+ if (!targets) return undefined;
153
+ const records = [];
154
+ const ancestors = new Set(targets.flatMap((target) => lexicalAncestors(target.path)));
155
+ try {
156
+ for (const ancestor of [...ancestors].sort(compare)) {
157
+ const stat = fs.lstatSync(ancestor, { bigint: true });
158
+ const symbolic = stat.isSymbolicLink();
159
+ const identity = symbolic ? statIdentity(stat, true) : ancestorIdentity(stat);
160
+ records.push(`a:${ancestor}:${identity}:${symbolic ? fs.readlinkSync(ancestor) : "directory"}`);
161
+ }
162
+ for (const target of targets) {
163
+ let lexical;
164
+ try {
165
+ lexical = fs.lstatSync(target.path, { bigint: true });
166
+ } catch (error) {
167
+ if (target.optional && error?.code === "ENOENT") {
168
+ records.push(`t:${target.path}:absent`);
169
+ continue;
170
+ }
171
+ return undefined;
172
+ }
173
+ const expectedType = target.kind === "directory" ? lexical.isDirectory() : lexical.isFile();
174
+ if (!expectedType || lexical.isSymbolicLink()) return undefined;
175
+ records.push(`t:${target.path}:${statIdentity(lexical, true)}`);
176
+ }
177
+ } catch {
178
+ return undefined;
179
+ }
180
+ return sha256(records.join("\n"));
181
+ }
182
+
183
+ const cacheKey = (root, profilePath, lockPath) => (
184
+ `${path.resolve(root)}\0${path.resolve(profilePath)}\0${path.resolve(lockPath)}`
185
+ );
186
+
187
+ function cacheIdentity(lockDocument, options = {}) {
188
+ return sha256(stableJson({
189
+ lockDocument,
190
+ packageSource: options.packageSource,
191
+ extraRoots: (options.extraRoots ?? []).map((entry) => ({ origin: entry.origin, root: entry.root, source: entry.source })),
192
+ identityFiles: (options.identityFiles ?? []).map((entry) => path.resolve(entry)).sort(compare)
193
+ }));
194
+ }
195
+
196
+ function snapshotTargets(targets) {
197
+ const signature = metadataSignature(targets);
198
+ return signature ? { signature, targets } : undefined;
199
+ }
200
+
201
+ function stableSnapshot(snapshot) {
202
+ if (!snapshot) return false;
203
+ const current = metadataSignature(snapshot.targets);
204
+ return Boolean(current && current === snapshot.signature);
205
+ }
206
+
207
+ export function verifyCapabilityLockCached(cache, root, profilePath, lockPath, lockDocument, options = {}) {
208
+ const key = cacheKey(root, profilePath, lockPath);
209
+ const identity = cacheIdentity(lockDocument, options);
210
+ const cached = options.forceFull === true ? undefined : cache?.get(key);
211
+ if (cached?.identity === identity) {
212
+ const signature = metadataSignature(cached.targets);
213
+ if (signature && signature === cached.signature) return { ...cached.verification, cacheStatus: "hit" };
214
+ }
215
+ cache?.delete(key);
216
+
217
+ const initialTargets = verificationTargets(root, profilePath, lockPath, lockDocument, options);
218
+ const before = snapshotTargets(initialTargets);
219
+ const verification = verifyCapabilityLock(root, profilePath, lockDocument, options);
220
+ options.afterFullVerify?.();
221
+ if (verification.status === "current" && before) {
222
+ const after = snapshotTargets(verificationTargets(root, profilePath, lockPath, verification.expected, options));
223
+ if (after && before.signature === after.signature && stableSnapshot(after)) {
224
+ cache?.set(key, { identity, ...after, verification });
225
+ }
226
+ }
227
+ return { ...verification, cacheStatus: "verified" };
228
+ }
229
+
230
+ export function verifyProjectCapabilityStateCached(input) {
231
+ const { cache, cwd, platformRoot, profilePath, lockPath, lockDocument, storedProfile } = input;
232
+ const profile = resolveProjectProfileDocument(platformRoot, storedProfile).profile;
233
+ if (!Array.isArray(profile.capabilityPacks)) return { ok: true };
234
+ const packageSource = input.packageSource(cwd);
235
+ const extraRoots = input.extraRoots(cwd, profile);
236
+ const identityFiles = [path.join(cwd, ".pi", "settings.json")];
237
+ if (typeof storedProfile.extends === "string" && /^[a-z0-9][a-z0-9-]{0,63}$/.test(storedProfile.extends)) {
238
+ identityFiles.push(path.join(platformRoot, "adapters", storedProfile.extends, "profile.json"));
239
+ }
240
+ const verificationOptions = { packageSource, extraRoots, identityFiles, forceFull: input.forceFull === true };
241
+ const verification = verifyCapabilityLockCached(cache, platformRoot, profilePath, lockPath, lockDocument, verificationOptions);
242
+ const runtimeDigest = verification.expected?.core?.packageDigest;
243
+ if (input.allowRepin !== true && input.sessionDigest && runtimeDigest !== input.sessionDigest) {
244
+ return { ok: false, reason: "Installed Piagent runtime changed during this session. Restart the session to verify and re-pin the capability lock before using tools." };
245
+ }
246
+ if (verification.status === "repin" && input.allowRepin !== true) {
247
+ return { ok: false, reason: "Installed Piagent runtime changed during this session. Restart the session to verify and re-pin the capability lock before using tools." };
248
+ }
249
+ if (verification.status === "blocked") {
250
+ return { ok: false, reason: `Capability lock does not match what this project agreed to: ${verification.reasons.join("; ")}. Reapply the project profile.` };
251
+ }
252
+ const granted = {
253
+ filesystemRead: verification.expected.permissions.filesystemRead,
254
+ filesystemWrite: verification.expected.permissions.filesystemWrite
255
+ };
256
+ if (verification.status !== "repin") {
257
+ if (input.allowRepin === true) input.rememberSessionDigest?.(runtimeDigest);
258
+ return { ok: true, ...granted };
259
+ }
260
+
261
+ try {
262
+ input.writeLock(lockPath, verification.expected);
263
+ } catch (error) {
264
+ return { ok: false, reason: `Capability lock is behind the installed platform and could not be rewritten: ${error instanceof Error ? error.message : String(error)}` };
265
+ }
266
+ input.afterRepinWrite?.();
267
+ const confirmed = verifyCapabilityLockCached(
268
+ cache,
269
+ platformRoot,
270
+ profilePath,
271
+ lockPath,
272
+ verification.expected,
273
+ { ...verificationOptions, forceFull: true }
274
+ );
275
+ if (confirmed.status !== "current") {
276
+ return { ok: false, reason: "Installed Piagent runtime changed while the capability lock was being re-pinned. Restart the session and try again." };
277
+ }
278
+ input.rememberSessionDigest?.(runtimeDigest);
279
+ return { ok: true, repinned: verification.reasons.join("; "), ...granted };
280
+ }
@@ -7,17 +7,23 @@ export const CORE_RUNTIME_INTEGRITY_FILES = Object.freeze([
7
7
  "packages/piagent-core/extensions/acceptance-receipt.js",
8
8
  "packages/piagent-core/extensions/criterion-graph.js",
9
9
  "packages/piagent-core/extensions/criterion-context-pack.js",
10
+ "packages/piagent-core/extensions/document-intake.ts",
11
+ "packages/piagent-core/extensions/execution-backend.js",
10
12
  "packages/piagent-core/extensions/failure-types.ts",
11
13
  "packages/piagent-core/extensions/repository-memory.js",
14
+ "packages/piagent-core/extensions/state-retention.js",
12
15
  "packages/piagent-core/extensions/task-contract-view.js",
13
16
  "packages/piagent-core/extensions/task-authority-contract.js",
14
17
  "packages/piagent-core/extensions/task-digest-migration.js",
15
18
  "packages/piagent-core/extensions/task-journal.js",
16
19
  "packages/piagent-core/extensions/task-runtime-audit.js",
20
+ "packages/piagent-core/extensions/task-state.js",
17
21
  "packages/piagent-core/extensions/tenant-contract.js",
22
+ "packages/piagent-core/extensions/verification-intelligence.js",
18
23
  "packages/piagent-core/extensions/working-tree-digest.js",
19
24
  "packages/piagent-core/policy/authority-manifest.v1.json",
20
25
  "packages/piagent-core/capabilities/authority-manifest.ts",
26
+ "packages/piagent-core/capabilities/capability-verification-cache.js",
21
27
  "packages/piagent-core/runtime/hooks/agent-start-hook.ts",
22
28
  "packages/piagent-core/runtime/hooks/completion-hook.ts",
23
29
  "packages/piagent-core/runtime/hooks/input-hook.ts",
@@ -95,5 +101,8 @@ export const CORE_RUNTIME_INTEGRITY_FILES = Object.freeze([
95
101
  "packages/piagent-core/runtime/tools/phase-tools.ts",
96
102
  "packages/piagent-core/runtime/tools/phase-tool-runtime.ts",
97
103
  "packages/piagent-core/runtime/workflows/input-routing.ts",
98
- "packages/piagent-core/runtime/workflows/task-intake.ts"
104
+ "packages/piagent-core/runtime/workflows/task-intake.ts",
105
+ "scripts/piagent-cli.mjs",
106
+ "scripts/register-typescript-loader.mjs",
107
+ "scripts/typescript-loader.mjs"
99
108
  ]);
@@ -51,6 +51,8 @@ export type DocumentExtraction =
51
51
  | { status: "ok"; text: string; truncated: boolean; kind: "text" | "docx" | "pdf" }
52
52
  | { status: "error"; reason: string };
53
53
 
54
+ type DocumentCommandResult = { status: number | null; stdout: string; stderr: string; error?: Error };
55
+
54
56
  export function expandHome(candidate: string, home: string | undefined): string {
55
57
  if (candidate === "~") return home ?? candidate;
56
58
  if (candidate.startsWith("~/")) {
@@ -435,7 +437,7 @@ export function extractTextDocument(buffer: Buffer): DocumentExtraction {
435
437
  // missing tool would be a lie in the direction that wastes the most time.
436
438
  export function extractPdfFromDescriptor(
437
439
  fd: number,
438
- run: (command: string, args: string[], input?: number) => { status: number | null; stdout: string; stderr: string; error?: Error } = defaultRun
440
+ run: (command: string, args: string[], input?: number) => DocumentCommandResult = defaultRun
439
441
  ): DocumentExtraction {
440
442
  const probe = run("command", ["-v", "pdftotext"]);
441
443
  if (probe.error || probe.status !== 0) {
@@ -471,19 +473,31 @@ export function extractPdfFromDescriptor(
471
473
  // returns would hang the session rather than fail the read.
472
474
  const PDF_TIMEOUT_MS = 20_000;
473
475
 
476
+ export function probeExecutableOnPath(executable: string): DocumentCommandResult {
477
+ const result = spawnSync("/bin/sh", ["-c", 'command -v "$1"', "piagent-command-probe", executable], {
478
+ encoding: "utf8",
479
+ timeout: PDF_TIMEOUT_MS
480
+ });
481
+ return {
482
+ status: result.status,
483
+ stdout: result.stdout ?? "",
484
+ stderr: result.stderr ?? "",
485
+ error: result.error
486
+ };
487
+ }
488
+
474
489
  function defaultRun(
475
490
  command: string,
476
491
  args: string[],
477
492
  input?: number
478
- ): { status: number | null; stdout: string; stderr: string; error?: Error } {
479
- const result = command === "command"
480
- ? spawnSync("/bin/sh", ["-c", `command -v ${args[1]}`], { encoding: "utf8", timeout: PDF_TIMEOUT_MS })
481
- : spawnSync(command, args, {
482
- encoding: "utf8",
483
- maxBuffer: MAX_DOCUMENT_BYTES,
484
- timeout: PDF_TIMEOUT_MS,
485
- stdio: [input ?? "ignore", "pipe", "pipe"]
486
- });
493
+ ): DocumentCommandResult {
494
+ if (command === "command") return probeExecutableOnPath(args[1] ?? "");
495
+ const result = spawnSync(command, args, {
496
+ encoding: "utf8",
497
+ maxBuffer: MAX_DOCUMENT_BYTES,
498
+ timeout: PDF_TIMEOUT_MS,
499
+ stdio: [input ?? "ignore", "pipe", "pipe"]
500
+ });
487
501
  return {
488
502
  status: result.status,
489
503
  stdout: result.stdout ?? "",
@@ -49,10 +49,13 @@ import * as mcpActions from "../mcp/mcp-command-actions.js";
49
49
  import { buildExtendingProfile, resolveProjectProfileDocument } from "../capabilities/project-profile.js";
50
50
  import {
51
51
  resolveCapabilityProfileDocument,
52
- verifyCapabilityLock,
53
52
  writeJsonAtomic,
54
53
  writeProfileLockAtomic
55
54
  } from "../capabilities/capability-core.js";
55
+ import {
56
+ createCapabilityVerificationCache,
57
+ verifyProjectCapabilityStateCached
58
+ } from "../capabilities/capability-verification-cache.js";
56
59
  import { resolveCapabilitySourceRoots } from "../capabilities/capability-sources.js";
57
60
  import {
58
61
  actionTextMatchesAny, actionTokens, classifyActionTokenSequence, classifyExplicitActionValues, classifyToolNameAction,
@@ -715,7 +718,15 @@ type ProjectCapabilityState = {
715
718
  filesystemWrite?: string[];
716
719
  };
717
720
 
718
- function verifyProjectCapabilityState(extensionDir: string, cwd: string, projectTrusted: boolean): ProjectCapabilityState {
721
+ const capabilityVerificationCache = createCapabilityVerificationCache();
722
+ const sessionCapabilityDigests = new Map<string, string>();
723
+
724
+ function verifyProjectCapabilityState(
725
+ extensionDir: string,
726
+ cwd: string,
727
+ projectTrusted: boolean,
728
+ options: { allowRepin?: boolean; forceFull?: boolean; sessionId?: string } = {}
729
+ ): ProjectCapabilityState {
719
730
  if (process.env.PIAGENT_PROFILE?.trim()) return { ok: true };
720
731
  if (!projectTrusted) return { ok: true };
721
732
  const profilePath = projectProfilePath(cwd);
@@ -725,46 +736,24 @@ function verifyProjectCapabilityState(extensionDir: string, cwd: string, project
725
736
  // would skip the lock entirely for every project that references an adapter.
726
737
  const stored = readJsonFile<ProjectProfile>(profilePath);
727
738
  if (!stored) return { ok: true };
728
- let profile: ProjectProfile;
729
739
  try {
730
- profile = resolveProjectProfileDocument(PLATFORM_ROOT, stored).profile as ProjectProfile;
740
+ const profile = resolveProjectProfileDocument(findPlatformRoot(extensionDir), stored).profile;
741
+ if (!Array.isArray(profile.capabilityPacks)) return { ok: true };
731
742
  } catch (error) {
732
743
  return { ok: false, reason: error instanceof Error ? error.message : String(error) };
733
744
  }
734
- if (!Array.isArray(profile.capabilityPacks)) return { ok: true };
735
745
  const lockPath = path.join(cwd, ".pi", "piagent-profile.lock.json");
736
746
  if (!fs.existsSync(lockPath)) return { ok: false, reason: "Capability lock is missing. Reapply the project profile." };
737
747
  const lock = readJsonFile<Record<string, unknown>>(lockPath);
738
748
  if (!lock) return { ok: false, reason: "Capability lock is unreadable. Reapply the project profile." };
739
749
  try {
740
- const verification = verifyCapabilityLock(findPlatformRoot(extensionDir), profilePath, lock, {
741
- packageSource: projectPackageSource(cwd),
742
- extraRoots: capabilitySourceRoots(cwd, profile)
750
+ return verifyProjectCapabilityStateCached({
751
+ cache: capabilityVerificationCache, cwd, platformRoot: findPlatformRoot(extensionDir), profilePath, lockPath,
752
+ lockDocument: lock, storedProfile: stored, packageSource: projectPackageSource, extraRoots: capabilitySourceRoots,
753
+ writeLock: writeJsonAtomic, allowRepin: options.allowRepin, forceFull: options.forceFull,
754
+ sessionDigest: options.sessionId ? sessionCapabilityDigests.get(`${cwd}\0${options.sessionId}`) : undefined,
755
+ rememberSessionDigest: options.sessionId ? (digest: string) => sessionCapabilityDigests.set(`${cwd}\0${options.sessionId}`, digest) : undefined
743
756
  });
744
- if (verification.status === "blocked") {
745
- return {
746
- ok: false,
747
- reason: `Capability lock does not match what this project agreed to: ${verification.reasons.join("; ")}. Reapply the project profile.`
748
- };
749
- }
750
- const granted = {
751
- filesystemRead: verification.expected.permissions.filesystemRead,
752
- filesystemWrite: verification.expected.permissions.filesystemWrite
753
- };
754
- if (verification.status !== "repin") return { ok: true, ...granted };
755
-
756
- // The grant is unchanged and only the platform build moved. Record the new
757
- // build instead of stopping the session, which is what turned every release
758
- // into a chore in every project.
759
- try {
760
- writeJsonAtomic(lockPath, verification.expected);
761
- } catch (error) {
762
- return {
763
- ok: false,
764
- reason: `Capability lock is behind the installed platform and could not be rewritten: ${error instanceof Error ? error.message : String(error)}`
765
- };
766
- }
767
- return { ok: true, repinned: verification.reasons.join("; "), ...granted };
768
757
  } catch (error) {
769
758
  return { ok: false, reason: `Capability validation failed: ${error instanceof Error ? error.message : String(error)}` };
770
759
  }
@@ -4273,7 +4262,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
4273
4262
  resolveTaskAny: (cwd, reference) => resolveTaskContract(cwd, reference, undefined) as TaskContract | undefined,
4274
4263
  bindTask: bindSessionTask,
4275
4264
  writeTask,
4276
- capabilityState: (ctx) => verifyProjectCapabilityState(extensionDir, ctx.cwd, ctx.isProjectTrusted()),
4265
+ capabilityState: (ctx) => verifyProjectCapabilityState(extensionDir, ctx.cwd, ctx.isProjectTrusted(), { allowRepin: true, forceFull: true, sessionId: ctx.sessionManager.getSessionId() }),
4277
4266
  permissionProfile: (ctx, profile) => resolvePermissionProfile(profile, policy, permissionOverrideFromContext(ctx)),
4278
4267
  legacyProjectWarning: legacyProjectStateWarning,
4279
4268
  mcpReadinessNotice,
@@ -4311,7 +4300,10 @@ export default function piagentGuard(pi: ExtensionAPI) {
4311
4300
  flushObservedTaskContext,
4312
4301
  onTurnEnd: activityInspector.refresh,
4313
4302
  onAgentSettled: activityInspector.refresh,
4314
- beforeShutdown: activityInspector.dispose
4303
+ beforeShutdown: (ctx) => {
4304
+ activityInspector.dispose(ctx);
4305
+ sessionCapabilityDigests.delete(`${ctx.cwd}\0${ctx.sessionManager.getSessionId()}`);
4306
+ }
4315
4307
  });
4316
4308
 
4317
4309
  registerInputHook(pi, {
@@ -4322,7 +4314,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
4322
4314
  const projectTrusted = ctx.isProjectTrusted();
4323
4315
  const profile = loadProfileFromContext(ctx);
4324
4316
  const permissionProfile = resolvePermissionProfile(profile, policy, permissionOverrideFromContext(ctx));
4325
- const capabilityState = verifyProjectCapabilityState(extensionDir, ctx.cwd, projectTrusted);
4317
+ const capabilityState = verifyProjectCapabilityState(extensionDir, ctx.cwd, projectTrusted, { sessionId: ctx.sessionManager.getSessionId() });
4326
4318
  return {
4327
4319
  roots: resolveDocumentRoots({
4328
4320
  cwd: ctx.cwd,
@@ -4572,7 +4564,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
4572
4564
  const eventInput = isPlainRecord(event.input) ? event.input : {};
4573
4565
  const backendDecision = executionBackendToolDecision(isTaskMutationTool(event.toolName, eventInput));
4574
4566
  if (!backendDecision.allowed) return { block: true, reason: backendDecision.reason };
4575
- const capabilityState = verifyProjectCapabilityState(extensionDir, ctx.cwd, projectTrusted);
4567
+ const capabilityState = verifyProjectCapabilityState(extensionDir, ctx.cwd, projectTrusted, { sessionId: ctx.sessionManager.getSessionId() });
4576
4568
  const recoveryTools = new Set(["piagent_profile_options", "piagent_profile_apply", "piagent_context", "read", "grep", "find", "ls"]);
4577
4569
  if (!capabilityState.ok && !recoveryTools.has(event.toolName)) {
4578
4570
  return { block: true, reason: capabilityState.reason ?? "Capability lock validation failed." };