@piagent/platform 1.4.0 → 1.5.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 (132) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +12 -2
  3. package/README.vi.md +47 -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 +42 -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/evals/fixtures/piagent-webui/document-workspace-v1.invalid.json +17 -0
  59. package/evals/fixtures/piagent-webui/document-workspace-v1.valid.json +42 -0
  60. package/evals/fixtures/piagent-webui/session-command-v1.valid.json +2 -1
  61. package/package.json +14 -2
  62. package/packages/piagent-core/README.md +3 -2
  63. package/packages/piagent-core/capabilities/capability-verification-cache.js +17 -7
  64. package/packages/piagent-core/capabilities/runtime-integrity.js +51 -4
  65. package/packages/piagent-core/extensions/document-intake.ts +6 -3
  66. package/packages/piagent-core/extensions/piagent-guard.ts +20 -237
  67. package/packages/piagent-core/extensions/runtime-evidence.js +12 -1
  68. package/packages/piagent-core/extensions/shell-reach.ts +250 -0
  69. package/packages/piagent-core/mcp/mcp-command-actions.js +1 -1
  70. package/packages/piagent-core/mcp/mcp-server-catalog.js +135 -5
  71. package/packages/piagent-core/package.json +1 -1
  72. package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +9 -2
  73. package/packages/piagent-core/runtime/input/attachment-document-command.ts +39 -0
  74. package/packages/piagent-core/runtime/input/attachment-reservation.ts +36 -0
  75. package/packages/{piagent-webui/extension → piagent-core/runtime/input}/attachment-store.ts +221 -44
  76. package/packages/piagent-core/runtime/inspection/document-workspace-projection.ts +276 -0
  77. package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +20 -5
  78. package/packages/piagent-core/runtime/inspection/session-identity.ts +20 -0
  79. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +25 -2
  80. package/packages/piagent-core/runtime/policy/block-remedy.ts +139 -0
  81. package/packages/piagent-core/runtime/registration/task-start-tool.ts +9 -13
  82. package/packages/piagent-core/runtime/registration/workflow-commands.ts +5 -4
  83. package/packages/piagent-core/runtime/session/token-economics.js +34 -0
  84. package/packages/piagent-core/runtime/tools/phase-tool-runtime.ts +2 -1
  85. package/packages/piagent-core/runtime/workflows/input-routing.ts +13 -2
  86. package/packages/piagent-core/runtime/workflows/task-intake.ts +5 -0
  87. package/packages/piagent-core/security/sensitive-data.js +7 -0
  88. package/packages/piagent-webui/dist/client/assets/MarkdownMessage-Uouhx1QO.js +29 -0
  89. package/packages/piagent-webui/dist/client/assets/index-BV-_03yl.css +1 -0
  90. package/packages/piagent-webui/dist/client/assets/index-MBZq8XLV.js +174 -0
  91. package/packages/piagent-webui/dist/client/index.html +2 -2
  92. package/packages/piagent-webui/extension/lifecycle-controller.ts +15 -0
  93. package/packages/piagent-webui/extension/piagent-webui.ts +19 -6
  94. package/packages/piagent-webui/extension/same-session-bridge.ts +12 -12
  95. package/packages/piagent-webui/gateway/ai-capability-inspection.ts +53 -0
  96. package/packages/piagent-webui/gateway/extension-authority.ts +47 -0
  97. package/packages/piagent-webui/gateway/gateway-service.ts +23 -3
  98. package/packages/piagent-webui/gateway/native-project-picker.ts +63 -7
  99. package/packages/piagent-webui/gateway/session-attachment-registry.ts +165 -0
  100. package/packages/piagent-webui/gateway/session-catalog.ts +62 -9
  101. package/packages/piagent-webui/gateway/session-command-controller.ts +47 -14
  102. package/packages/piagent-webui/gateway/session-inspection-registry.ts +67 -12
  103. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +24 -11
  104. package/packages/piagent-webui/package.json +3 -1
  105. package/packages/piagent-webui/server/core-inspection-provider.ts +52 -15
  106. package/packages/piagent-webui/server/ipc-read-model-client.ts +2 -0
  107. package/packages/piagent-webui/server/loopback-server.ts +63 -25
  108. package/packages/piagent-webui/server/read-model-provider.ts +2 -0
  109. package/packages/piagent-webui/server/read-only-router.ts +6 -0
  110. package/packages/piagent-webui/server/transcript-projection.ts +58 -2
  111. package/schemas/piagent-webui/attachment-v1.schema.json +24 -4
  112. package/schemas/piagent-webui/capabilities-v1.schema.json +2 -2
  113. package/schemas/piagent-webui/catalog-v1.json +9 -0
  114. package/schemas/piagent-webui/document-workspace-v1.schema.json +155 -0
  115. package/schemas/piagent-webui/session-command-v1.schema.json +14 -3
  116. package/schemas/piagent-webui/transcript-v1.schema.json +27 -0
  117. package/scripts/build-docs-site.mjs +21 -1
  118. package/scripts/explain-command.mjs +240 -0
  119. package/scripts/install-global.sh +104 -3
  120. package/scripts/mcp-manage.mjs +17 -4
  121. package/scripts/pi-session-stats.sh +10 -2
  122. package/scripts/piagent-cli.mjs +14 -1
  123. package/scripts/piagent-dashboard.mjs +55 -2
  124. package/scripts/setup.sh +69 -33
  125. package/scripts/traffic-snapshot.mjs +214 -0
  126. package/scripts/typescript-loader.mjs +20 -4
  127. package/scripts/uninstall-global.sh +53 -2
  128. package/scripts/update-global.mjs +33 -1
  129. package/scripts/verify-local.sh +20 -1
  130. package/scripts/verify-translation-pairing.mjs +121 -0
  131. package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +0 -172
  132. package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +0 -1
