@piagent/platform 1.37.1 → 1.38.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 +47 -0
- package/README.md +5 -3
- package/README.vi.md +2 -2
- package/catalog/capabilities.json +2 -2
- package/docs/command-reference-vietnamese.md +4 -3
- package/docs/distribution-standard.md +6 -6
- package/docs/en/maintainer-guide.md +8 -0
- package/docs/en/workspace-editor.md +222 -0
- package/docs/languages.json +13 -4
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/research/ide-m0-spike.md +101 -0
- package/docs/team-onboarding.md +2 -2
- package/docs/vi/maintainer-guide.md +8 -0
- package/docs/vi/workspace-editor.md +217 -0
- package/docs-site/architecture.html +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/en/index.html +4 -4
- package/docs-site/content/en/whats-new.html +17 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +17 -0
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +5 -5
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/whats-new.html +18 -1
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +5 -5
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/whats-new.html +18 -1
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/fixtures/piagent-webui/checkpoint-v1.invalid.json +21 -0
- package/evals/fixtures/piagent-webui/checkpoint-v1.valid.json +20 -0
- package/evals/fixtures/piagent-webui/terminal-v1.invalid.json +17 -0
- package/evals/fixtures/piagent-webui/terminal-v1.valid.json +16 -0
- package/evals/fixtures/piagent-webui/workspace-file-v1.invalid.json +24 -0
- package/evals/fixtures/piagent-webui/workspace-file-v1.valid.json +23 -0
- package/evals/fixtures/piagent-webui/workspace-paths-v1.invalid.json +24 -0
- package/evals/fixtures/piagent-webui/workspace-paths-v1.valid.json +24 -0
- package/evals/fixtures/piagent-webui/workspace-search-v1.invalid.json +26 -0
- package/evals/fixtures/piagent-webui/workspace-search-v1.valid.json +26 -0
- package/evals/fixtures/piagent-webui/workspace-tree-v1.invalid.json +28 -0
- package/evals/fixtures/piagent-webui/workspace-tree-v1.valid.json +27 -0
- package/evals/fixtures/piagent-webui/workspace-write-v1.invalid.json +19 -0
- package/evals/fixtures/piagent-webui/workspace-write-v1.valid.json +19 -0
- package/evals/long-task-soak-v1/README.md +17 -0
- package/evals/long-task-soak-v1/lib/session-loop.mjs +19 -3
- package/evals/long-task-soak-v1/scenarios/ide-editor-terminal.json +19 -0
- package/evals/long-task-soak-v1/surfaces/webui-gateway.mjs +17 -2
- package/evals/long-task-soak-v1/surfaces/workspace-actions.mjs +95 -0
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +7 -3
- package/packages/piagent-core/extensions/fallback-base-policy.ts +1 -1
- package/packages/piagent-core/extensions/guard-types.ts +3 -1
- package/packages/piagent-core/extensions/path-suggestions.ts +137 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +85 -7
- package/packages/piagent-core/extensions/workspace-inspection.ts +145 -0
- package/packages/piagent-core/extensions/workspace-policy.d.ts +7 -0
- package/packages/piagent-core/extensions/workspace-policy.js +3 -0
- package/packages/piagent-core/extensions/workspace-search-index.mjs +8 -0
- package/packages/piagent-core/extensions/workspace-search.ts +74 -0
- package/packages/piagent-core/extensions/workspace-write.ts +116 -0
- package/packages/piagent-core/managed/background-process.mjs +9 -4
- package/packages/piagent-core/managed/boundary-package-proxy.mjs +21 -0
- package/packages/piagent-core/managed/boundary-queue.mjs +24 -0
- package/packages/piagent-core/managed/checkpoint-git.mjs +16 -0
- package/packages/piagent-core/managed/checkpoints.mjs +17 -64
- package/packages/piagent-core/managed/command-tools.mjs +6 -6
- package/packages/piagent-core/managed/continuation.mjs +3 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/backend-api/profile.json +88 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/be-readonly-fe/profile.json +130 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/data/profile.json +88 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/devops/profile.json +90 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/docs/profile.json +74 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/fullstack/profile.json +92 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/generic/profile.json +95 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/mobile/profile.json +86 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/node-typescript/profile.json +74 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/python/profile.json +75 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/web-frontend/profile.json +99 -0
- package/packages/piagent-core/managed/dist/workspace/assets/local-state-path-BrmghxEZ.js +284 -0
- package/packages/piagent-core/managed/dist/workspace/assets/packages/piagent-core/policies/base-policy.json +351 -0
- package/packages/piagent-core/managed/dist/workspace/manifest.json +65 -0
- package/packages/piagent-core/managed/dist/workspace/worker.mjs +7374 -0
- package/packages/piagent-core/managed/dist/workspace/workspace-search-index.mjs +1007 -0
- package/packages/piagent-core/managed/patch-snapshot.mjs +6 -8
- package/packages/piagent-core/managed/permission.mjs +29 -6
- package/packages/piagent-core/managed/repository-operation.mjs +2 -1
- package/packages/piagent-core/managed/review-snapshot-request.mjs +21 -0
- package/packages/piagent-core/managed/sandbox-diagnostic.mjs +2 -0
- package/packages/piagent-core/managed/session-mutation-observations.mjs +37 -0
- package/packages/piagent-core/managed/session.mjs +2 -2
- package/packages/piagent-core/managed/source-revert-requests.mjs +21 -0
- package/packages/piagent-core/managed/terminal-boundary.mjs +88 -0
- package/packages/piagent-core/managed/terminal-channel.mjs +66 -0
- package/packages/piagent-core/managed/terminal-runtime.mjs +64 -0
- package/packages/piagent-core/managed/terminal-seatbelt.mjs +48 -0
- package/packages/piagent-core/managed/terminal-worker.mjs +70 -0
- package/packages/piagent-core/managed/tool-boundary.mjs +45 -35
- package/packages/piagent-core/managed/tool-freshness.mjs +47 -0
- package/packages/piagent-core/managed/tool-worker.mjs +18 -3
- package/packages/piagent-core/managed/workspace-worker-pins.mjs +26 -0
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/policies/base-policy.json +2 -1
- package/packages/piagent-core/runtime/checkpoints/checkpoint-authority.ts +43 -0
- package/packages/piagent-core/runtime/checkpoints/checkpoint-refs.mjs +51 -0
- package/packages/piagent-core/runtime/checkpoints/checkpoint-store.mjs +179 -0
- package/packages/piagent-core/runtime/checkpoints/checkpoint-worker.ts +28 -0
- package/packages/piagent-core/runtime/checkpoints/git-adapter.mjs +36 -0
- package/packages/piagent-core/runtime/checkpoints/persistent-history.mjs +96 -0
- package/packages/piagent-core/runtime/checkpoints/personal-git.ts +272 -0
- package/packages/piagent-core/runtime/checkpoints/personal-runtime.ts +158 -0
- package/packages/piagent-core/runtime/checkpoints/prune.mjs +29 -0
- package/packages/piagent-core/runtime/checkpoints/restore-preview.mjs +46 -0
- package/packages/piagent-core/runtime/checkpoints/turn-checkpoints.mjs +167 -0
- package/packages/piagent-core/runtime/hooks/session-hooks.ts +19 -16
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +3 -0
- package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +2 -2
- package/packages/piagent-core/runtime/inspection/diff-projection.ts +2 -1
- package/packages/piagent-core/runtime/inspection/managed-source-revert.ts +90 -0
- package/packages/piagent-core/runtime/inspection/review-state-projection.ts +19 -8
- package/packages/piagent-core/runtime/inspection/session-diff-projection.ts +47 -0
- package/packages/piagent-core/runtime/inspection/session-hunk-provenance.ts +56 -0
- package/packages/piagent-core/runtime/inspection/session-mutation-observer.ts +125 -0
- package/packages/piagent-core/runtime/inspection/source-action-contract.ts +123 -0
- package/packages/piagent-core/runtime/inspection/source-change-projection.ts +3 -9
- package/packages/piagent-core/runtime/inspection/source-mutation-projection.ts +3 -41
- package/packages/piagent-core/runtime/inspection/source-mutation-store.ts +6 -36
- package/packages/piagent-core/runtime/inspection/source-revert-projection.ts +36 -30
- package/packages/piagent-core/runtime/inspection/text-diff.ts +5 -4
- package/packages/piagent-core/runtime/inspection/workspace-worker.ts +79 -0
- package/packages/piagent-core/runtime/policy/source-index-transaction.ts +3 -10
- package/packages/piagent-core/runtime/policy/source-mutation-guard-binding.ts +27 -2
- package/packages/piagent-core/runtime/policy/source-mutation-guard.ts +42 -12
- package/packages/piagent-core/runtime/policy/source-worktree-transaction.ts +10 -5
- package/packages/piagent-core/runtime/policy/workspace-write-authority.ts +51 -0
- package/packages/piagent-core/runtime/process-owner.mjs +20 -0
- package/packages/piagent-core/runtime/process-tree-watch.mjs +46 -0
- package/packages/piagent-core/runtime/quality/model-mutation-proof.ts +8 -5
- package/packages/piagent-core/runtime/registration/permission-commands.ts +10 -2
- package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
- package/packages/piagent-core/runtime/session/workspace-member-edits.d.mts +7 -0
- package/packages/piagent-core/runtime/session/workspace-member-edits.mjs +41 -0
- package/packages/piagent-core/security/workspace-file-reader.ts +2 -1
- package/packages/piagent-webui/dist/client/assets/Alert-DXF8-XqE.js +1 -0
- package/packages/piagent-webui/dist/client/assets/Button-DZJosXQi.js +1 -0
- package/packages/piagent-webui/dist/client/assets/DialogTitle-CL-fzCnT.js +3 -0
- package/packages/piagent-webui/dist/client/assets/MarkdownMessage-k4tq1y8s.js +29 -0
- package/packages/piagent-webui/dist/client/assets/TerminalPanel-DvOPgrGI.js +2 -0
- package/packages/piagent-webui/dist/client/assets/TerminalView-B_a16moC.js +9 -0
- package/packages/piagent-webui/dist/client/assets/TerminalView-kHJ-D0s7.css +1 -0
- package/packages/piagent-webui/dist/client/assets/Typography-DMZL1kLS.js +1 -0
- package/packages/piagent-webui/dist/client/assets/WorkspaceCodeEditor-BCiYM9NA.js +2 -0
- package/packages/piagent-webui/dist/client/assets/WorkspaceExplorer-DttpQtpf.js +5 -0
- package/packages/piagent-webui/dist/client/assets/WorkspaceQuickOpen-fgXwQ46B.js +1 -0
- package/packages/piagent-webui/dist/client/assets/bootstrap-CedkNtRG.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-B1M7IjfA.js +10 -0
- package/packages/piagent-webui/dist/client/assets/dist-BUYtGaUP.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-C82AtDDT.js +6 -0
- package/packages/piagent-webui/dist/client/assets/dist-D9b8j6pJ.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-DLfpWwLs.js +23 -0
- package/packages/piagent-webui/dist/client/assets/dist-DcEu_WYV.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-_JsfplOG.js +9 -0
- package/packages/piagent-webui/dist/client/assets/dist-idN8f9Fc.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-nvgpa2Us.js +1 -0
- package/packages/piagent-webui/dist/client/assets/index-BW8-BUMx.js +102 -0
- package/packages/piagent-webui/dist/client/assets/index-Brs_HjIo.css +1 -0
- package/packages/piagent-webui/dist/client/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/packages/piagent-webui/dist/client/assets/tab-close-HE0XxoJf.js +1 -0
- package/packages/piagent-webui/dist/client/assets/ui-preferences-DiddStu0.js +124 -0
- package/packages/piagent-webui/dist/client/assets/useTheme-_ESOj7MI.js +10 -0
- package/packages/piagent-webui/dist/client/assets/workspace-api-O7auv_td.js +1 -0
- package/packages/piagent-webui/dist/client/index.html +10 -2
- package/packages/piagent-webui/extension/same-session-bridge.ts +2 -3
- package/packages/piagent-webui/extension/source-revert-controller.ts +14 -208
- package/packages/piagent-webui/gateway/checkpoint-sessions.ts +99 -0
- package/packages/piagent-webui/gateway/checkpoint-store.ts +6 -0
- package/packages/piagent-webui/gateway/company-relay.ts +5 -0
- package/packages/piagent-webui/gateway/company-terminal-cache.ts +54 -0
- package/packages/piagent-webui/gateway/company-terminal-channel.ts +117 -0
- package/packages/piagent-webui/gateway/company-terminal-relay.ts +75 -0
- package/packages/piagent-webui/gateway/gateway-protocol-service.ts +12 -0
- package/packages/piagent-webui/gateway/gateway-service.ts +48 -4
- package/packages/piagent-webui/gateway/managed-gateway.mjs +30 -3
- package/packages/piagent-webui/gateway/managed-projection.ts +5 -3
- package/packages/piagent-webui/gateway/managed-source-revert-controller.ts +45 -0
- package/packages/piagent-webui/gateway/managed-source-revert-sessions.ts +74 -0
- package/packages/piagent-webui/gateway/managed-terminal-binding.ts +30 -0
- package/packages/piagent-webui/gateway/managed-terminal-processes.ts +74 -0
- package/packages/piagent-webui/gateway/managed-terminal-sessions.ts +9 -0
- package/packages/piagent-webui/gateway/path-suggestions.ts +1 -124
- package/packages/piagent-webui/gateway/personal-terminal-binding.ts +36 -0
- package/packages/piagent-webui/gateway/personal-terminal-diagnostic.mjs +16 -0
- package/packages/piagent-webui/gateway/personal-terminal-processes.ts +119 -0
- package/packages/piagent-webui/gateway/personal-terminal-sessions.ts +22 -0
- package/packages/piagent-webui/gateway/runtime-session-controls.ts +2 -1
- package/packages/piagent-webui/gateway/session-catalog.ts +3 -2
- package/packages/piagent-webui/gateway/session-command-controller.ts +4 -4
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +29 -5
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +8 -8
- package/packages/piagent-webui/gateway/signed-session-journal.ts +77 -0
- package/packages/piagent-webui/gateway/source-revert-sessions.ts +74 -0
- package/packages/piagent-webui/gateway/source-revert-store.ts +47 -0
- package/packages/piagent-webui/gateway/terminal-command-ledger.ts +33 -0
- package/packages/piagent-webui/gateway/terminal-mentions.ts +29 -0
- package/packages/piagent-webui/gateway/terminal-output-buffer.ts +58 -0
- package/packages/piagent-webui/gateway/terminal-router.ts +12 -0
- package/packages/piagent-webui/gateway/terminal-sessions.ts +247 -0
- package/packages/piagent-webui/gateway/workspace-write-sessions.ts +69 -0
- package/packages/piagent-webui/gateway/workspace-write-store.ts +6 -0
- package/packages/piagent-webui/package.json +14 -2
- package/packages/piagent-webui/server/checkpoint-command.ts +30 -0
- package/packages/piagent-webui/server/checkpoint-controller.ts +87 -0
- package/packages/piagent-webui/server/checkpoint-routes.ts +38 -0
- package/packages/piagent-webui/server/control-command-digest.ts +5 -0
- package/packages/piagent-webui/server/core-inspection-provider.ts +16 -22
- package/packages/piagent-webui/server/gateway-websocket.ts +36 -8
- package/packages/piagent-webui/server/loopback-server.ts +9 -0
- package/packages/piagent-webui/server/read-model-provider.ts +3 -0
- package/packages/piagent-webui/server/read-only-router.ts +3 -0
- package/packages/piagent-webui/server/session-auth.ts +4 -1
- package/packages/piagent-webui/server/session-source-revert-routes.ts +54 -0
- package/packages/piagent-webui/server/source-projection-binding.ts +18 -0
- package/packages/piagent-webui/server/source-revert-controller.ts +225 -0
- package/packages/piagent-webui/server/terminal-command.ts +35 -0
- package/packages/piagent-webui/server/terminal-websocket-stream.ts +56 -0
- package/packages/piagent-webui/server/transcript-projection.ts +2 -1
- package/packages/piagent-webui/server/workspace-read-routes.ts +32 -0
- package/packages/piagent-webui/server/workspace-reader.ts +23 -0
- package/packages/piagent-webui/server/workspace-write-controller.ts +118 -0
- package/packages/piagent-webui/server/workspace-write-routes.ts +38 -0
- package/packages/piagent-webui/shared/terminal-message.ts +23 -0
- package/schemas/piagent-webui/catalog-v1.json +63 -0
- package/schemas/piagent-webui/checkpoint-v1.schema.json +466 -0
- package/schemas/piagent-webui/control-command-v1.schema.json +68 -3
- package/schemas/piagent-webui/diff-v1.schema.json +4 -0
- package/schemas/piagent-webui/gateway-protocol-v1.schema.json +123 -3
- package/schemas/piagent-webui/session-catalog-v1.schema.json +1 -1
- package/schemas/piagent-webui/session-command-v1.schema.json +1 -1
- package/schemas/piagent-webui/snapshot-v1.schema.json +2 -1
- package/schemas/piagent-webui/source-revert-v1.schema.json +22 -3
- package/schemas/piagent-webui/terminal-v1.schema.json +495 -0
- package/schemas/piagent-webui/workspace-file-v1.schema.json +79 -0
- package/schemas/piagent-webui/workspace-paths-v1.schema.json +71 -0
- package/schemas/piagent-webui/workspace-search-v1.schema.json +80 -0
- package/schemas/piagent-webui/workspace-tree-v1.schema.json +87 -0
- package/schemas/piagent-webui/workspace-write-v1.schema.json +276 -0
- package/schemas/project-profile.schema.json +8 -3
- package/scripts/company-terminal.mjs +16 -11
- package/scripts/pi-auto.sh +5 -1
- package/scripts/team-doctor.sh +8 -0
- package/packages/piagent-webui/dist/client/assets/MarkdownMessage-CfgyQQbW.js +0 -29
- package/packages/piagent-webui/dist/client/assets/index-HyzWKvDu.js +0 -234
- package/packages/piagent-webui/dist/client/assets/index-_0nGP2n3.css +0 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { inspectWorkspaceEntry } from "../security/workspace-file-reader.ts";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
// What an @ in the composer offers, as Pi's terminal does: files and folders
|
|
8
|
+
// of the project matched loosely by name (fd, which honours .gitignore), and,
|
|
9
|
+
// after a folder and a slash (src/, ~/Documents/, /Volumes/), the names under
|
|
10
|
+
// that folder anywhere on this Mac. Only names reach the browser; no file is
|
|
11
|
+
// opened.
|
|
12
|
+
export type PathSuggestion = { value: string; label: string; detail: string; kind: "file" | "directory" };
|
|
13
|
+
type Entry = { path: string; directory: boolean };
|
|
14
|
+
|
|
15
|
+
const LIMIT = 20;
|
|
16
|
+
const SEARCH_RESULTS = 100;
|
|
17
|
+
const LISTED_ENTRIES = 5_000;
|
|
18
|
+
// Outside the project a search stays shallow and skips app data and
|
|
19
|
+
// dependency folders: a home folder is far larger than a repository.
|
|
20
|
+
const OUTSIDE_DEPTH = 4;
|
|
21
|
+
const fold = (value: string) => value.normalize("NFC").toLowerCase();
|
|
22
|
+
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
23
|
+
// A name typed composed (Dự án) may be stored decomposed on disk: either form matches.
|
|
24
|
+
const either = (value: string) => value.normalize("NFC") === value.normalize("NFD") ? escape(value)
|
|
25
|
+
: `(?:${escape(value.normalize("NFC"))}|${escape(value.normalize("NFD"))})`;
|
|
26
|
+
const inside = (file: string, root: string) => file === root || file.startsWith(root + path.sep);
|
|
27
|
+
|
|
28
|
+
// fd where it is commonly installed, then on PATH (Debian and Ubuntu name it
|
|
29
|
+
// fdfind; Windows adds .exe).
|
|
30
|
+
export function findFd(home = os.homedir()): string | null {
|
|
31
|
+
const names = process.platform === "win32" ? ["fd.exe"] : ["fd", "fdfind"];
|
|
32
|
+
const onPath = String(process.env.PATH ?? "").split(path.delimiter).filter(Boolean).flatMap((dir) => names.map((name) => path.join(dir, name)));
|
|
33
|
+
for (const candidate of ["/opt/homebrew/bin/fd", "/usr/local/bin/fd", path.join(home, ".pi/agent/bin", names[0]), ...onPath]) {
|
|
34
|
+
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch { /* next */ }
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
// macOS: the system's git, always there; elsewhere git from PATH.
|
|
39
|
+
const GIT = process.platform === "darwin" ? "/usr/bin/git" : "git";
|
|
40
|
+
|
|
41
|
+
// Higher is better: the name itself, then its start, then anywhere in it,
|
|
42
|
+
// then anywhere in the path; a folder before a file with the same score.
|
|
43
|
+
function score(entry: string, query: string, directory: boolean): number {
|
|
44
|
+
const name = fold(path.basename(entry)), wanted = fold(query);
|
|
45
|
+
const value = name === wanted ? 100 : name.startsWith(wanted) ? 80 : name.includes(wanted) ? 50 : fold(entry).includes(wanted) ? 30 : 0;
|
|
46
|
+
return value && directory ? value + 10 : value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function run(file: string, args: string[], signal?: AbortSignal, input?: string): Promise<string> {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const child = execFile(file, args, { encoding: "utf8", timeout: 4_000, maxBuffer: 8 << 20, signal }, (error, stdout) => resolve(error ? "" : stdout));
|
|
52
|
+
child.stdin?.on("error", () => {}); child.stdin?.end(input ?? "");
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The folder's own names, without what its repository ignores (dist/,
|
|
57
|
+
// node_modules/), as fd lists them for Pi.
|
|
58
|
+
async function listed(folder: string, signal?: AbortSignal, projectOnly = false): Promise<Entry[]> {
|
|
59
|
+
const entries: Entry[] = [];
|
|
60
|
+
try {
|
|
61
|
+
for await (const entry of await fs.promises.opendir(folder)) {
|
|
62
|
+
let directory = entry.isDirectory();
|
|
63
|
+
if (entry.isSymbolicLink() && projectOnly) continue;
|
|
64
|
+
if (entry.isSymbolicLink()) try { directory = fs.statSync(path.join(folder, entry.name)).isDirectory(); } catch { /* broken link */ }
|
|
65
|
+
entries.push({ path: entry.name, directory });
|
|
66
|
+
if (entries.length >= LISTED_ENTRIES) break;
|
|
67
|
+
}
|
|
68
|
+
} catch { /* unreadable */ }
|
|
69
|
+
if (!entries.length) return entries;
|
|
70
|
+
const ignored = new Set((await run(GIT, ["-C", folder, "check-ignore", "-z", "--stdin"], signal, entries.map((entry) => `${entry.path}\0`).join("")))
|
|
71
|
+
.split("\0").filter(Boolean));
|
|
72
|
+
return ignored.size ? entries.filter((entry) => !ignored.has(entry.path)) : entries;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Names deeper down that match: fd as Pi runs it in the project; shallow and
|
|
76
|
+
// without hidden folders elsewhere; without fd, the files git knows about.
|
|
77
|
+
async function searched(folder: string, query: string, options: { fd: string | null; project: boolean; atRoot: boolean; projectOnly?: boolean; signal?: AbortSignal }): Promise<Entry[]> {
|
|
78
|
+
if (options.fd) {
|
|
79
|
+
const args = ["--base-directory", folder, "--type", "f", "--type", "d", "--max-results", String(SEARCH_RESULTS), "--ignore-case", "--exclude", ".git",
|
|
80
|
+
...(options.project ? [...(options.projectOnly ? [] : ["--follow"]), "--hidden"] : ["--max-depth", String(OUTSIDE_DEPTH), "--exclude", "Library", "--exclude", "node_modules", ...(query.startsWith(".") ? ["--hidden"] : [])]),
|
|
81
|
+
...(query.includes("/") ? ["--full-path", query.split("/").filter(Boolean).map(either).join("/")] : [either(query)])];
|
|
82
|
+
return (await run(options.fd, args, options.signal)).split("\n").filter(Boolean)
|
|
83
|
+
.map((line) => ({ path: line.replace(/^\.\//, "").replace(/\/$/, ""), directory: line.endsWith("/") }));
|
|
84
|
+
}
|
|
85
|
+
if (!options.atRoot) return [];
|
|
86
|
+
const files = (await run(GIT, ["-C", folder, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], options.signal))
|
|
87
|
+
.split("\0").filter(Boolean).slice(0, 50_000);
|
|
88
|
+
const folders = new Set<string>();
|
|
89
|
+
for (const file of files) for (let dir = path.posix.dirname(file); dir !== "."; dir = path.posix.dirname(dir)) folders.add(dir);
|
|
90
|
+
const wanted = fold(query);
|
|
91
|
+
return [...[...folders].map((dir) => ({ path: dir, directory: true })), ...files.map((file) => ({ path: file, directory: false }))]
|
|
92
|
+
.filter((entry) => fold(entry.path).includes(wanted));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function suggestPaths(options: { root: string; query: string; home?: string; fd?: string | null; projectOnly?: boolean; isAllowed?: (path: string) => boolean; signal?: AbortSignal }): Promise<{ suggestions: PathSuggestion[] }> {
|
|
96
|
+
const home = options.home ?? os.homedir(), fd = options.fd === undefined ? findFd(home) : options.fd;
|
|
97
|
+
const root = fs.realpathSync(options.root);
|
|
98
|
+
const typed = options.query.trim() === "~" ? "~/" : options.query.trim();
|
|
99
|
+
// Text up to the last slash names a folder: search under it (Pi's scoped
|
|
100
|
+
// search). When it is not a folder, the whole text is matched in the project.
|
|
101
|
+
if (options.projectOnly && (path.isAbsolute(typed) || typed.startsWith("~") || /[\\\x00-\x1f]/.test(typed)
|
|
102
|
+
|| typed.split("/").includes(".."))) return { suggestions: [] };
|
|
103
|
+
const cut = typed.lastIndexOf("/") + 1;
|
|
104
|
+
let base = typed.slice(0, cut), name = typed.slice(cut), folder = root;
|
|
105
|
+
if (base) {
|
|
106
|
+
try {
|
|
107
|
+
const candidate = fs.realpathSync(base.startsWith("~/") ? path.join(home, base.slice(2)) : path.resolve(root, base));
|
|
108
|
+
if (fs.statSync(candidate).isDirectory()) folder = candidate; else { base = ""; name = typed; }
|
|
109
|
+
} catch { base = ""; name = typed; }
|
|
110
|
+
}
|
|
111
|
+
const project = inside(folder, root);
|
|
112
|
+
if (options.projectOnly && (!inside(folder, root) || (base && (() => {
|
|
113
|
+
try { return inspectWorkspaceEntry(root, base.replace(/\/$/, "")).isSymbolicLink(); } catch { return true; }
|
|
114
|
+
})()))) return { suggestions: [] };
|
|
115
|
+
const near = await listed(folder, options.signal, options.projectOnly);
|
|
116
|
+
const deep = name ? await searched(folder, name, { fd, project, atRoot: folder === root, projectOnly: options.projectOnly, signal: options.signal }) : [];
|
|
117
|
+
const seen = new Set<string>(), entries: Array<Entry & { score: number }> = [];
|
|
118
|
+
for (const entry of [...near, ...deep]) {
|
|
119
|
+
if (options.projectOnly) {
|
|
120
|
+
try {
|
|
121
|
+
const relative = `${base}${entry.path}`;
|
|
122
|
+
if (inspectWorkspaceEntry(root, relative).isSymbolicLink() || options.isAllowed && !options.isAllowed(relative)) continue;
|
|
123
|
+
} catch { continue; }
|
|
124
|
+
}
|
|
125
|
+
if (seen.has(entry.path)) continue; seen.add(entry.path);
|
|
126
|
+
// Hidden entries (.github, .ssh, .zshrc) for a name typed with a dot, and
|
|
127
|
+
// in the project for any name typed, as Pi's search finds them there.
|
|
128
|
+
if (entry.path.split("/").some((part) => part.startsWith(".")) && !name.startsWith(".") && !(project && name)) continue;
|
|
129
|
+
const value = name ? score(entry.path, name.split("/").filter(Boolean).at(-1) ?? name, entry.directory) : 1;
|
|
130
|
+
if (value > 0) entries.push({ ...entry, score: value });
|
|
131
|
+
}
|
|
132
|
+
const depth = (entry: Entry) => entry.path.split("/").length;
|
|
133
|
+
entries.sort((a, b) => b.score - a.score || depth(a) - depth(b)
|
|
134
|
+
|| (name ? a.path.length - b.path.length : Number(b.directory) - Number(a.directory)) || a.path.localeCompare(b.path));
|
|
135
|
+
return { suggestions: entries.slice(0, LIMIT).map((entry) => ({ value: `${base}${entry.path}${entry.directory ? "/" : ""}`,
|
|
136
|
+
label: path.basename(entry.path) + (entry.directory ? "/" : ""), detail: `${base}${entry.path}`, kind: entry.directory ? "directory" : "file" })) };
|
|
137
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sessionMutationObserver } from "../runtime/inspection/session-mutation-observer.ts";
|
|
1
2
|
import { unresolvedFocusedVerification } from "./acceptance-focused-verification.js";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import os from "node:os";
|
|
@@ -21,10 +22,10 @@ import {
|
|
|
21
22
|
import { grantedSourceCheckoutRootForPath, piToolPathInput, referenceReadRootForPath, shellTouchesGrantedSourceCheckout } from "./source-checkout-paths.ts";
|
|
22
23
|
import { experimentLoopDecision } from "./experiment-loop-policy.ts";
|
|
23
24
|
import { extractShellWritePathCandidates } from "./shell-write-targets.js";
|
|
24
|
-
import { isProjectMutatingShellCommand } from "./readonly-inline-inspection.ts";
|
|
25
|
+
import { isProjectMutatingShellCommand, isReadOnlyTaskShellCommand } from "./readonly-inline-inspection.ts";
|
|
25
26
|
import { commandMatchesVerifyPlan, createBashResultLedger, findMatchingObservedBashResult, readObservedBashResults } from "./runtime-evidence.js";
|
|
26
27
|
import { findPackageRoot, findPlatformRoot, readJsonFile } from "./guard-io.js";
|
|
27
|
-
import { registerCompanySessionGuard } from "./company-session-guard.ts";
|
|
28
|
+
import { isCompanySession, registerCompanySessionGuard } from "./company-session-guard.ts";
|
|
28
29
|
import {
|
|
29
30
|
extractDocument,
|
|
30
31
|
resolveDocumentPath,
|
|
@@ -93,6 +94,8 @@ import { ensurePrivateStateDirectory, resolveLocalStatePath } from "./local-stat
|
|
|
93
94
|
import { hasDurableContextEvidence } from "./context-evidence.js";
|
|
94
95
|
import { piApprovalBroker, type ApprovalActionDraft, type ApprovalUnavailableFallback } from "../runtime/inspection/approval-broker.ts";
|
|
95
96
|
import { createSourceMutationGuardBindings } from "../runtime/policy/source-mutation-guard-binding.ts";
|
|
97
|
+
import { createWorkspaceWriteAuthorityBindings } from "../runtime/policy/workspace-write-authority.ts";
|
|
98
|
+
import { registerWorkspaceMemberEdits } from "../runtime/session/workspace-member-edits.mjs";
|
|
96
99
|
import {
|
|
97
100
|
TOOL_RESULT_CAPTURE_MAX_CHARS,
|
|
98
101
|
TOOL_RESULT_COMPACT_CHAR_THRESHOLD,
|
|
@@ -136,6 +139,8 @@ import { registerAgentStartHook } from "../runtime/hooks/agent-start-hook.ts";
|
|
|
136
139
|
import { registerSessionHooks } from "../runtime/hooks/session-hooks.ts";
|
|
137
140
|
import { registerSessionStartHook } from "../runtime/hooks/session-start-hook.ts";
|
|
138
141
|
import { registerToolCallHook } from "../runtime/hooks/tool-call-hook.ts";
|
|
142
|
+
import { PersonalCheckpointRuntime } from "../runtime/checkpoints/personal-runtime.ts";
|
|
143
|
+
import { checkpointOwnershipError } from "../runtime/checkpoints/turn-checkpoints.mjs";
|
|
139
144
|
import { registerToolResultHook } from "../runtime/hooks/tool-result-hook.ts";
|
|
140
145
|
import { readRuntimeVersionMetadata, RuntimeSnapshotCapture } from "../runtime/model/runtime-snapshot.ts";
|
|
141
146
|
import { recordRuntimeSnapshotTelemetry } from "../runtime/model/snapshot-telemetry.ts";
|
|
@@ -339,7 +344,12 @@ const PERMISSION_PROFILE_ALIASES: Record<string, PermissionProfileMode> = {
|
|
|
339
344
|
"trusted-full-access": "trusted-full-access",
|
|
340
345
|
"trusted_full_access": "trusted-full-access",
|
|
341
346
|
"danger-full-access": "trusted-full-access",
|
|
342
|
-
"danger_full_access": "trusted-full-access"
|
|
347
|
+
"danger_full_access": "trusted-full-access",
|
|
348
|
+
bypass: "bypass",
|
|
349
|
+
yolo: "bypass",
|
|
350
|
+
"dangerously-bypass": "bypass",
|
|
351
|
+
"danger-bypass": "bypass",
|
|
352
|
+
"dangerously-bypass-approvals-and-sandbox": "bypass"
|
|
343
353
|
};
|
|
344
354
|
const READ_ONLY_TOOL_NAMES = new Set(["read", "grep", "find", "ls"]);
|
|
345
355
|
const WRITE_TOOL_NAMES = new Set(["write", "edit"]);
|
|
@@ -1121,6 +1131,7 @@ function normalizePermissionProfileMode(value: unknown): PermissionProfileMode |
|
|
|
1121
1131
|
function runtimeEquivalentForPermissionProfile(mode: PermissionProfileMode): string {
|
|
1122
1132
|
if (mode === "read-only") return "sandbox_mode=read-only";
|
|
1123
1133
|
if (mode === "trusted-full-access") return "sandbox_mode=danger-full-access + approval_policy=never, with Piagent protected-path and human-action gates still enforced";
|
|
1134
|
+
if (mode === "bypass") return "sandbox_mode=danger-full-access + approval_policy=never; every Piagent gate off (output redaction and turn checkpoints remain)";
|
|
1124
1135
|
return "sandbox_mode=workspace-write + approval_policy=on-request";
|
|
1125
1136
|
}
|
|
1126
1137
|
|
|
@@ -1198,6 +1209,9 @@ function isPiagentTool(toolName: string): boolean {
|
|
|
1198
1209
|
return toolName.startsWith("piagent_");
|
|
1199
1210
|
}
|
|
1200
1211
|
|
|
1212
|
+
// Full access or more: the profile does not narrow reads or the tool registry.
|
|
1213
|
+
const broadAccess = (mode: PermissionProfileMode) => mode === "trusted-full-access" || mode === "bypass";
|
|
1214
|
+
|
|
1201
1215
|
function evaluatePermissionProfileToolAccess(
|
|
1202
1216
|
toolName: string,
|
|
1203
1217
|
permissionProfile: ResolvedPermissionProfile
|
|
@@ -3453,7 +3467,29 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3453
3467
|
// task-contract retirement, so it uses one session-wide key; the guard itself
|
|
3454
3468
|
// is already per session.
|
|
3455
3469
|
const FREEFORM_EDIT_SCOPE = "freeform";
|
|
3456
|
-
const
|
|
3470
|
+
const workspaceWritable = (ctx: ExtensionContext, profile: ProjectProfile) => {
|
|
3471
|
+
const permission = resolvePermissionProfile(profile, policy, permissionOverrideFromContext(ctx));
|
|
3472
|
+
return permission.mode !== "read-only" && !permission.warning;
|
|
3473
|
+
};
|
|
3474
|
+
const sourceMutationGuardBindings = createSourceMutationGuardBindings(policy, loadProfileFromContext, workspaceWritable);
|
|
3475
|
+
registerWorkspaceMemberEdits(pi);
|
|
3476
|
+
const workspaceWriteBindings = createWorkspaceWriteAuthorityBindings(policy, loadProfileFromContext, workspaceWritable);
|
|
3477
|
+
const personalCheckpoints = new PersonalCheckpointRuntime(ctx => ({
|
|
3478
|
+
policy: policy as unknown as Record<string, unknown>,
|
|
3479
|
+
profile: loadProfileFromContext(ctx) as unknown as Record<string, unknown>,
|
|
3480
|
+
readOnly: !workspaceWritable(ctx, loadProfileFromContext(ctx))
|
|
3481
|
+
}));
|
|
3482
|
+
registerRuntimeCommand(pi, "undo", {
|
|
3483
|
+
description: "Hoàn tác thay đổi của lượt trước; giữ nguyên commit.",
|
|
3484
|
+
handler: async (_args, ctx) => {
|
|
3485
|
+
try {
|
|
3486
|
+
const undo = await personalCheckpoints.undo(ctx);
|
|
3487
|
+
emitRuntimeMessage(ctx, "agent-watch-process", undo.text, { phase: "undo", undo });
|
|
3488
|
+
} catch (error) {
|
|
3489
|
+
emitRuntimeMessage(ctx, "agent-watch-process", `Không hoàn tác: ${redactText(String(error))}`, { phase: "undo", undo: { result: "failed" } });
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
});
|
|
3457
3493
|
const runtimeSnapshotCapture = new RuntimeSnapshotCapture(), runtimeVersions = readRuntimeVersionMetadata(PLATFORM_ROOT);
|
|
3458
3494
|
const solverShadow = solverMode === "off" ? undefined : new SolverShadowRuntime(solverMode);
|
|
3459
3495
|
const modelRouteRuntime = new ModelRouteRuntime(parentRoutingMode, routingObjective);
|
|
@@ -3699,6 +3735,8 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3699
3735
|
ctx.ui.notify("Piagent Fast mode is enabled, but the active provider is not OpenAI Codex; no service tier was changed.", "warning");
|
|
3700
3736
|
}
|
|
3701
3737
|
sourceMutationGuardBindings.bind(ctx);
|
|
3738
|
+
workspaceWriteBindings.bind(ctx);
|
|
3739
|
+
personalCheckpoints.bind(ctx);
|
|
3702
3740
|
await activityInspector.refresh(ctx);
|
|
3703
3741
|
}
|
|
3704
3742
|
});
|
|
@@ -3713,13 +3751,18 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3713
3751
|
appendTrace,
|
|
3714
3752
|
flushObservedTaskContext: noTask,
|
|
3715
3753
|
onTurnEnd: activityInspector.refresh,
|
|
3716
|
-
onAgentSettled: activityInspector.refresh,
|
|
3754
|
+
onAgentSettled: async ctx => { await personalCheckpoints.finish(ctx); await activityInspector.refresh(ctx); },
|
|
3717
3755
|
beforeShutdown: async (ctx) => {
|
|
3756
|
+
try { await personalCheckpoints.close(ctx); }
|
|
3757
|
+
finally {
|
|
3718
3758
|
serviceTierRuntime.forget(ctx);
|
|
3719
3759
|
sourceMutationGuardBindings.unbind(ctx);
|
|
3760
|
+
workspaceWriteBindings.unbind(ctx);
|
|
3761
|
+
sessionMutationObserver.clear(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
3720
3762
|
editFreshnessGuard.clear(ctx);
|
|
3721
3763
|
activityInspector.dispose(ctx);
|
|
3722
3764
|
sessionCapabilityDigests.delete(`${ctx.cwd}\0${ctx.sessionManager.getSessionId()}`);
|
|
3765
|
+
}
|
|
3723
3766
|
}
|
|
3724
3767
|
});
|
|
3725
3768
|
registerTaskUsageHooks(pi, { activeTask: noTask, telemetry });
|
|
@@ -3742,7 +3785,7 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3742
3785
|
}),
|
|
3743
3786
|
readProtectedPaths: effectiveProtectedPaths(policy, profile).readProtectedPaths,
|
|
3744
3787
|
filesystemRead: capabilityState.filesystemRead,
|
|
3745
|
-
enforceFilesystemRead: permissionProfile.mode
|
|
3788
|
+
enforceFilesystemRead: !broadAccess(permissionProfile.mode)
|
|
3746
3789
|
};
|
|
3747
3790
|
},
|
|
3748
3791
|
activateToolGroups,
|
|
@@ -3795,6 +3838,8 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3795
3838
|
disposition: result.disposition, reasonCode: result.reasonCode, calls: result.calls, tokens: result.tokens });
|
|
3796
3839
|
},
|
|
3797
3840
|
observeEditFreshness: (ctx, event, metadata) => {
|
|
3841
|
+
sessionMutationObserver.finish(ctx.cwd, ctx.sessionManager.getSessionId(), event.toolCallId ?? "", event.toolName, metadata.successful,
|
|
3842
|
+
{ policy: policy as unknown as Record<string, unknown>, profile: loadProfileFromContext(ctx) as unknown as Record<string, unknown> });
|
|
3798
3843
|
if (!metadata.successful) return;
|
|
3799
3844
|
const protectedPaths = effectiveProtectedPaths(policy, loadProfileFromContext(ctx)).readProtectedPaths;
|
|
3800
3845
|
const source = event.toolName === "read" ? "read" : ["edit", "write", "apply_patch"].includes(event.toolName) ? "mutation" : undefined;
|
|
@@ -3805,11 +3850,35 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3805
3850
|
},
|
|
3806
3851
|
});
|
|
3807
3852
|
|
|
3853
|
+
// Bypass is for personal sessions only: a company session keeps Studio's rules.
|
|
3854
|
+
const bypassActive = (ctx: ExtensionContext) => !isCompanySession(ctx) && ctx.model?.provider !== "agent_watch_managed"
|
|
3855
|
+
&& resolvePermissionProfile(loadProfileFromContext(ctx), policy, permissionOverrideFromContext(ctx)).mode === "bypass";
|
|
3808
3856
|
registerToolCallHook(pi, {
|
|
3809
3857
|
extractLikelyPath: extractLikelyPathFromInput,
|
|
3810
3858
|
redactText,
|
|
3811
3859
|
telemetry,
|
|
3812
3860
|
activity: recordActivity,
|
|
3861
|
+
beforeStart: async (event, ctx) => {
|
|
3862
|
+
const input = isPlainRecord(event.input) ? event.input : {};
|
|
3863
|
+
const target = authorizationMutationTargetInspection(ctx.cwd, event.toolName, input);
|
|
3864
|
+
let mutating = ["write", "edit", "apply_patch"].includes(event.toolName) || target.exactLocalPatchExecutor;
|
|
3865
|
+
if (SHELL_TOOL_NAMES.has(event.toolName)) {
|
|
3866
|
+
const shell = extractShellCommandInput(input);
|
|
3867
|
+
if (shell.command) {
|
|
3868
|
+
// Anything not provably read-only may change files (`prettier --write`,
|
|
3869
|
+
// `git stash`, a generator): it gets the turn's checkpoint first.
|
|
3870
|
+
const command = normalizeShellCommandForPolicy(shell.command), segments = evaluateExecPolicy(command, loadProfileFromContext(ctx), policy).segments;
|
|
3871
|
+
mutating ||= isProjectMutatingShellCommand(command, segments, ctx.cwd) || !isReadOnlyTaskShellCommand(command, segments, ctx.cwd);
|
|
3872
|
+
}
|
|
3873
|
+
}
|
|
3874
|
+
if (!mutating) return;
|
|
3875
|
+
// A missing checkpoint costs this turn its undo, never the edit itself;
|
|
3876
|
+
// only another live owner of the session's history makes the edit wait.
|
|
3877
|
+
try { await personalCheckpoints.beforeMutation(ctx); }
|
|
3878
|
+
catch (error) {
|
|
3879
|
+
if (checkpointOwnershipError(error)) return { block: true, reason: `Checkpoint failed before tool start: ${redactText(String(error))}` };
|
|
3880
|
+
}
|
|
3881
|
+
},
|
|
3813
3882
|
beforeAuthorize: (event, ctx) => {
|
|
3814
3883
|
const toolInput = isPlainRecord(event.input) ? event.input : {};
|
|
3815
3884
|
const helperPreflight = evaluateDirectSubagentDispatch({ toolName: event.toolName, toolInput, cwd: ctx.cwd, helpersMode: helpersMode(),
|
|
@@ -3819,9 +3888,10 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3819
3888
|
role: helperPreflight.role, requestCount: helperPreflight.requestCount });
|
|
3820
3889
|
return { block: true, reason: `Blocked ${event.toolName}: ${helperPreflight.reason} Do not retry this helper; continue in the parent.` };
|
|
3821
3890
|
}
|
|
3891
|
+
if (bypassActive(ctx)) return;
|
|
3822
3892
|
const targetInspection = authorizationMutationTargetInspection(ctx.cwd, event.toolName, toolInput);
|
|
3823
3893
|
if (targetInspection.reason) return { block: true, reason: `Blocked ${event.toolName}: ${targetInspection.reason}.` };
|
|
3824
|
-
if (event.toolName === "edit" || targetInspection.exactLocalPatchExecutor) {
|
|
3894
|
+
if (event.toolName === "edit" || event.toolName === "write" || targetInspection.exactLocalPatchExecutor) {
|
|
3825
3895
|
const freshness = editFreshnessGuard.evaluate(ctx, FREEFORM_EDIT_SCOPE, targetInspection.targets);
|
|
3826
3896
|
if (freshness.decision === "stale") {
|
|
3827
3897
|
telemetry(ctx, { event: "edit_freshness_stale", toolCallId: event.toolCallId, enforce: freshness.enforce, paths: freshness.stalePaths.map(redactText) });
|
|
@@ -3831,6 +3901,10 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3831
3901
|
return;
|
|
3832
3902
|
},
|
|
3833
3903
|
afterAuthorized: (event, ctx, metadata) => {
|
|
3904
|
+
const input = isPlainRecord(event.input) ? event.input : {};
|
|
3905
|
+
sessionMutationObserver.begin(ctx.cwd, ctx.sessionManager.getSessionId(), metadata.toolCallId, event.toolName, input,
|
|
3906
|
+
authorizationMutationTargetInspection(ctx.cwd, event.toolName, input).targets,
|
|
3907
|
+
{ policy: policy as unknown as Record<string, unknown>, profile: loadProfileFromContext(ctx) as unknown as Record<string, unknown> });
|
|
3834
3908
|
const helperPreflight = evaluateDirectSubagentDispatch({ toolName: event.toolName, toolInput: event.input, cwd: ctx.cwd, helpersMode: helpersMode(),
|
|
3835
3909
|
taskPending: false, dispatchAuthority: false, durableSubagentState: undefined });
|
|
3836
3910
|
if (helperPreflight.applicable && helperPreflight.dispatch && helperPreflight.allowed) {
|
|
@@ -3840,6 +3914,10 @@ export default function piagentGuard(pi: ExtensionAPI) {
|
|
|
3840
3914
|
return undefined;
|
|
3841
3915
|
},
|
|
3842
3916
|
authorize: async (event, ctx) => {
|
|
3917
|
+
// Bypass: no Piagent gate asks or blocks, as with Codex's
|
|
3918
|
+
// --dangerously-bypass-approvals-and-sandbox. Tool output is still
|
|
3919
|
+
// redacted and the turn still gets its checkpoint; neither stops a command.
|
|
3920
|
+
if (bypassActive(ctx)) return;
|
|
3843
3921
|
const preInput = isPlainRecord(event.input) ? event.input : {};
|
|
3844
3922
|
const authorizationTargetInspection = authorizationMutationTargetInspection(ctx.cwd, event.toolName, preInput);
|
|
3845
3923
|
if (authorizationTargetInspection.reason) {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { suggestPaths } from "./path-suggestions.ts";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { effectiveProtectedPaths, matchesProtectedPath } from "./workspace-policy.js";
|
|
8
|
+
import { inspectWorkspaceEntry, readWorkspaceFile, WorkspaceReadError } from "../security/workspace-file-reader.ts";
|
|
9
|
+
|
|
10
|
+
export type WorkspacePolicy = { policy: Record<string, unknown>; profile: Record<string, unknown>; readOnly?: boolean };
|
|
11
|
+
export type WorkspaceEntry = { name: string; path: string; kind: "file" | "directory" | "symlink" | "other";
|
|
12
|
+
protected: boolean; readOnly: boolean; ignored: boolean };
|
|
13
|
+
const MAX_ENTRIES = 1_000, MAX_FILE_BYTES = 1 << 20;
|
|
14
|
+
const git = process.platform === "darwin" ? "/usr/bin/git" : "git";
|
|
15
|
+
|
|
16
|
+
function validPath(value: string, rootAllowed = false): void {
|
|
17
|
+
if (rootAllowed && value === "") return;
|
|
18
|
+
if (!value || value.length > 4096 || /[\\\x00-\x1f\x7f]/.test(value) || path.isAbsolute(value)
|
|
19
|
+
|| value.split("/").some(part => !part || part === "." || part === "..")) {
|
|
20
|
+
throw new WorkspaceReadError("outside-root", "Workspace path is not a safe relative path");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Ancestors inherit restrictions too: a protected folder cannot be opened via
|
|
25
|
+
// a direct file URL that avoids the explorer's locked entry.
|
|
26
|
+
export function workspacePathFlags(value: string, options: WorkspacePolicy): { protected: boolean; readOnly: boolean } {
|
|
27
|
+
const effective = effectiveProtectedPaths(options.policy, options.profile);
|
|
28
|
+
const parts = value.split("/");
|
|
29
|
+
const candidates = parts.map((_, index) => parts.slice(0, index + 1).join("/"));
|
|
30
|
+
return {
|
|
31
|
+
protected: candidates.some(candidate => !!matchesProtectedPath(candidate, effective.readProtectedPaths)),
|
|
32
|
+
readOnly: !!options.readOnly || candidates.some(candidate => !!matchesProtectedPath(candidate, effective.writeProtectedPaths))
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function directory(root: string, relative: string): { absolute: string; stat: fs.Stats } {
|
|
37
|
+
validPath(relative, true);
|
|
38
|
+
const absolute = relative ? path.join(root, relative) : root;
|
|
39
|
+
const stat = relative ? inspectWorkspaceEntry(root, relative) : fs.lstatSync(root);
|
|
40
|
+
if (stat.isSymbolicLink()) throw new WorkspaceReadError("symlink-ancestor", "Workspace directory is a symbolic link");
|
|
41
|
+
if (!stat.isDirectory()) throw new WorkspaceReadError("not-file", "Workspace entry is not a directory");
|
|
42
|
+
return { absolute, stat };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function inspectWorkspaceTree(rootPath: string, relative: string, options: WorkspacePolicy, limit = MAX_ENTRIES) {
|
|
46
|
+
const root = fs.realpathSync(rootPath);
|
|
47
|
+
const folder = directory(root, relative);
|
|
48
|
+
if (relative && workspacePathFlags(relative, options).protected) throw new Error("workspace-path-protected");
|
|
49
|
+
const cap = Math.max(1, Math.min(MAX_ENTRIES, Number.isFinite(limit) ? Math.floor(limit) : MAX_ENTRIES));
|
|
50
|
+
const names = new Set<string>(), directories = new Set<string>();
|
|
51
|
+
let truncated = false, source: "git" | "directory" = "git";
|
|
52
|
+
let output: string | null = null;
|
|
53
|
+
try {
|
|
54
|
+
output = execFileSync(git, ["-C", folder.absolute, "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "."],
|
|
55
|
+
{ encoding: "utf8", timeout: 3000, maxBuffer: 4 << 20, stdio: ["ignore", "pipe", "ignore"] });
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const failure = error as NodeJS.ErrnoException & { stdout?: string | Buffer };
|
|
58
|
+
if (failure.code === "ENOBUFS" && failure.stdout) {
|
|
59
|
+
const partial = String(failure.stdout);
|
|
60
|
+
output = partial.slice(0, partial.lastIndexOf("\0") + 1);
|
|
61
|
+
truncated = true;
|
|
62
|
+
} else {
|
|
63
|
+
let repository = false;
|
|
64
|
+
try { repository = execFileSync(git, ["-C", root, "rev-parse", "--is-inside-work-tree"],
|
|
65
|
+
{ encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"] }).trim() === "true"; } catch { /* no repository */ }
|
|
66
|
+
// A Git timeout must not turn ignored paths into visible fallback entries.
|
|
67
|
+
if (repository) throw new Error("workspace-tree-unavailable");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (output !== null) {
|
|
71
|
+
for (const entry of output.split("\0")) {
|
|
72
|
+
const name = entry.split("/")[0];
|
|
73
|
+
if (!name || name === ".git") continue;
|
|
74
|
+
names.add(name);
|
|
75
|
+
if (entry.includes("/")) directories.add(name);
|
|
76
|
+
if (names.size > cap) { truncated = true; break; }
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
// Even without Git, never materialize an unbounded directory listing.
|
|
80
|
+
source = "directory";
|
|
81
|
+
const handle = fs.opendirSync(folder.absolute);
|
|
82
|
+
try {
|
|
83
|
+
let entry: fs.Dirent | null;
|
|
84
|
+
while ((entry = handle.readSync())) {
|
|
85
|
+
if (entry.name === ".git") continue;
|
|
86
|
+
names.add(entry.name);
|
|
87
|
+
if (entry.isDirectory()) directories.add(entry.name);
|
|
88
|
+
if (names.size > cap) { truncated = true; break; }
|
|
89
|
+
}
|
|
90
|
+
} finally { handle.closeSync(); }
|
|
91
|
+
}
|
|
92
|
+
const entries: WorkspaceEntry[] = [];
|
|
93
|
+
for (const name of [...names].slice(0, cap)) {
|
|
94
|
+
const entryPath = relative ? `${relative}/${name}` : name;
|
|
95
|
+
try {
|
|
96
|
+
validPath(entryPath);
|
|
97
|
+
const flags = workspacePathFlags(entryPath, options);
|
|
98
|
+
if (flags.protected) {
|
|
99
|
+
entries.push({ name, path: entryPath, kind: directories.has(name) ? "directory" : "file", ...flags, ignored: false });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const stat = inspectWorkspaceEntry(root, entryPath);
|
|
103
|
+
const kind = stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : stat.isFile() ? "file" : "other";
|
|
104
|
+
entries.push({ name, path: entryPath, kind, ...workspacePathFlags(entryPath, options), ignored: false });
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const after = directory(root, relative).stat;
|
|
111
|
+
if (after.dev !== folder.stat.dev || after.ino !== folder.stat.ino || after.mtimeMs !== folder.stat.mtimeMs) {
|
|
112
|
+
throw new WorkspaceReadError("changed-during-read", "Workspace directory changed during inspection");
|
|
113
|
+
}
|
|
114
|
+
entries.sort((a, b) => Number(b.kind === "directory") - Number(a.kind === "directory") || a.name.localeCompare(b.name));
|
|
115
|
+
return { path: relative, entries, truncated, source };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const LANGUAGES: Record<string, string> = { ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
|
|
119
|
+
json: "json", md: "markdown", css: "css", html: "html", py: "python", rs: "rust", go: "go", sh: "shell", yaml: "yaml", yml: "yaml" };
|
|
120
|
+
|
|
121
|
+
export function inspectWorkspaceFile(root: string, relative: string, options: WorkspacePolicy) {
|
|
122
|
+
validPath(relative);
|
|
123
|
+
const flags = workspacePathFlags(relative, options);
|
|
124
|
+
const base = { path: relative, language: LANGUAGES[relative.split(".").at(-1)?.toLowerCase() ?? ""] ?? "text", ...flags,
|
|
125
|
+
binary: false, oversized: false, content: null as string | null, digest: null as string | null, revision: null as string | null };
|
|
126
|
+
// Do not stat or hash protected files: even their digest would expose data.
|
|
127
|
+
if (flags.protected) return base;
|
|
128
|
+
let bytes: Buffer;
|
|
129
|
+
try { bytes = readWorkspaceFile(root, relative, MAX_FILE_BYTES); }
|
|
130
|
+
catch (error) { if (error instanceof WorkspaceReadError && error.code === "oversized") return { ...base, oversized: true }; throw error; }
|
|
131
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
132
|
+
let content: string;
|
|
133
|
+
try {
|
|
134
|
+
if (bytes.includes(0)) return { ...base, binary: true, digest, revision: `sha256:${digest}` };
|
|
135
|
+
content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
136
|
+
} catch { return { ...base, binary: true, digest, revision: `sha256:${digest}` }; }
|
|
137
|
+
return { ...base, content, digest, revision: `sha256:${digest}` };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function inspectWorkspacePaths(root: string, query: string, options: WorkspacePolicy) {
|
|
141
|
+
if (query.length > 512 || /[\x00-\x1f\x7f]/.test(query)) throw new Error("workspace-query-invalid");
|
|
142
|
+
const result = await suggestPaths({ root, query, projectOnly: true,
|
|
143
|
+
isAllowed: relative => !workspacePathFlags(relative, options).protected });
|
|
144
|
+
return { query, ...result, truncated: result.suggestions.length >= 20 };
|
|
145
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function effectiveProtectedPaths(policy?: Record<string, unknown>, profile?: Record<string, unknown>): {
|
|
2
|
+
readProtectedPaths: string[]; writeProtectedPaths: string[]; shellProtectedPaths: string[]; readOnlyPaths: string[];
|
|
3
|
+
};
|
|
4
|
+
export function matchesProtectedPath(candidate: string, patterns: string[]): string | undefined;
|
|
5
|
+
export function loadProjectContextIndexPolicy(platformRoot: string, projectPath: string, options?: { profilePath?: string }): {
|
|
6
|
+
policy: Record<string, unknown>; profile: Record<string, unknown>; profilePath: string; profileSource: string; excludePatterns: string[];
|
|
7
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { searchContextIndexV2 } from './context-engine.js';
|
|
3
|
+
// Isolate synchronous SQLite work so the caller can enforce its deadline.
|
|
4
|
+
try {
|
|
5
|
+
const result = await searchContextIndexV2(workerData.root, workerData.query,
|
|
6
|
+
{ excludePatterns: workerData.excludePatterns, limit: 50 });
|
|
7
|
+
parentPort.postMessage({ paths: result.results.map(row => row.path), available: result.status.exists });
|
|
8
|
+
} catch { parentPort.postMessage({ paths: [], available: false }); }
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Worker } from "node:worker_threads";
|
|
5
|
+
import { inspectWorkspaceFile, workspacePathFlags, type WorkspacePolicy } from "./workspace-inspection.ts";
|
|
6
|
+
import { effectiveProtectedPaths } from "./workspace-policy.js";
|
|
7
|
+
|
|
8
|
+
export type WorkspaceMatch = { path: string; line: number; column: number; preview: string };
|
|
9
|
+
const TIMEOUT = 3000, LIMIT = 100;
|
|
10
|
+
function findRipgrep(): string | null {
|
|
11
|
+
for (const dir of String(process.env.PATH ?? "").split(path.delimiter)) {
|
|
12
|
+
const file = path.join(dir, process.platform === "win32" ? "rg.exe" : "rg");
|
|
13
|
+
try { fs.accessSync(file, fs.constants.X_OK); return file; } catch { /* next */ }
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ftsCandidates(root: string, query: string, excludePatterns: string[], timeout: number) {
|
|
19
|
+
return new Promise<{ paths: string[]; available: boolean; timedOut: boolean }>((resolve) => {
|
|
20
|
+
const worker = new Worker(new URL("./workspace-search-index.mjs", import.meta.url), { workerData: { root, query, excludePatterns } });
|
|
21
|
+
let settled = false;
|
|
22
|
+
const finish = (value: { paths: string[]; available: boolean; timedOut: boolean }) => {
|
|
23
|
+
if (settled) return; settled = true; clearTimeout(timer); void worker.terminate(); resolve(value);
|
|
24
|
+
};
|
|
25
|
+
const timer = setTimeout(() => finish({ paths: [], available: true, timedOut: true }), timeout);
|
|
26
|
+
worker.once("message", value => finish({ ...value, timedOut: false }));
|
|
27
|
+
worker.once("error", () => finish({ paths: [], available: false, timedOut: false }));
|
|
28
|
+
worker.once("exit", () => finish({ paths: [], available: false, timedOut: false }));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function searchWorkspace(root: string, query: string, options: WorkspacePolicy, config: { rg?: string | null; timeout?: number } = {}) {
|
|
33
|
+
if (!query.trim() || query.length > 512 || /[\x00-\x1f\x7f]/.test(query)) throw new Error("workspace-query-invalid");
|
|
34
|
+
const deadline = Date.now() + Math.max(1, Math.min(TIMEOUT, config.timeout ?? TIMEOUT));
|
|
35
|
+
const effective = effectiveProtectedPaths(options.policy, options.profile);
|
|
36
|
+
const excludePatterns = [...new Set([...effective.readProtectedPaths, ".git/**"])];
|
|
37
|
+
const rg = config.rg === undefined ? findRipgrep() : config.rg;
|
|
38
|
+
let paths: string[] = [], truncated = false, available = true;
|
|
39
|
+
const backend = rg ? "ripgrep" : "fts";
|
|
40
|
+
if (rg) {
|
|
41
|
+
const result = await new Promise<{ output: string; limited: boolean }>((resolve) => {
|
|
42
|
+
execFile(rg, ["--files-with-matches", "--null", "--hidden", "--fixed-strings", "--max-filesize", "1M", "--no-messages",
|
|
43
|
+
...excludePatterns.flatMap(pattern => ["--iglob", `!${pattern}`, "--iglob", `!${pattern}/**`]), "--", query, "."],
|
|
44
|
+
{ cwd: root, timeout: Math.max(1, deadline - Date.now()), maxBuffer: 1 << 20, encoding: "utf8" }, (error, stdout) => {
|
|
45
|
+
resolve({ output: stdout, limited: !!error && (error as { code?: unknown }).code !== 1 });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
truncated = result.limited;
|
|
49
|
+
paths = result.output.slice(0, result.output.lastIndexOf("\0") + 1).split("\0").filter(Boolean).map(file => file.replace(/^\.\//, ""));
|
|
50
|
+
} else {
|
|
51
|
+
const result = await ftsCandidates(root, query, excludePatterns, Math.max(1, deadline - Date.now()));
|
|
52
|
+
paths = result.paths; available = result.available; truncated = result.timedOut || paths.length >= 50;
|
|
53
|
+
}
|
|
54
|
+
const matches: WorkspaceMatch[] = [];
|
|
55
|
+
for (const relative of [...new Set(paths)]) {
|
|
56
|
+
if (Date.now() >= deadline || matches.length >= LIMIT) { truncated = true; break; }
|
|
57
|
+
if (workspacePathFlags(relative, options).protected) continue;
|
|
58
|
+
try {
|
|
59
|
+
// Never return stale index text or rg output; apply the file reader's
|
|
60
|
+
// symlink/policy checks and calculate positions from current bytes.
|
|
61
|
+
const file = inspectWorkspaceFile(root, relative, options);
|
|
62
|
+
if (file.content === null) continue;
|
|
63
|
+
const lines = file.content.split(/\r?\n/);
|
|
64
|
+
for (let index = 0; index < lines.length; index++) {
|
|
65
|
+
if (Date.now() >= deadline || matches.length >= LIMIT) { truncated = true; break; }
|
|
66
|
+
const column = lines[index].indexOf(query);
|
|
67
|
+
if (column < 0) continue;
|
|
68
|
+
const start = Math.max(0, column - 80);
|
|
69
|
+
matches.push({ path: relative, line: index + 1, column: column + 1, preview: lines[index].slice(start, start + 400) });
|
|
70
|
+
}
|
|
71
|
+
} catch { /* moved, unsafe, or unreadable candidate */ }
|
|
72
|
+
}
|
|
73
|
+
return { query, matches, truncated, backend, available };
|
|
74
|
+
}
|