@piagent/platform 1.4.0 → 1.4.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 (100) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +8 -2
  3. package/README.vi.md +27 -0
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +7 -7
  6. package/docs/en/architecture.md +13 -0
  7. package/docs/languages.json +20 -4
  8. package/docs/mcp-and-tools.md +112 -1
  9. package/docs/operator-manual-vietnamese.md +7 -7
  10. package/docs/publishing-for-teams.md +36 -0
  11. package/docs/release-install-policy.md +21 -8
  12. package/docs/runtime-harness-standard.md +1 -1
  13. package/docs/security-threat-model.md +1 -1
  14. package/docs/task-implementation-contract.md +11 -4
  15. package/docs/team-onboarding.md +2 -2
  16. package/docs/usage-observability.md +70 -0
  17. package/docs/vi/architecture.md +13 -0
  18. package/docs-site/architecture.html +5 -1
  19. package/docs-site/benchmark.html +5 -1
  20. package/docs-site/commands.html +5 -1
  21. package/docs-site/content/en/index.html +49 -0
  22. package/docs-site/content/en/runtime.html +13 -0
  23. package/docs-site/content/index.html +49 -0
  24. package/docs-site/content/runtime.html +28 -0
  25. package/docs-site/context-engine.html +5 -1
  26. package/docs-site/en/architecture.html +5 -1
  27. package/docs-site/en/benchmark.html +5 -1
  28. package/docs-site/en/commands.html +5 -1
  29. package/docs-site/en/context-engine.html +5 -1
  30. package/docs-site/en/index.html +54 -1
  31. package/docs-site/en/mcp-approval.html +5 -1
  32. package/docs-site/en/mcp-auth.html +5 -1
  33. package/docs-site/en/mcp-servers.html +5 -1
  34. package/docs-site/en/mcp.html +5 -1
  35. package/docs-site/en/permissions.html +5 -1
  36. package/docs-site/en/profiles.html +5 -1
  37. package/docs-site/en/quickstart.html +5 -1
  38. package/docs-site/en/runtime.html +18 -1
  39. package/docs-site/en/scope.html +5 -1
  40. package/docs-site/en/security.html +5 -1
  41. package/docs-site/en/team.html +5 -1
  42. package/docs-site/en/whats-new.html +5 -1
  43. package/docs-site/en/workflows.html +5 -1
  44. package/docs-site/index.html +54 -1
  45. package/docs-site/mcp-approval.html +5 -1
  46. package/docs-site/mcp-auth.html +5 -1
  47. package/docs-site/mcp-servers.html +5 -1
  48. package/docs-site/mcp.html +5 -1
  49. package/docs-site/permissions.html +5 -1
  50. package/docs-site/profiles.html +5 -1
  51. package/docs-site/quickstart.html +5 -1
  52. package/docs-site/runtime.html +33 -1
  53. package/docs-site/scope.html +5 -1
  54. package/docs-site/security.html +5 -1
  55. package/docs-site/team.html +5 -1
  56. package/docs-site/whats-new.html +5 -1
  57. package/docs-site/workflows.html +5 -1
  58. package/package.json +14 -2
  59. package/packages/piagent-core/README.md +3 -2
  60. package/packages/piagent-core/capabilities/capability-verification-cache.js +17 -7
  61. package/packages/piagent-core/capabilities/runtime-integrity.js +51 -4
  62. package/packages/piagent-core/extensions/piagent-guard.ts +18 -235
  63. package/packages/piagent-core/extensions/runtime-evidence.js +12 -1
  64. package/packages/piagent-core/extensions/shell-reach.ts +250 -0
  65. package/packages/piagent-core/mcp/mcp-command-actions.js +1 -1
  66. package/packages/piagent-core/mcp/mcp-server-catalog.js +135 -5
  67. package/packages/piagent-core/package.json +1 -1
  68. package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +9 -2
  69. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +25 -2
  70. package/packages/piagent-core/runtime/policy/block-remedy.ts +139 -0
  71. package/packages/piagent-core/runtime/registration/task-start-tool.ts +9 -13
  72. package/packages/piagent-core/runtime/session/token-economics.js +34 -0
  73. package/packages/piagent-core/runtime/tools/phase-tool-runtime.ts +2 -1
  74. package/packages/piagent-core/runtime/workflows/task-intake.ts +5 -0
  75. package/packages/piagent-core/security/sensitive-data.js +7 -0
  76. package/packages/piagent-webui/dist/client/assets/index-bu7XpD9Q.js +172 -0
  77. package/packages/piagent-webui/dist/client/index.html +1 -1
  78. package/packages/piagent-webui/extension/lifecycle-controller.ts +15 -0
  79. package/packages/piagent-webui/extension/piagent-webui.ts +4 -4
  80. package/packages/piagent-webui/extension/same-session-bridge.ts +3 -3
  81. package/packages/piagent-webui/gateway/extension-authority.ts +47 -0
  82. package/packages/piagent-webui/gateway/session-inspection-registry.ts +37 -2
  83. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +5 -1
  84. package/packages/piagent-webui/server/core-inspection-provider.ts +5 -5
  85. package/packages/piagent-webui/server/transcript-projection.ts +15 -0
  86. package/scripts/build-docs-site.mjs +21 -1
  87. package/scripts/explain-command.mjs +240 -0
  88. package/scripts/install-global.sh +104 -3
  89. package/scripts/mcp-manage.mjs +17 -4
  90. package/scripts/pi-session-stats.sh +10 -2
  91. package/scripts/piagent-cli.mjs +14 -1
  92. package/scripts/piagent-dashboard.mjs +55 -2
  93. package/scripts/setup.sh +69 -33
  94. package/scripts/traffic-snapshot.mjs +214 -0
  95. package/scripts/typescript-loader.mjs +20 -4
  96. package/scripts/uninstall-global.sh +53 -2
  97. package/scripts/update-global.mjs +33 -1
  98. package/scripts/verify-local.sh +4 -0
  99. package/scripts/verify-translation-pairing.mjs +121 -0
  100. package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +0 -172