@@ -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";
@@ -110,7 +115,7 @@ import {
110
115
  activeTaskToolGroups
111
116
  } from "../runtime/tools/tool-groups.ts";
112
117
  import type { PiagentToolGroup } from "../runtime/tools/tool-groups.ts";
113
- import { shortTaskLabel } from "../runtime/workflows/input-routing.ts";
118
+ import { freshRequestParts, shortTaskLabel } from "../runtime/workflows/input-routing.ts";
114
119
  import {
115
120
  automaticAcceptanceCriteria,
116
121
  automaticReviewLenses,
@@ -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") {
@@ -4922,7 +4705,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
4922
4705
  safeTaskId, searchContextIndex, searchContextIndexV2, searchMemoryFiles, selectRuntimeAction,
4923
4706
  selectValueFromUi, selectVerificationPlan, semanticCompactionInstructions, sendWorkflowFollowUp, setPermissionOverrideForContext,
4924
4707
  semanticRepairCompletionBlock: (cwd: string, taskRunId: string) => semanticRepairRuntime.completionBlock(cwd, taskRunId),
4925
- shellArg, shortTaskLabel, solverShadow, summarizeAttempt, taskChangedFileEvidence,
4708
+ freshRequestParts, shellArg, shortTaskLabel, solverShadow, summarizeAttempt, taskChangedFileEvidence,
4926
4709
  techContextDirPath, techContextFilePath, techContextRelativePath, techOptionById, techStackPath,
4927
4710
  telemetry, toolRegistryConfig, trajectoryRuntime, uniqueStrings, usageExactCommands,
4928
4711
  validTaskScopePattern, validateNewWorkPlan, verifierCommandInstructions, verifyProjectCapabilityState, workingTreeEvidenceDigest,
@@ -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) {
@@ -0,0 +1,250 @@
1
+ // What a shell command can reach, and how to say so.
2
+ //
3
+ // These checks used to live inside the guard entrypoint, which imports the Pi
4
+ // host runtime and therefore cannot be loaded anywhere else. `piagent explain`
5
+ // needs the same answers the guard gives, and a second implementation of "does
6
+ // this command reach a protected path" is exactly the defect this project keeps
7
+ // closing: one segment, many readers, each with its own view. So the readers
8
+ // live here and both the guard and the CLI take this one.
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ import {
15
+ extractShellGlobCandidates,
16
+ extractShellPathCandidates,
17
+ matchesProtectedPath,
18
+ normalizePathCandidate
19
+ } from "./policy-core.js";
20
+
21
+ export function normalizeRelative(cwd: string, candidate: unknown): string | undefined {
22
+ if (typeof candidate !== "string" || candidate.trim().length === 0) return undefined;
23
+ let raw = candidate.trim();
24
+ try {
25
+ raw = decodeURIComponent(raw);
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(raw)) {
30
+ if (!raw.toLowerCase().startsWith("file://")) return undefined;
31
+ try {
32
+ raw = fileURLToPath(raw);
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ const absolute = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
38
+ return path.relative(cwd, absolute).split(path.sep).join("/");
39
+ }
40
+
41
+ export function resolveRepositoryPathCandidate(cwd: string, candidate: string): string | undefined {
42
+ const normalized = normalizePathCandidate(candidate);
43
+ if (normalized === ".." || normalized.startsWith("../")) return undefined;
44
+
45
+ const relative = path.posix.isAbsolute(normalized)
46
+ ? path.relative(cwd, normalized).split(path.sep).join("/")
47
+ : normalized;
48
+ if (relative === ".." || relative.startsWith("../")) return undefined;
49
+
50
+ const pending = relative.split("/").filter((item) => item && item !== ".");
51
+ let current = cwd;
52
+ let resolvedDepth = 0;
53
+ for (let index = 0; index < pending.length; index += 1) {
54
+ const next = path.join(current, pending[index]);
55
+ try {
56
+ fs.lstatSync(next);
57
+ current = next;
58
+ resolvedDepth = index + 1;
59
+ } catch (error) {
60
+ const code = error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : "";
61
+ if (code !== "ENOENT") return undefined;
62
+ break;
63
+ }
64
+ }
65
+
66
+ let canonicalBase: string;
67
+ try {
68
+ canonicalBase = fs.realpathSync.native(current);
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ const canonical = path.resolve(canonicalBase, ...pending.slice(resolvedDepth));
73
+ const canonicalRoot = fs.realpathSync.native(cwd);
74
+ const canonicalRelative = path.relative(canonicalRoot, canonical).split(path.sep).join("/");
75
+ if (canonicalRelative === ".." || canonicalRelative.startsWith("../") || path.isAbsolute(canonicalRelative)) return undefined;
76
+ return canonicalRelative || ".";
77
+ }
78
+
79
+ export function findResolvedProtectedPathInCommand(
80
+ cwd: string,
81
+ command: string,
82
+ protectedPatterns: string[]
83
+ ): { candidate: string; resolved: string; pattern: string } | undefined {
84
+ for (const candidate of extractShellPathCandidates(command)) {
85
+ const relative = normalizeRelative(cwd, candidate);
86
+ if (!relative) continue;
87
+ const resolved = resolveRepositoryPathCandidate(cwd, relative);
88
+ if (!resolved || resolved === relative) continue;
89
+ const pattern = matchesProtectedPath(resolved, protectedPatterns);
90
+ if (pattern) return { candidate, resolved, pattern };
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ export function expandSimpleGlobAlternatives(pattern: string, max = 24): { values: string[]; complete: boolean } {
96
+ let results = [pattern];
97
+ let changed = true;
98
+ let complete = true;
99
+
100
+ while (changed) {
101
+ changed = false;
102
+ const expanded: string[] = [];
103
+ for (const item of results) {
104
+ const match = item.match(/\{([^{}]+)\}/);
105
+ if (!match) {
106
+ expanded.push(item);
107
+ continue;
108
+ }
109
+ changed = true;
110
+ const options = match[1].split(",").map((option) => option.trim());
111
+ for (const option of options) {
112
+ expanded.push(`${item.slice(0, match.index)}${option}${item.slice((match.index ?? 0) + match[0].length)}`);
113
+ if (expanded.length >= max) {
114
+ complete = false;
115
+ break;
116
+ }
117
+ }
118
+ if (expanded.length >= max) break;
119
+ }
120
+ results = expanded.slice(0, max);
121
+ }
122
+
123
+ return { values: results, complete };
124
+ }
125
+
126
+ export function shellGlobSegmentMatches(patternSegment: string, candidateSegment: string): boolean {
127
+ if (candidateSegment.startsWith(".") && !patternSegment.startsWith(".")) return false;
128
+ let source = "";
129
+ for (let index = 0; index < patternSegment.length; index += 1) {
130
+ const char = patternSegment[index];
131
+ if (char === "*") {
132
+ source += "[^/]*";
133
+ continue;
134
+ }
135
+ if (char === "?") {
136
+ source += "[^/]";
137
+ continue;
138
+ }
139
+ if (char === "[") {
140
+ const end = patternSegment.indexOf("]", index + 1);
141
+ const body = end > index + 1 ? patternSegment.slice(index + 1, end) : "";
142
+ if (body && /^[!^A-Za-z0-9_-]+$/.test(body)) {
143
+ const negated = body.startsWith("!") ? `^${body.slice(1)}` : body;
144
+ source += `[${negated}]`;
145
+ index = end;
146
+ continue;
147
+ }
148
+ }
149
+ source += char.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
150
+ }
151
+ return new RegExp(`^${source}$`, "i").test(candidateSegment);
152
+ }
153
+
154
+ export function shellGlobMatchesPath(pattern: string, candidate: string): boolean {
155
+ const patternSegments = normalizePathCandidate(pattern).split("/").filter(Boolean);
156
+ const candidateSegments = normalizePathCandidate(candidate).split("/").filter(Boolean);
157
+
158
+ function match(patternIndex: number, candidateIndex: number): boolean {
159
+ if (patternIndex === patternSegments.length) return candidateIndex === candidateSegments.length;
160
+ const patternSegment = patternSegments[patternIndex];
161
+ if (patternSegment === "**") {
162
+ if (match(patternIndex + 1, candidateIndex)) return true;
163
+ for (let next = candidateIndex; next < candidateSegments.length; next += 1) {
164
+ if (match(patternIndex + 1, next + 1)) return true;
165
+ }
166
+ return false;
167
+ }
168
+ if (candidateIndex >= candidateSegments.length) return false;
169
+ return shellGlobSegmentMatches(patternSegment, candidateSegments[candidateIndex])
170
+ && match(patternIndex + 1, candidateIndex + 1);
171
+ }
172
+
173
+ return patternSegments.length > 0 && candidateSegments.length > 0 && match(0, 0);
174
+ }
175
+
176
+ export function protectedPatternExamples(pattern: string): string[] {
177
+ const normalized = normalizePathCandidate(pattern);
178
+ if (!normalized) return [];
179
+
180
+ const examples = new Set<string>();
181
+ const add = (value: string | undefined) => {
182
+ const normalizedValue = normalizePathCandidate(value ?? "");
183
+ if (normalizedValue) examples.add(normalizedValue);
184
+ };
185
+
186
+ add(normalized);
187
+
188
+ if (normalized.endsWith("/**")) {
189
+ const base = normalized.slice(0, -3);
190
+ add(base);
191
+ add(`${base}/probe`);
192
+ }
193
+
194
+ if (normalized.startsWith("**/")) {
195
+ const tail = normalized.slice(3);
196
+ const concreteTail = tail
197
+ .replace(/\*\*/g, "nested")
198
+ .replace(/\*/g, tail.includes(".env.") ? "local" : "probe");
199
+ add(tail);
200
+ add(concreteTail);
201
+ add(`nested/${concreteTail}`);
202
+ }
203
+
204
+ const concrete = normalized
205
+ .replace(/^\*\*\//, "")
206
+ .replace(/\/\*\*$/, "/probe")
207
+ .replace(/\*\*/g, "nested")
208
+ .replace(/\*/g, normalized.includes(".env.") ? "local" : "probe");
209
+ add(concrete);
210
+
211
+ for (const example of [...examples]) {
212
+ const base = path.posix.basename(example);
213
+ if (base && base !== "probe") examples.add(base);
214
+ }
215
+
216
+ return [...examples];
217
+ }
218
+
219
+ export function shellGlobTargetsProtectedPath(
220
+ command: string,
221
+ protectedPatterns: string[]
222
+ ): { glob: string; pattern: string; example: string } | undefined {
223
+ for (const candidate of extractShellGlobCandidates(command)) {
224
+ if (!/[*?{\[]/.test(candidate)) continue;
225
+ const expanded = expandSimpleGlobAlternatives(candidate);
226
+ if (!expanded.complete) return { glob: candidate, pattern: "bounded glob expansion", example: "a protected path" };
227
+ for (const candidateGlob of expanded.values) {
228
+ for (const pattern of protectedPatterns) {
229
+ for (const example of protectedPatternExamples(pattern)) {
230
+ if (/[*?{\[\]]/.test(example)) continue;
231
+ if (
232
+ shellGlobMatchesPath(candidateGlob, example)
233
+ || shellGlobMatchesPath(`**/${candidateGlob}`, example)
234
+ || shellGlobMatchesPath(candidateGlob, path.posix.basename(example))
235
+ ) {
236
+ return { glob: candidate, pattern, example };
237
+ }
238
+ }
239
+ }
240
+ }
241
+ }
242
+ return undefined;
243
+ }
244
+
245
+ export function unresolvedExpansionReason(subject: string, words: string[]): string {
246
+ const listed = words.map((word) => `\`${word}\``).join(", ");
247
+ return `${subject} builds a filename this guard cannot resolve: ${listed}. `
248
+ + "The literal text around the expansion makes it a path, but its value is only known at run time, "
249
+ + "so it cannot be checked against the protected paths. Write the path out, or put the expansion in its own argument.";
250
+ }
@@ -37,7 +37,7 @@ export const SERVER_ACTIONS = new Set(["get", "approve", "reject", "reset", "ena
37
37
  export const TERMINAL_ONLY = new Map([
38
38
  ["add", "piagent-mcp add <name> [--scope <scope>] --url <url> | -- <command> [args...]"],
39
39
  ["remove", "piagent-mcp remove <name> --scope <scope>"],
40
- ["preset", "piagent-mcp --preset <core|popular|design> --scope <scope>"]
40
+ ["preset", "piagent-mcp --preset <core|popular|documents|google|design> --scope <scope>"]
41
41
  ]);
42
42
 
43
43
  export const HELP_LINES = [