@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.
- package/CHANGELOG.md +93 -0
- package/README.md +8 -2
- package/README.vi.md +27 -0
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +7 -7
- package/docs/en/architecture.md +13 -0
- package/docs/languages.json +20 -4
- package/docs/mcp-and-tools.md +112 -1
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +36 -0
- package/docs/release-install-policy.md +21 -8
- package/docs/runtime-harness-standard.md +1 -1
- package/docs/security-threat-model.md +1 -1
- package/docs/task-implementation-contract.md +11 -4
- package/docs/team-onboarding.md +2 -2
- package/docs/usage-observability.md +70 -0
- package/docs/vi/architecture.md +13 -0
- package/docs-site/architecture.html +5 -1
- package/docs-site/benchmark.html +5 -1
- package/docs-site/commands.html +5 -1
- package/docs-site/content/en/index.html +49 -0
- package/docs-site/content/en/runtime.html +13 -0
- package/docs-site/content/index.html +49 -0
- package/docs-site/content/runtime.html +28 -0
- package/docs-site/context-engine.html +5 -1
- package/docs-site/en/architecture.html +5 -1
- package/docs-site/en/benchmark.html +5 -1
- package/docs-site/en/commands.html +5 -1
- package/docs-site/en/context-engine.html +5 -1
- package/docs-site/en/index.html +54 -1
- package/docs-site/en/mcp-approval.html +5 -1
- package/docs-site/en/mcp-auth.html +5 -1
- package/docs-site/en/mcp-servers.html +5 -1
- package/docs-site/en/mcp.html +5 -1
- package/docs-site/en/permissions.html +5 -1
- package/docs-site/en/profiles.html +5 -1
- package/docs-site/en/quickstart.html +5 -1
- package/docs-site/en/runtime.html +18 -1
- package/docs-site/en/scope.html +5 -1
- package/docs-site/en/security.html +5 -1
- package/docs-site/en/team.html +5 -1
- package/docs-site/en/whats-new.html +5 -1
- package/docs-site/en/workflows.html +5 -1
- package/docs-site/index.html +54 -1
- package/docs-site/mcp-approval.html +5 -1
- package/docs-site/mcp-auth.html +5 -1
- package/docs-site/mcp-servers.html +5 -1
- package/docs-site/mcp.html +5 -1
- package/docs-site/permissions.html +5 -1
- package/docs-site/profiles.html +5 -1
- package/docs-site/quickstart.html +5 -1
- package/docs-site/runtime.html +33 -1
- package/docs-site/scope.html +5 -1
- package/docs-site/security.html +5 -1
- package/docs-site/team.html +5 -1
- package/docs-site/whats-new.html +5 -1
- package/docs-site/workflows.html +5 -1
- package/package.json +14 -2
- package/packages/piagent-core/README.md +3 -2
- package/packages/piagent-core/capabilities/capability-verification-cache.js +17 -7
- package/packages/piagent-core/capabilities/runtime-integrity.js +51 -4
- package/packages/piagent-core/extensions/piagent-guard.ts +18 -235
- package/packages/piagent-core/extensions/runtime-evidence.js +12 -1
- package/packages/piagent-core/extensions/shell-reach.ts +250 -0
- package/packages/piagent-core/mcp/mcp-command-actions.js +1 -1
- package/packages/piagent-core/mcp/mcp-server-catalog.js +135 -5
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +9 -2
- package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +25 -2
- package/packages/piagent-core/runtime/policy/block-remedy.ts +139 -0
- package/packages/piagent-core/runtime/registration/task-start-tool.ts +9 -13
- package/packages/piagent-core/runtime/session/token-economics.js +34 -0
- package/packages/piagent-core/runtime/tools/phase-tool-runtime.ts +2 -1
- package/packages/piagent-core/runtime/workflows/task-intake.ts +5 -0
- package/packages/piagent-core/security/sensitive-data.js +7 -0
- package/packages/piagent-webui/dist/client/assets/index-bu7XpD9Q.js +172 -0
- package/packages/piagent-webui/dist/client/index.html +1 -1
- package/packages/piagent-webui/extension/lifecycle-controller.ts +15 -0
- package/packages/piagent-webui/extension/piagent-webui.ts +4 -4
- package/packages/piagent-webui/extension/same-session-bridge.ts +3 -3
- package/packages/piagent-webui/gateway/extension-authority.ts +47 -0
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +37 -2
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +5 -1
- package/packages/piagent-webui/server/core-inspection-provider.ts +5 -5
- package/packages/piagent-webui/server/transcript-projection.ts +15 -0
- package/scripts/build-docs-site.mjs +21 -1
- package/scripts/explain-command.mjs +240 -0
- package/scripts/install-global.sh +104 -3
- package/scripts/mcp-manage.mjs +17 -4
- package/scripts/pi-session-stats.sh +10 -2
- package/scripts/piagent-cli.mjs +14 -1
- package/scripts/piagent-dashboard.mjs +55 -2
- package/scripts/setup.sh +69 -33
- package/scripts/traffic-snapshot.mjs +214 -0
- package/scripts/typescript-loader.mjs +20 -4
- package/scripts/uninstall-global.sh +53 -2
- package/scripts/update-global.mjs +33 -1
- package/scripts/verify-local.sh +4 -0
- package/scripts/verify-translation-pairing.mjs +121 -0
- 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.
|
|
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": "
|
|
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;
|
|
183
|
-
the prior
|
|
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
|
-
|
|
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
|
|
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
|
|
198
|
-
|
|
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
|
-
|
|
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() &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|