package/package.json CHANGED
@@ -1,19 +1,30 @@
1
1
  {
2
2
  "name": "@piagent/platform",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "provenance": true
8
8
  },
9
9
  "type": "module",
10
- "description": "Reusable Pi agent platform, project adapters, MCP/tool policy, and coding workflow harness.",
10
+ "description": "Guardrails for AI coding agents: blocks secret reads, destructive commands, and unapproved MCP servers before the tool call runs. Ships project profiles, capability locks, a context engine, and task verification.",
11
11
  "homepage": "https://piagent.io.vn",
12
12
  "repository": {
13
13
  "type": "git",
14
14
  "url": "git+https://github.com/Vt-mmm/piagent.git"
15
15
  },
16
16
  "keywords": [
17
+ "ai-agent",
18
+ "coding-agent",
19
+ "agent-guardrails",
20
+ "agent-security",
21
+ "llm-tool-policy",
22
+ "prompt-injection",
23
+ "mcp",
24
+ "mcp-security",
25
+ "secret-redaction",
26
+ "context-engineering",
27
+ "developer-tools",
17
28
  "pi-package",
18
29
  "pi-extension",
19
30
  "pi-skill",
@@ -75,6 +86,7 @@
75
86
  "piagent-import-instructions": "scripts/piagent-cli.mjs",
76
87
  "piagent-auto": "scripts/piagent-cli.mjs",
77
88
  "piagent-context": "scripts/piagent-cli.mjs",
89
+ "piagent-explain": "scripts/piagent-cli.mjs",
78
90
  "piagent-webui": "scripts/piagent-cli.mjs"
79
91
  },
80
92
  "pi": {
@@ -179,8 +179,9 @@ Project-local state belongs in `.pi/.gitignore`.
179
179
  Task files use schema v2 and bind one unique `taskRunId` to one Pi `sessionId`.
180
180
  They record attempt/max-attempt history, dependency-safe work-plan progress,
181
181
  Git baseline/final digests, observed changes, all exact verify results and final
182
- trace. A terminal task is immutable; retry starts in a fresh session and carries
183
- the prior failure/ruled-out evidence. Legacy v1 state is migrated and archived
182
+ trace. A terminal task is immutable; the same conversation may bind a new task
183
+ or retry attempt after the prior task closes, while keeping only one task pending
184
+ at a time and carrying prior failure/ruled-out evidence. Legacy v1 state is migrated and archived
184
185
  without requiring project onboarding again.
185
186
 
186
187
  Local state directories are owner-only. Task state, context telemetry, trace,
@@ -148,12 +148,21 @@ function lexicalAncestors(target) {
148
148
  return ancestors;
149
149
  }
150
150
 
151
- function metadataSignature(targets) {
151
+ // The directories above every target, sorted, derived once per snapshot. The
152
+ // set is a pure function of the target list, and a cached entry's target list
153
+ // cannot change while the entry lives, so deriving it per call was repeating
154
+ // work that could not produce a different answer -- and at 296 pinned files it
155
+ // was the single most expensive part of a cache hit, more than every `lstat`
156
+ // this function performs put together.
157
+ function ancestorPaths(targets) {
158
+ return [...new Set(targets.flatMap((target) => lexicalAncestors(target.path)))].sort(compare);
159
+ }
160
+
161
+ function metadataSignature(targets, ancestors) {
152
162
  if (!targets) return undefined;
153
163
  const records = [];
154
- const ancestors = new Set(targets.flatMap((target) => lexicalAncestors(target.path)));
155
164
  try {
156
- for (const ancestor of [...ancestors].sort(compare)) {
165
+ for (const ancestor of ancestors ?? ancestorPaths(targets)) {
157
166
  const stat = fs.lstatSync(ancestor, { bigint: true });
158
167
  const symbolic = stat.isSymbolicLink();
159
168
  const identity = symbolic ? statIdentity(stat, true) : ancestorIdentity(stat);
@@ -194,13 +203,14 @@ function cacheIdentity(lockDocument, options = {}) {
194
203
  }
195
204
 
196
205
  function snapshotTargets(targets) {
197
- const signature = metadataSignature(targets);
198
- return signature ? { signature, targets } : undefined;
206
+ const ancestors = ancestorPaths(targets);
207
+ const signature = metadataSignature(targets, ancestors);
208
+ return signature ? { signature, targets, ancestors } : undefined;
199
209
  }
200
210
 
201
211
  function stableSnapshot(snapshot) {
202
212
  if (!snapshot) return false;
203
- const current = metadataSignature(snapshot.targets);
213
+ const current = metadataSignature(snapshot.targets, snapshot.ancestors);
204
214
  return Boolean(current && current === snapshot.signature);
205
215
  }
206
216
 
@@ -209,7 +219,7 @@ export function verifyCapabilityLockCached(cache, root, profilePath, lockPath, l
209
219
  const identity = cacheIdentity(lockDocument, options);
210
220
  const cached = options.forceFull === true ? undefined : cache?.get(key);
211
221
  if (cached?.identity === identity) {
212
- const signature = metadataSignature(cached.targets);
222
+ const signature = metadataSignature(cached.targets, cached.ancestors);
213
223
  if (signature && signature === cached.signature) return { ...cached.verification, cacheStatus: "hit" };
214
224
  }
215
225
  cache?.delete(key);
@@ -164,6 +164,11 @@ const EXECUTABLE_SOURCE = /\.(?:[cm]?js|ts|json|sh)$/;
164
164
  const IMPORT_SPECIFIER = /(?:\bimport\s*(?:type\s+)?(?:[^"'`]*?\s+from\s*)?|\bexport\s+(?:type\s+)?[^"'`]*?\s+from\s*)["']([^"']+)["']/g;
165
165
  const DYNAMIC_IMPORT = /\bimport\(\s*["']([^"']+)["']\s*\)/g;
166
166
  const RUNTIME_LITERAL = /["']((?:packages|scripts)\/[A-Za-z0-9_./-]+\.(?:[cm]?js|ts|json|sh))["']/g;
167
+ // A shell entrypoint reaches a sibling program through the directory it computed
168
+ // for itself -- `"$SCRIPT_DIR/pi-usage-history.mjs"` -- so the repository-relative
169
+ // literal the scanner above looks for never appears in the source. `piagent-usage`
170
+ // is a pinned command that ran a 567-line unpinned program this way.
171
+ const SHELL_SIBLING = /\$(?:\{)?(?:SCRIPT_DIR|ROOT|REPO_ROOT|PLATFORM_ROOT)(?:\})?\/([A-Za-z0-9_./-]+\.(?:[cm]?js|ts|json|sh))/g;
167
172
  const MAX_INTEGRITY_FILES = 5_000;
168
173
 
169
174
  function normalizedRelative(root, absolute) {
@@ -172,7 +177,16 @@ function normalizedRelative(root, absolute) {
172
177
  return relative.split(path.sep).join("/");
173
178
  }
174
179
 
175
- function executableFilesBelow(root, relativeDirectory) {
180
+ // A served bundle is more than its scripts: the document names which script runs,
181
+ // so pinning the JavaScript while leaving the HTML free would leave the choice of
182
+ // program unpinned.
183
+ const SERVED_BUNDLE_ASSET = /\.(?:[cm]?js|css|html|json|svg|woff2)$/;
184
+
185
+ function bundleFilesBelow(root, relativeDirectory) {
186
+ return executableFilesBelow(root, relativeDirectory, SERVED_BUNDLE_ASSET);
187
+ }
188
+
189
+ function executableFilesBelow(root, relativeDirectory, accept = EXECUTABLE_SOURCE) {
176
190
  const directory = path.join(root, relativeDirectory);
177
191
  if (!fs.existsSync(directory)) return [];
178
192
  const files = [];
@@ -182,7 +196,7 @@ function executableFilesBelow(root, relativeDirectory) {
182
196
  const target = path.join(absolute, entry.name), stat = fs.lstatSync(target);
183
197
  if (stat.isSymbolicLink()) throw new Error(`runtime-integrity-symlink:${normalizedRelative(root, target) ?? "outside-root"}`);
184
198
  if (stat.isDirectory()) visit(target);
185
- else if (stat.isFile() && EXECUTABLE_SOURCE.test(entry.name)) files.push(normalizedRelative(root, target));
199
+ else if (stat.isFile() && accept.test(entry.name)) files.push(normalizedRelative(root, target));
186
200
  }
187
201
  };
188
202
  visit(directory);
@@ -192,7 +206,13 @@ function executableFilesBelow(root, relativeDirectory) {
192
206
  function localImportTarget(root, importer, specifier) {
193
207
  if (!specifier.startsWith(".")) return undefined;
194
208
  const base = path.resolve(path.dirname(path.join(root, importer)), specifier);
195
- const candidates = [base, `${base}.ts`, `${base}.js`, `${base}.mjs`, path.join(base, "index.ts"), path.join(base, "index.js")];
209
+ // TypeScript sources import a sibling module by its emitted name, so `./x.js`
210
+ // is how a `.ts` file refers to `x.ts`. Without that rewrite the specifier
211
+ // resolves to nothing and the reference is dropped in silence -- a module
212
+ // that decides what the guard enforces would then sit outside the lock while
213
+ // this list still claimed to cover the whole import graph.
214
+ const rewritten = base.endsWith(".js") ? [`${base.slice(0, -3)}.ts`] : [];
215
+ const candidates = [base, ...rewritten, `${base}.ts`, `${base}.js`, `${base}.mjs`, path.join(base, "index.ts"), path.join(base, "index.js")];
196
216
  for (const candidate of candidates) {
197
217
  const relative = normalizedRelative(root, candidate);
198
218
  if (!relative || !fs.existsSync(candidate)) continue;
@@ -204,8 +224,24 @@ function localImportTarget(root, importer, specifier) {
204
224
  }
205
225
 
206
226
  function sourceReferences(root, relativeFile) {
207
- if (!/\.(?:[cm]?js|ts)$/.test(relativeFile)) return [];
227
+ // Shell entrypoints are scanned too. They were skipped here, so a pinned
228
+ // command could execute an unpinned program and the lock reported a complete
229
+ // graph while missing it.
230
+ const shell = relativeFile.endsWith(".sh");
231
+ if (!shell && !/\.(?:[cm]?js|ts)$/.test(relativeFile)) return [];
208
232
  const source = fs.readFileSync(path.join(root, relativeFile), "utf8"), references = new Set();
233
+ if (shell) {
234
+ const directory = path.posix.dirname(relativeFile);
235
+ for (const pattern of [RUNTIME_LITERAL, SHELL_SIBLING]) {
236
+ pattern.lastIndex = 0;
237
+ for (let match = pattern.exec(source); match; match = pattern.exec(source)) {
238
+ const candidate = pattern === SHELL_SIBLING ? path.posix.join(directory, match[1]) : match[1];
239
+ const absolute = path.join(root, candidate), relative = normalizedRelative(root, absolute);
240
+ if (relative && fs.existsSync(absolute) && fs.lstatSync(absolute).isFile()) references.add(relative);
241
+ }
242
+ }
243
+ return [...references];
244
+ }
209
245
  for (const pattern of [IMPORT_SPECIFIER, DYNAMIC_IMPORT]) {
210
246
  pattern.lastIndex = 0;
211
247
  for (let match = pattern.exec(source); match; match = pattern.exec(source)) {
@@ -231,6 +267,17 @@ export function discoverRuntimeIntegrityFiles(root) {
231
267
  ...CORE_RUNTIME_INTEGRITY_FILES,
232
268
  "packages/piagent-core/extensions/piagent-guard.ts",
233
269
  ...executableFilesBelow(root, "packages/piagent-core/runtime"),
270
+ // The browser bundle the WebUI serves. The published package ships the built
271
+ // assets and not the sources they came from, so on an installed platform
272
+ // this code cannot be regenerated or cross-checked against anything -- and it
273
+ // runs inside the origin that holds the session cookie and the CSRF token,
274
+ // with the authority to drive session commands, source mutation and provider
275
+ // auth. The server that serves it was pinned; what it serves was not.
276
+ //
277
+ // The tree is absent in a source checkout until the workspace is built, and
278
+ // absence is not a failure here: a lock recorded with these entries still
279
+ // fails verification if they later disappear.
280
+ ...bundleFilesBelow(root, "packages/piagent-webui/dist/client"),
234
281
  ...declaredExtensions,
235
282
  ...declaredBins,
236
283
  ...declaredEntrypoints
@@ -11,6 +11,11 @@ import {
11
11
  evaluateExecPolicyCore, extractShellGlobCandidates, extractShellPathCandidates, findProtectedPathInCommand, unresolvedPathExpansions,
12
12
  globMatchesPath, matchesProtectedPath, normalizePathCandidate, matchesAnyPath
13
13
  } from "./policy-core.js";
14
+ import {
15
+ expandSimpleGlobAlternatives, findResolvedProtectedPathInCommand, normalizeRelative, protectedPatternExamples,
16
+ resolveRepositoryPathCandidate, shellGlobMatchesPath, shellGlobSegmentMatches, shellGlobTargetsProtectedPath,
17
+ unresolvedExpansionReason
18
+ } from "./shell-reach.ts";
14
19
  import { extractShellWritePathCandidates, shellHasFileWriteRedirection } from "./shell-write-targets.js";
15
20
  import { commandMatchesVerifyPlan, createBashResultLedger, findMatchingObservedBashResult, readObservedBashResults } from "./runtime-evidence.js";
16
21
  import { findPackageRoot, findPlatformRoot, readJsonFile } from "./guard-io.js";
@@ -374,10 +379,16 @@ const SHELL_TOOL_NAMES = new Set(["bash", "shell", "exec"]);
374
379
  const MAX_MCP_PROXY_ARGS_CHARS = 131_072;
375
380
  const SESSION_PERMISSION_OVERRIDES = new Map<string, PermissionProfileMode>();
376
381
 
382
+ // The policy used when `policies/base-policy.json` cannot be read. It runs in
383
+ // exactly the case where something is already wrong, so it must never be the
384
+ // looser of the two: this copy had drifted behind the file and silently dropped
385
+ // `.pi/piagent-state/**` -- the guard's own state -- along with the `sudo ` and
386
+ // `chmod -R 777` blocks. A missing policy file is a reason to be stricter, not a
387
+ // quiet downgrade. `tests/policy-core.test.mjs` fails if this drifts again.
377
388
  const DEFAULT_POLICY: BasePolicy = {
378
- protectedPaths: [".git/**", "**/auth.json", "**/.env", "**/.env.*", ".pi/settings.json", ".pi/piagent-profile.json", ".pi/piagent-profile.lock.json", CONTEXT_INDEX_FILE],
379
- shellProtectedPaths: [".git/**", "**/auth.json", "**/.env", "**/.env.*", ".pi/settings.json", ".pi/piagent-profile.json", ".pi/piagent-profile.lock.json", CONTEXT_INDEX_FILE],
380
- blockedCommandPatterns: ["rm -rf /", "rm -rf ~", "rm -rf $HOME", "git reset --hard", "git clean -fd"],
389
+ protectedPaths: [".git/**", "**/auth.json", "**/.env", "**/.env.*", "**/node_modules/**", "**/dist/**", ".pi/piagent-state/**", ".pi/settings.json", ".pi/piagent-profile.json", ".pi/piagent-profile.lock.json", CONTEXT_INDEX_FILE],
390
+ shellProtectedPaths: [".git/**", "**/auth.json", "**/.env", "**/.env.*", ".pi/piagent-state/**", ".pi/settings.json", ".pi/piagent-profile.json", ".pi/piagent-profile.lock.json", CONTEXT_INDEX_FILE],
391
+ blockedCommandPatterns: ["rm -rf /", "rm -rf ~", "rm -rf $HOME", "git reset --hard", "git clean -fd", "sudo ", "chmod -R 777"],
381
392
  requireConfirmationPatterns: ["deploy", "release", "publish", "migration", "gh pr merge", "git push"],
382
393
  defaultRequiredContext: ["AGENTS.md", "README.md"],
383
394
  permissionProfiles: {
@@ -646,26 +657,6 @@ function legacyProjectStateWarning(cwd: string): string | undefined {
646
657
  : `${detail} No .pi/piagent-profile.json exists, so this project is running unprofiled and its protected paths and verify commands are NOT enforced. Convert it with \`piagent-migrate . --apply\`.`;
647
658
  }
648
659
 
649
- function normalizeRelative(cwd: string, candidate: unknown): string | undefined {
650
- if (typeof candidate !== "string" || candidate.trim().length === 0) return undefined;
651
- let raw = candidate.trim();
652
- try {
653
- raw = decodeURIComponent(raw);
654
- } catch {
655
- return undefined;
656
- }
657
- if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(raw)) {
658
- if (!raw.toLowerCase().startsWith("file://")) return undefined;
659
- try {
660
- raw = fileURLToPath(raw);
661
- } catch {
662
- return undefined;
663
- }
664
- }
665
- const absolute = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
666
- return path.relative(cwd, absolute).split(path.sep).join("/");
667
- }
668
-
669
660
  function slugify(input: string): string {
670
661
  return input
671
662
  .trim()
@@ -957,60 +948,6 @@ function inspectRepositoryPathBoundary(cwd: string, candidate: string): { reason
957
948
  return {};
958
949
  }
959
950
 
960
- function resolveRepositoryPathCandidate(cwd: string, candidate: string): string | undefined {
961
- const normalized = normalizePathCandidate(candidate);
962
- if (normalized === ".." || normalized.startsWith("../")) return undefined;
963
-
964
- const relative = path.posix.isAbsolute(normalized)
965
- ? path.relative(cwd, normalized).split(path.sep).join("/")
966
- : normalized;
967
- if (relative === ".." || relative.startsWith("../")) return undefined;
968
-
969
- const pending = relative.split("/").filter((item) => item && item !== ".");
970
- let current = cwd;
971
- let resolvedDepth = 0;
972
- for (let index = 0; index < pending.length; index += 1) {
973
- const next = path.join(current, pending[index]);
974
- try {
975
- fs.lstatSync(next);
976
- current = next;
977
- resolvedDepth = index + 1;
978
- } catch (error) {
979
- const code = error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : "";
980
- if (code !== "ENOENT") return undefined;
981
- break;
982
- }
983
- }
984
-
985
- let canonicalBase: string;
986
- try {
987
- canonicalBase = fs.realpathSync.native(current);
988
- } catch {
989
- return undefined;
990
- }
991
- const canonical = path.resolve(canonicalBase, ...pending.slice(resolvedDepth));
992
- const canonicalRoot = fs.realpathSync.native(cwd);
993
- const canonicalRelative = path.relative(canonicalRoot, canonical).split(path.sep).join("/");
994
- if (canonicalRelative === ".." || canonicalRelative.startsWith("../") || path.isAbsolute(canonicalRelative)) return undefined;
995
- return canonicalRelative || ".";
996
- }
997
-
998
- function findResolvedProtectedPathInCommand(
999
- cwd: string,
1000
- command: string,
1001
- protectedPatterns: string[]
1002
- ): { candidate: string; resolved: string; pattern: string } | undefined {
1003
- for (const candidate of extractShellPathCandidates(command)) {
1004
- const relative = normalizeRelative(cwd, candidate);
1005
- if (!relative) continue;
1006
- const resolved = resolveRepositoryPathCandidate(cwd, relative);
1007
- if (!resolved || resolved === relative) continue;
1008
- const pattern = matchesProtectedPath(resolved, protectedPatterns);
1009
- if (pattern) return { candidate, resolved, pattern };
1010
- }
1011
- return undefined;
1012
- }
1013
-
1014
951
  const MAX_TOOL_INPUT_INSPECTION_DEPTH = 32;
1015
952
 
1016
953
  type StringInputWalkResult = {
@@ -1088,130 +1025,6 @@ function extractLikelyPathFromInput(cwd: string, input: Record<string, unknown>)
1088
1025
  return extractPathInputsFromInput(cwd, input)[0]?.path;
1089
1026
  }
1090
1027
 
1091
- function expandSimpleGlobAlternatives(pattern: string, max = 24): { values: string[]; complete: boolean } {
1092
- let results = [pattern];
1093
- let changed = true;
1094
- let complete = true;
1095
-
1096
- while (changed) {
1097
- changed = false;
1098
- const expanded: string[] = [];
1099
- for (const item of results) {
1100
- const match = item.match(/\{([^{}]+)\}/);
1101
- if (!match) {
1102
- expanded.push(item);
1103
- continue;
1104
- }
1105
- changed = true;
1106
- const options = match[1].split(",").map((option) => option.trim());
1107
- for (const option of options) {
1108
- expanded.push(`${item.slice(0, match.index)}${option}${item.slice((match.index ?? 0) + match[0].length)}`);
1109
- if (expanded.length >= max) {
1110
- complete = false;
1111
- break;
1112
- }
1113
- }
1114
- if (expanded.length >= max) break;
1115
- }
1116
- results = expanded.slice(0, max);
1117
- }
1118
-
1119
- return { values: results, complete };
1120
- }
1121
-
1122
- function shellGlobSegmentMatches(patternSegment: string, candidateSegment: string): boolean {
1123
- if (candidateSegment.startsWith(".") && !patternSegment.startsWith(".")) return false;
1124
- let source = "";
1125
- for (let index = 0; index < patternSegment.length; index += 1) {
1126
- const char = patternSegment[index];
1127
- if (char === "*") {
1128
- source += "[^/]*";
1129
- continue;
1130
- }
1131
- if (char === "?") {
1132
- source += "[^/]";
1133
- continue;
1134
- }
1135
- if (char === "[") {
1136
- const end = patternSegment.indexOf("]", index + 1);
1137
- const body = end > index + 1 ? patternSegment.slice(index + 1, end) : "";
1138
- if (body && /^[!^A-Za-z0-9_-]+$/.test(body)) {
1139
- const negated = body.startsWith("!") ? `^${body.slice(1)}` : body;
1140
- source += `[${negated}]`;
1141
- index = end;
1142
- continue;
1143
- }
1144
- }
1145
- source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
1146
- }
1147
- return new RegExp(`^${source}$`, "i").test(candidateSegment);
1148
- }
1149
-
1150
- function shellGlobMatchesPath(pattern: string, candidate: string): boolean {
1151
- const patternSegments = normalizePathCandidate(pattern).split("/").filter(Boolean);
1152
- const candidateSegments = normalizePathCandidate(candidate).split("/").filter(Boolean);
1153
-
1154
- function match(patternIndex: number, candidateIndex: number): boolean {
1155
- if (patternIndex === patternSegments.length) return candidateIndex === candidateSegments.length;
1156
- const patternSegment = patternSegments[patternIndex];
1157
- if (patternSegment === "**") {
1158
- if (match(patternIndex + 1, candidateIndex)) return true;
1159
- for (let next = candidateIndex; next < candidateSegments.length; next += 1) {
1160
- if (match(patternIndex + 1, next + 1)) return true;
1161
- }
1162
- return false;
1163
- }
1164
- if (candidateIndex >= candidateSegments.length) return false;
1165
- return shellGlobSegmentMatches(patternSegment, candidateSegments[candidateIndex])
1166
- && match(patternIndex + 1, candidateIndex + 1);
1167
- }
1168
-
1169
- return patternSegments.length > 0 && candidateSegments.length > 0 && match(0, 0);
1170
- }
1171
-
1172
- function protectedPatternExamples(pattern: string): string[] {
1173
- const normalized = normalizePathCandidate(pattern);
1174
- if (!normalized) return [];
1175
-
1176
- const examples = new Set<string>();
1177
- const add = (value: string | undefined) => {
1178
- const normalizedValue = normalizePathCandidate(value ?? "");
1179
- if (normalizedValue) examples.add(normalizedValue);
1180
- };
1181
-
1182
- add(normalized);
1183
-
1184
- if (normalized.endsWith("/**")) {
1185
- const base = normalized.slice(0, -3);
1186
- add(base);
1187
- add(`${base}/probe`);
1188
- }
1189
-
1190
- if (normalized.startsWith("**/")) {
1191
- const tail = normalized.slice(3);
1192
- const concreteTail = tail
1193
- .replace(/\*\*/g, "nested")
1194
- .replace(/\*/g, tail.includes(".env.") ? "local" : "probe");
1195
- add(tail);
1196
- add(concreteTail);
1197
- add(`nested/${concreteTail}`);
1198
- }
1199
-
1200
- const concrete = normalized
1201
- .replace(/^\*\*\//, "")
1202
- .replace(/\/\*\*$/, "/probe")
1203
- .replace(/\*\*/g, "nested")
1204
- .replace(/\*/g, normalized.includes(".env.") ? "local" : "probe");
1205
- add(concrete);
1206
-
1207
- for (const example of [...examples]) {
1208
- const base = path.posix.basename(example);
1209
- if (base && base !== "probe") examples.add(base);
1210
- }
1211
-
1212
- return [...examples];
1213
- }
1214
-
1215
1028
  function protectedLiteralHints(pattern: string): string[] {
1216
1029
  const normalized = normalizePathCandidate(pattern).toLowerCase();
1217
1030
  if (!normalized) return [];
@@ -1271,32 +1084,6 @@ function globTargetsProtectedPath(glob: unknown, protectedPatterns: string[]): {
1271
1084
  return undefined;
1272
1085
  }
1273
1086
 
1274
- function shellGlobTargetsProtectedPath(
1275
- command: string,
1276
- protectedPatterns: string[]
1277
- ): { glob: string; pattern: string; example: string } | undefined {
1278
- for (const candidate of extractShellGlobCandidates(command)) {
1279
- if (!/[*?{\[]/.test(candidate)) continue;
1280
- const expanded = expandSimpleGlobAlternatives(candidate);
1281
- if (!expanded.complete) return { glob: candidate, pattern: "bounded glob expansion", example: "a protected path" };
1282
- for (const candidateGlob of expanded.values) {
1283
- for (const pattern of protectedPatterns) {
1284
- for (const example of protectedPatternExamples(pattern)) {
1285
- if (/[*?{\[\]]/.test(example)) continue;
1286
- if (
1287
- shellGlobMatchesPath(candidateGlob, example)
1288
- || shellGlobMatchesPath(`**/${candidateGlob}`, example)
1289
- || shellGlobMatchesPath(candidateGlob, path.posix.basename(example))
1290
- ) {
1291
- return { glob: candidate, pattern, example };
1292
- }
1293
- }
1294
- }
1295
- }
1296
- }
1297
- return undefined;
1298
- }
1299
-
1300
1087
  function isPlainRecord(value: unknown): value is Record<string, unknown> {
1301
1088
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1302
1089
  const prototype = Object.getPrototypeOf(value);
@@ -2550,13 +2337,6 @@ function evaluateMcpApproval(
2550
2337
  // running the substitution. Matching the literal half against the protected
2551
2338
  // patterns answers a different question than the one being asked, so refuse
2552
2339
  // rather than let an unknown filename through unchecked.
2553
- function unresolvedExpansionReason(subject: string, words: string[]): string {
2554
- const listed = words.map((word) => `\`${word}\``).join(", ");
2555
- return `${subject} builds a filename this guard cannot resolve: ${listed}. `
2556
- + "The literal text around the expansion makes it a path, but its value is only known at run time, "
2557
- + "so it cannot be checked against the protected paths. Write the path out, or put the expansion in its own argument.";
2558
- }
2559
-
2560
2340
  function evaluateMcpProxyShellProtectedAccess(
2561
2341
  cwd: string,
2562
2342
  prepared: PreparedToolInput,
@@ -4829,7 +4609,10 @@ export default function piagentGuard(pi: ExtensionAPI) {
4829
4609
  if (!sessionTask && taskContractRequired && (shellProjectMutation || directProjectMutation)) {
4830
4610
  return {
4831
4611
  block: true,
4832
- reason: "A session-bound Task Implementation Contract is required before project files can be mutated. Start the task first."
4612
+ // Naming the tool matters: this is the first wall a new operator hits,
4613
+ // and "start the task first" does not say with what.
4614
+ reason: "A session-bound Task Implementation Contract is required before project files can be mutated."
4615
+ + " Call `piagent_task_start` once with explicit project-relative scope, then retry."
4833
4616
  };
4834
4617
  }
4835
4618
  if (sessionTask?.trace.outcome === "pending" && sessionTask.changeMode === "source-change") {
@@ -58,11 +58,22 @@ function numericExitCode(value) {
58
58
  return undefined;
59
59
  }
60
60
 
61
+ // `isError: false` is Pi stating that the command succeeded, so it is evidence
62
+ // and a claim of zero matches it. An observation that states neither an exit code
63
+ // nor an error flag says nothing at all, and nothing is not evidence of success:
64
+ // without this an empty record satisfied a claim of zero, which is the one
65
+ // direction a ledger built to catch forged verification must never fail in.
66
+ //
67
+ // Both shapes are unreachable through `observedBashResultFromToolResultEvent` and
68
+ // `readObservedBashResults`, which always set a boolean flag. The guard is here
69
+ // because a reader of this function should not have to prove that to know the
70
+ // answer is safe.
61
71
  export function claimedExitMatchesObserved(exitCode, observed) {
62
72
  const claimed = numericExitCode(exitCode);
63
73
  if (claimed === undefined) return false;
64
74
  if (typeof observed?.exitCode === "number") return observed.exitCode === claimed;
65
- return observed?.isError === true ? claimed !== 0 : claimed === 0;
75
+ if (typeof observed?.isError !== "boolean") return false;
76
+ return observed.isError ? claimed !== 0 : claimed === 0;
66
77
  }
67
78
 
68
79
  export function extractBashCommandFromToolResultEvent(event) {