@piagent/platform 1.3.1 → 1.4.0
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 +39 -0
- package/README.md +3 -3
- package/architecture/layers.json +96 -1
- package/docs/README.md +1 -0
- package/docs/architecture.md +1 -0
- package/docs/authority-manifest.md +1 -0
- package/docs/auto-delegation-policy.md +1 -0
- package/docs/capability-packs.md +1 -0
- package/docs/command-reference-vietnamese.md +5 -4
- package/docs/context-engine.md +1 -0
- package/docs/context-window-policy.md +1 -0
- package/docs/distribution-standard.md +8 -7
- package/docs/herdr-workflow.md +1 -0
- package/docs/mcp-and-tools.md +14 -9
- package/docs/memory-policy.md +1 -0
- package/docs/model-options.md +1 -0
- package/docs/oauth-providers.md +1 -0
- package/docs/operator-manual-vietnamese.md +15 -12
- package/docs/package-architecture-notes.md +1 -0
- package/docs/project-adapters.md +1 -0
- package/docs/project-onboarding.md +1 -0
- package/docs/publishing-for-teams.md +1 -0
- package/docs/quality-benchmark.md +1 -0
- package/docs/quickstart-vietnamese.md +3 -2
- package/docs/release-install-policy.md +7 -6
- package/docs/runtime-harness-standard.md +1 -0
- package/docs/runtime-policy-design.md +1 -0
- package/docs/runtime-quality-baseline.md +1 -0
- package/docs/security-redaction-benchmark.md +1 -0
- package/docs/security-threat-model.md +2 -1
- package/docs/subagent-orchestration-capabilities.md +1 -0
- package/docs/subagents-and-multiagent.md +1 -0
- package/docs/task-implementation-contract.md +1 -0
- package/docs/task-lifecycle-vietnamese.md +1 -0
- package/docs/team-onboarding.md +3 -2
- package/docs/third-party-neutrality.md +26 -0
- package/docs/usage-observability.md +1 -0
- package/docs/vercel-docs-site.md +1 -0
- package/docs/workflow-recipes.md +1 -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/context-engine.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/index.html +1 -1
- 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 +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +1 -1
- 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 +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/fixtures/mutation-provenance-record.invalid.json +17 -0
- package/evals/fixtures/mutation-provenance-record.valid.json +27 -0
- package/evals/fixtures/piagent-webui/approval-v1.invalid.json +54 -0
- package/evals/fixtures/piagent-webui/approval-v1.valid.json +58 -0
- package/evals/fixtures/piagent-webui/attachment-v1.invalid.json +25 -0
- package/evals/fixtures/piagent-webui/attachment-v1.valid.json +25 -0
- package/evals/fixtures/piagent-webui/capabilities-v1.invalid.json +51 -0
- package/evals/fixtures/piagent-webui/capabilities-v1.valid.json +122 -0
- package/evals/fixtures/piagent-webui/commit-summary-v1.invalid.json +10 -0
- package/evals/fixtures/piagent-webui/commit-summary-v1.valid.json +25 -0
- package/evals/fixtures/piagent-webui/control-command-v1.invalid.json +37 -0
- package/evals/fixtures/piagent-webui/control-command-v1.valid.json +35 -0
- package/evals/fixtures/piagent-webui/diff-v1.invalid.json +13 -0
- package/evals/fixtures/piagent-webui/diff-v1.valid.json +141 -0
- package/evals/fixtures/piagent-webui/gateway-capabilities-v1.invalid.json +35 -0
- package/evals/fixtures/piagent-webui/gateway-capabilities-v1.valid.json +35 -0
- package/evals/fixtures/piagent-webui/gateway-protocol-v1.invalid.json +14 -0
- package/evals/fixtures/piagent-webui/gateway-protocol-v1.valid.json +14 -0
- package/evals/fixtures/piagent-webui/handoff-history-v1.invalid.json +16 -0
- package/evals/fixtures/piagent-webui/handoff-history-v1.valid.json +16 -0
- package/evals/fixtures/piagent-webui/model-catalog-v1.invalid.json +31 -0
- package/evals/fixtures/piagent-webui/model-catalog-v1.valid.json +43 -0
- package/evals/fixtures/piagent-webui/queue-v1.invalid.json +29 -0
- package/evals/fixtures/piagent-webui/queue-v1.valid.json +44 -0
- package/evals/fixtures/piagent-webui/recovery-history-v1.invalid.json +17 -0
- package/evals/fixtures/piagent-webui/recovery-history-v1.valid.json +20 -0
- package/evals/fixtures/piagent-webui/release-monitor-v1.invalid.json +12 -0
- package/evals/fixtures/piagent-webui/release-monitor-v1.valid.json +29 -0
- package/evals/fixtures/piagent-webui/review-state-v1.invalid.json +25 -0
- package/evals/fixtures/piagent-webui/review-state-v1.valid.json +38 -0
- package/evals/fixtures/piagent-webui/runtime-event-v2.invalid.json +49 -0
- package/evals/fixtures/piagent-webui/runtime-event-v2.valid.json +49 -0
- package/evals/fixtures/piagent-webui/session-catalog-v1.invalid.json +17 -0
- package/evals/fixtures/piagent-webui/session-catalog-v1.valid.json +58 -0
- package/evals/fixtures/piagent-webui/session-command-v1.invalid.json +19 -0
- package/evals/fixtures/piagent-webui/session-command-v1.valid.json +19 -0
- package/evals/fixtures/piagent-webui/snapshot-v1.invalid.json +5 -0
- package/evals/fixtures/piagent-webui/snapshot-v1.valid.json +191 -0
- package/evals/fixtures/piagent-webui/source-change-v1.invalid.json +57 -0
- package/evals/fixtures/piagent-webui/source-change-v1.valid.json +98 -0
- package/evals/fixtures/piagent-webui/source-mutation-v1.invalid.json +19 -0
- package/evals/fixtures/piagent-webui/source-mutation-v1.valid.json +38 -0
- package/evals/fixtures/piagent-webui/source-revert-v1.invalid.json +20 -0
- package/evals/fixtures/piagent-webui/source-revert-v1.valid.json +62 -0
- package/evals/fixtures/piagent-webui/subagent-tree-v1.invalid.json +26 -0
- package/evals/fixtures/piagent-webui/subagent-tree-v1.valid.json +39 -0
- package/evals/fixtures/piagent-webui/task-index-v1.invalid.json +15 -0
- package/evals/fixtures/piagent-webui/task-index-v1.valid.json +15 -0
- package/evals/fixtures/piagent-webui/task-timeline-v1.invalid.json +14 -0
- package/evals/fixtures/piagent-webui/task-timeline-v1.valid.json +16 -0
- package/evals/fixtures/piagent-webui/transcript-v1.invalid.json +35 -0
- package/evals/fixtures/piagent-webui/transcript-v1.valid.json +55 -0
- package/evals/fixtures/task-baseline-manifest.invalid.json +16 -0
- package/evals/fixtures/task-baseline-manifest.valid.json +41 -0
- package/evals/fixtures/verifier-file-snapshot.invalid.json +21 -0
- package/evals/fixtures/verifier-file-snapshot.valid.json +21 -0
- package/package.json +30 -3
- package/packages/piagent-core/benchmark/benchmark-candidate.d.ts +1 -0
- package/packages/piagent-core/benchmark/benchmark-forensics.d.ts +1 -0
- package/packages/piagent-core/benchmark/benchmark-ledger.d.ts +4 -0
- package/packages/piagent-core/benchmark/benchmark-record-validation.d.ts +1 -0
- package/packages/piagent-core/benchmark/benchmark-runtime.d.ts +1 -0
- package/packages/piagent-core/capabilities/capability-core.js +2 -2
- package/packages/piagent-core/capabilities/runtime-integrity.js +143 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +62 -37
- package/packages/piagent-core/extensions/redaction-core.d.ts +2 -0
- package/packages/piagent-core/extensions/task-journal.js +30 -1
- package/packages/piagent-core/extensions/task-state.js +33 -36
- package/packages/piagent-core/mcp/mcp-server-catalog.js +5 -5
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +2 -2
- package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +5 -2
- package/packages/piagent-core/runtime/hooks/tool-result-hook.ts +20 -19
- package/packages/piagent-core/runtime/hooks/tool-result-metadata.ts +13 -0
- package/packages/piagent-core/runtime/input/chat-images.ts +2 -2
- package/packages/piagent-core/runtime/inspection/activity-event-adapter.ts +94 -0
- package/packages/piagent-core/runtime/inspection/approval-broker.ts +318 -0
- package/packages/piagent-core/runtime/inspection/commit-summary-projection.ts +59 -0
- package/packages/piagent-core/runtime/inspection/context-telemetry-inspection.ts +37 -0
- package/packages/piagent-core/runtime/inspection/criteria-links.ts +295 -0
- package/packages/piagent-core/runtime/inspection/diff-projection.ts +499 -0
- package/packages/piagent-core/runtime/inspection/git-filter-safety.ts +38 -0
- package/packages/piagent-core/runtime/inspection/git-numstat-adapter.ts +71 -0
- package/packages/piagent-core/runtime/inspection/git-status-adapter.ts +493 -0
- package/packages/piagent-core/runtime/inspection/git-tree-adapter.ts +108 -0
- package/packages/piagent-core/runtime/inspection/mcp-control.ts +10 -0
- package/packages/piagent-core/runtime/inspection/mutation-provenance-contract.ts +131 -0
- package/packages/piagent-core/runtime/inspection/mutation-provenance-recorder.ts +48 -0
- package/packages/piagent-core/runtime/inspection/mutation-provenance-store.ts +161 -0
- package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +36 -0
- package/packages/piagent-core/runtime/inspection/review-state-contract.ts +108 -0
- package/packages/piagent-core/runtime/inspection/review-state-projection.ts +93 -0
- package/packages/piagent-core/runtime/inspection/review-state-store.ts +112 -0
- package/packages/piagent-core/runtime/inspection/runtime-event-store.ts +262 -0
- package/packages/piagent-core/runtime/inspection/same-process-bridge-proof.ts +103 -0
- package/packages/piagent-core/runtime/inspection/source-change-projection.ts +471 -0
- package/packages/piagent-core/runtime/inspection/source-evidence-contract.ts +139 -0
- package/packages/piagent-core/runtime/inspection/source-evidence-store.ts +315 -0
- package/packages/piagent-core/runtime/inspection/source-handoff-store.ts +70 -0
- package/packages/piagent-core/runtime/inspection/source-inspection-plan.ts +34 -0
- package/packages/piagent-core/runtime/inspection/source-mutation-projection.ts +164 -0
- package/packages/piagent-core/runtime/inspection/source-mutation-store.ts +168 -0
- package/packages/piagent-core/runtime/inspection/source-open-target.ts +38 -0
- package/packages/piagent-core/runtime/inspection/source-revert-projection.ts +147 -0
- package/packages/piagent-core/runtime/inspection/task-baseline-start-capture.ts +22 -0
- package/packages/piagent-core/runtime/inspection/task-compaction-history.ts +160 -0
- package/packages/piagent-core/runtime/inspection/task-control-journal.ts +131 -0
- package/packages/piagent-core/runtime/inspection/task-handoff-history.ts +95 -0
- package/packages/piagent-core/runtime/inspection/task-provenance-projection.ts +60 -0
- package/packages/piagent-core/runtime/inspection/task-recovery-timeline.ts +88 -0
- package/packages/piagent-core/runtime/inspection/task-run-index.ts +87 -0
- package/packages/piagent-core/runtime/inspection/task-source-projection.ts +401 -0
- package/packages/piagent-core/runtime/inspection/task-subagent-tree.ts +101 -0
- package/packages/piagent-core/runtime/inspection/text-diff.ts +60 -0
- package/packages/piagent-core/runtime/inspection/verifier-snapshot-contract.ts +114 -0
- package/packages/piagent-core/runtime/inspection/verifier-snapshot-store.ts +189 -0
- package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +320 -0
- package/packages/piagent-core/runtime/inspection/workspace-file-reader.ts +132 -0
- package/packages/piagent-core/runtime/inspection/workspace-source-projection.ts +96 -0
- package/packages/piagent-core/runtime/inspection/zero-turn-conformance.ts +175 -0
- package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +68 -4
- package/packages/piagent-core/runtime/policy/source-index-transaction.ts +173 -0
- package/packages/piagent-core/runtime/policy/source-mutation-guard-binding.ts +42 -0
- package/packages/piagent-core/runtime/policy/source-mutation-guard.ts +184 -0
- package/packages/piagent-core/runtime/policy/source-worktree-transaction.ts +105 -0
- package/packages/piagent-core/runtime/product/activity-inspector-footer.ts +5 -3
- package/packages/piagent-core/runtime/product/activity-inspector.ts +85 -343
- package/packages/piagent-core/runtime/recovery/resume-state.ts +38 -1
- package/packages/piagent-core/runtime/registration/activity-inspector-command.ts +68 -14
- package/packages/piagent-core/runtime/registration/task-start-tool.ts +13 -14
- package/packages/piagent-core/runtime/session/model-authorship-state.ts +31 -3
- package/packages/piagent-core/runtime/session/runtime-state.ts +11 -1
- package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +172 -0
- package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +1 -0
- package/packages/piagent-webui/dist/client/index.html +16 -0
- package/packages/piagent-webui/extension/attachment-store.ts +323 -0
- package/packages/piagent-webui/extension/held-message-queue.ts +311 -0
- package/packages/piagent-webui/extension/lifecycle-controller.ts +408 -0
- package/packages/piagent-webui/extension/lifecycle-event-adapter.ts +23 -0
- package/packages/piagent-webui/extension/piagent-webui.ts +470 -0
- package/packages/piagent-webui/extension/review-controller.ts +160 -0
- package/packages/piagent-webui/extension/same-session-bridge.ts +500 -0
- package/packages/piagent-webui/extension/session-options-controller.ts +281 -0
- package/packages/piagent-webui/extension/session-stream-adapter.ts +249 -0
- package/packages/piagent-webui/extension/source-mutation-controller.ts +215 -0
- package/packages/piagent-webui/extension/source-open-controller.ts +136 -0
- package/packages/piagent-webui/extension/source-revert-controller.ts +189 -0
- package/packages/piagent-webui/extension/terminal-session-adapter.ts +73 -0
- package/packages/piagent-webui/extension/vscode-handoff.ts +51 -0
- package/packages/piagent-webui/gateway/control-socket.ts +92 -0
- package/packages/piagent-webui/gateway/gateway-events.ts +60 -0
- package/packages/piagent-webui/gateway/gateway-protocol-service.ts +64 -0
- package/packages/piagent-webui/gateway/gateway-service.ts +215 -0
- package/packages/piagent-webui/gateway/gateway-session-stream.ts +110 -0
- package/packages/piagent-webui/gateway/mcp-auth-broker.ts +158 -0
- package/packages/piagent-webui/gateway/native-project-picker.ts +29 -0
- package/packages/piagent-webui/gateway/pi-host.ts +41 -0
- package/packages/piagent-webui/gateway/profile-state.ts +1 -0
- package/packages/piagent-webui/gateway/project-registry.ts +108 -0
- package/packages/piagent-webui/gateway/provider-auth-broker.ts +172 -0
- package/packages/piagent-webui/gateway/session-catalog.ts +128 -0
- package/packages/piagent-webui/gateway/session-command-controller.ts +237 -0
- package/packages/piagent-webui/gateway/session-command-store.ts +224 -0
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +204 -0
- package/packages/piagent-webui/gateway/session-lease-store.ts +1 -0
- package/packages/piagent-webui/gateway/session-metadata-store.ts +152 -0
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +485 -0
- package/packages/piagent-webui/ownership/gateway-runtime-context.ts +8 -0
- package/packages/piagent-webui/ownership/profile-state.ts +117 -0
- package/packages/piagent-webui/ownership/session-lease-store.ts +329 -0
- package/packages/piagent-webui/ownership/session-refs.ts +8 -0
- package/packages/piagent-webui/package.json +33 -0
- package/packages/piagent-webui/server/README.md +4 -0
- package/packages/piagent-webui/server/benchmark-release-monitor.ts +230 -0
- package/packages/piagent-webui/server/core-inspection-provider.ts +403 -0
- package/packages/piagent-webui/server/gateway-websocket.ts +143 -0
- package/packages/piagent-webui/server/http-security.ts +27 -0
- package/packages/piagent-webui/server/ipc-read-model-client.ts +89 -0
- package/packages/piagent-webui/server/loopback-server.ts +368 -0
- package/packages/piagent-webui/server/read-model-provider.ts +37 -0
- package/packages/piagent-webui/server/read-only-router.ts +105 -0
- package/packages/piagent-webui/server/session-auth.ts +88 -0
- package/packages/piagent-webui/server/sidecar-main.ts +86 -0
- package/packages/piagent-webui/server/sse-hub.ts +76 -0
- package/packages/piagent-webui/server/static-bundle.ts +40 -0
- package/packages/piagent-webui/server/transcript-projection.ts +110 -0
- package/schemas/mutation-provenance-record.schema.json +100 -0
- package/schemas/piagent-webui/approval-v1.schema.json +654 -0
- package/schemas/piagent-webui/attachment-v1.schema.json +267 -0
- package/schemas/piagent-webui/capabilities-v1.schema.json +729 -0
- package/schemas/piagent-webui/catalog-v1.json +240 -0
- package/schemas/piagent-webui/commit-summary-v1.schema.json +63 -0
- package/schemas/piagent-webui/common-v1.schema.json +479 -0
- package/schemas/piagent-webui/control-command-v1.schema.json +1780 -0
- package/schemas/piagent-webui/diff-v1.schema.json +826 -0
- package/schemas/piagent-webui/gateway-capabilities-v1.schema.json +136 -0
- package/schemas/piagent-webui/gateway-protocol-v1.schema.json +456 -0
- package/schemas/piagent-webui/handoff-history-v1.schema.json +92 -0
- package/schemas/piagent-webui/model-catalog-v1.schema.json +95 -0
- package/schemas/piagent-webui/queue-v1.schema.json +99 -0
- package/schemas/piagent-webui/recovery-history-v1.schema.json +144 -0
- package/schemas/piagent-webui/release-monitor-v1.schema.json +173 -0
- package/schemas/piagent-webui/review-state-v1.schema.json +206 -0
- package/schemas/piagent-webui/runtime-event-v2.schema.json +5206 -0
- package/schemas/piagent-webui/session-catalog-v1.schema.json +222 -0
- package/schemas/piagent-webui/session-command-v1.schema.json +390 -0
- package/schemas/piagent-webui/snapshot-v1.schema.json +2622 -0
- package/schemas/piagent-webui/source-change-v1.schema.json +763 -0
- package/schemas/piagent-webui/source-mutation-v1.schema.json +96 -0
- package/schemas/piagent-webui/source-revert-v1.schema.json +130 -0
- package/schemas/piagent-webui/subagent-tree-v1.schema.json +88 -0
- package/schemas/piagent-webui/task-index-v1.schema.json +74 -0
- package/schemas/piagent-webui/task-timeline-v1.schema.json +49 -0
- package/schemas/piagent-webui/transcript-v1.schema.json +243 -0
- package/schemas/task-baseline-manifest.schema.json +122 -0
- package/schemas/verifier-file-snapshot.schema.json +81 -0
- package/scripts/check-architecture.mjs +1 -0
- package/scripts/check-doc-languages.mjs +19 -2
- package/scripts/check-third-party-neutrality.mjs +43 -0
- package/scripts/install-global.sh +1 -1
- package/scripts/piagent-cli.mjs +17 -3
- package/scripts/piagent-dashboard.mjs +208 -0
- package/scripts/piagent-webui-launcher.mjs +121 -0
- package/scripts/typescript-loader.mjs +26 -1
- package/scripts/verify-local.sh +253 -0
- package/scripts/verify-release-identity.mjs +4 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const MUTATION_PROVENANCE_VERSION = "piagent-mutation-provenance-v1" as const;
|
|
5
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
6
|
+
const TREE = /^wt-content-v2:[a-f0-9]{64}$/;
|
|
7
|
+
const PUBLIC_ID = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/;
|
|
8
|
+
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
9
|
+
|
|
10
|
+
export type MutationProvenanceChange = {
|
|
11
|
+
pathDigest: string;
|
|
12
|
+
repoPathBase64: string;
|
|
13
|
+
beforeCarrierDigest: string | null;
|
|
14
|
+
afterCarrierDigest: string | null;
|
|
15
|
+
afterContentDigest: string | null;
|
|
16
|
+
proofMode: "full-content" | "exact-replacement" | null;
|
|
17
|
+
effect: "exact-content" | "content-preserved" | "content-changed" | "unknown";
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type MutationProvenanceRecord = {
|
|
21
|
+
schemaVersion: 1;
|
|
22
|
+
version: typeof MUTATION_PROVENANCE_VERSION;
|
|
23
|
+
recordId: string;
|
|
24
|
+
evidenceRef: string;
|
|
25
|
+
taskId: string;
|
|
26
|
+
taskRunId: string;
|
|
27
|
+
sessionIdentityHash: string;
|
|
28
|
+
toolCallIdentityHash: string;
|
|
29
|
+
toolName: "edit" | "write" | "apply_patch" | "shell" | "opaque";
|
|
30
|
+
recordedAt: string;
|
|
31
|
+
evidenceMode: "exact-runtime" | "observed-runtime";
|
|
32
|
+
beforeTreeDigest: string;
|
|
33
|
+
afterTreeDigest: string;
|
|
34
|
+
changes: MutationProvenanceChange[];
|
|
35
|
+
integrityDigest: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function plain(value: unknown): value is Record<string, unknown> {
|
|
39
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
40
|
+
const prototype = Object.getPrototypeOf(value);
|
|
41
|
+
return prototype === Object.prototype || prototype === null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function canonical(value: unknown): unknown {
|
|
45
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
46
|
+
if (!plain(value)) return value;
|
|
47
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hash(value: string): string {
|
|
51
|
+
return createHash("sha256").update(value).digest("hex");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function mutationProvenanceIdentity(value: Pick<MutationProvenanceRecord,
|
|
55
|
+
"taskRunId" | "toolCallIdentityHash" | "recordedAt" | "afterTreeDigest" | "changes">): string {
|
|
56
|
+
const identity = {
|
|
57
|
+
taskRunId: value.taskRunId,
|
|
58
|
+
toolCallIdentityHash: value.toolCallIdentityHash,
|
|
59
|
+
recordedAt: value.recordedAt,
|
|
60
|
+
afterTreeDigest: value.afterTreeDigest,
|
|
61
|
+
changes: value.changes
|
|
62
|
+
};
|
|
63
|
+
return hash(`mutation-provenance-identity-v1\0${JSON.stringify(canonical(identity))}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function mutationProvenanceDigest(value: Omit<MutationProvenanceRecord, "integrityDigest"> | MutationProvenanceRecord): string {
|
|
67
|
+
const { integrityDigest: _ignored, ...payload } = value as MutationProvenanceRecord;
|
|
68
|
+
return `sha256:${hash(`mutation-provenance-record-v1\0${JSON.stringify(canonical(payload))}`)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function decodeMutationRepoPath(change: MutationProvenanceChange): string | null {
|
|
72
|
+
const value = Buffer.from(change.repoPathBase64, "base64url").toString("utf8");
|
|
73
|
+
return value && !path.posix.isAbsolute(value) && !path.win32.isAbsolute(value)
|
|
74
|
+
&& !value.split("/").includes("..") && !value.includes("\0") ? value : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function exactKeys(value: Record<string, unknown>, keys: string[]): boolean {
|
|
78
|
+
const actual = Object.keys(value).sort(), expected = [...keys].sort();
|
|
79
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validChange(value: unknown): value is MutationProvenanceChange {
|
|
83
|
+
if (!plain(value) || !exactKeys(value, ["pathDigest", "repoPathBase64", "beforeCarrierDigest", "afterCarrierDigest", "afterContentDigest", "proofMode", "effect"])) return false;
|
|
84
|
+
if (!DIGEST.test(String(value.pathDigest))) return false;
|
|
85
|
+
if (typeof value.repoPathBase64 !== "string" || !value.repoPathBase64 || value.repoPathBase64.length > 4096 || !/^[A-Za-z0-9_-]+$/.test(value.repoPathBase64)) return false;
|
|
86
|
+
if (value.beforeCarrierDigest !== null && !TREE.test(String(value.beforeCarrierDigest))) return false;
|
|
87
|
+
if (value.afterCarrierDigest !== null && !TREE.test(String(value.afterCarrierDigest))) return false;
|
|
88
|
+
if (value.afterContentDigest !== null && !DIGEST.test(String(value.afterContentDigest))) return false;
|
|
89
|
+
if (value.beforeCarrierDigest === value.afterCarrierDigest) return false;
|
|
90
|
+
if (value.proofMode !== null && !["full-content", "exact-replacement"].includes(String(value.proofMode))) return false;
|
|
91
|
+
if (!["exact-content", "content-preserved", "content-changed", "unknown"].includes(String(value.effect))) return false;
|
|
92
|
+
const repoPath = decodeMutationRepoPath(value as MutationProvenanceChange);
|
|
93
|
+
return Boolean(repoPath && value.pathDigest === `sha256:${hash(repoPath as string)}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function mutationProvenanceErrors(value: unknown): string[] {
|
|
97
|
+
const keys = ["schemaVersion", "version", "recordId", "evidenceRef", "taskId", "taskRunId", "sessionIdentityHash", "toolCallIdentityHash", "toolName", "recordedAt", "evidenceMode", "beforeTreeDigest", "afterTreeDigest", "changes", "integrityDigest"];
|
|
98
|
+
if (!plain(value) || !exactKeys(value, keys)) return ["record shape is invalid"];
|
|
99
|
+
const errors: string[] = [];
|
|
100
|
+
if (value.schemaVersion !== 1 || value.version !== MUTATION_PROVENANCE_VERSION) errors.push("record version is invalid");
|
|
101
|
+
if (!PUBLIC_ID.test(String(value.taskId)) || !PUBLIC_ID.test(String(value.taskRunId))) errors.push("task identity is invalid");
|
|
102
|
+
if (!DIGEST.test(String(value.sessionIdentityHash)) || !DIGEST.test(String(value.toolCallIdentityHash))) errors.push("private identity hash is invalid");
|
|
103
|
+
if (!["edit", "write", "apply_patch", "shell", "opaque"].includes(String(value.toolName))
|
|
104
|
+
|| !["exact-runtime", "observed-runtime"].includes(String(value.evidenceMode))) errors.push("tool evidence is invalid");
|
|
105
|
+
if (!TIMESTAMP.test(String(value.recordedAt)) || !Number.isFinite(Date.parse(String(value.recordedAt)))) errors.push("recorded timestamp is invalid");
|
|
106
|
+
if (!TREE.test(String(value.beforeTreeDigest)) || !TREE.test(String(value.afterTreeDigest)) || value.beforeTreeDigest === value.afterTreeDigest) errors.push("tree evidence is invalid");
|
|
107
|
+
if (!Array.isArray(value.changes) || value.changes.length < 1 || value.changes.length > 100 || !value.changes.every(validChange)) errors.push("file changes are invalid");
|
|
108
|
+
else if (new Set(value.changes.map((change) => (change as MutationProvenanceChange).pathDigest)).size !== value.changes.length) errors.push("file changes are duplicated");
|
|
109
|
+
else if (value.evidenceMode === "exact-runtime" && (
|
|
110
|
+
!["edit", "write", "apply_patch"].includes(String(value.toolName))
|
|
111
|
+
|| value.changes.some((change) => {
|
|
112
|
+
const item = change as MutationProvenanceChange;
|
|
113
|
+
return item.afterCarrierDigest === null || item.afterContentDigest === null || item.proofMode === null || item.effect !== "exact-content";
|
|
114
|
+
})
|
|
115
|
+
)) errors.push("exact mutation proof is invalid");
|
|
116
|
+
else if (value.evidenceMode === "observed-runtime" && value.changes.some((change) => {
|
|
117
|
+
const item = change as MutationProvenanceChange;
|
|
118
|
+
return item.proofMode !== null || item.effect === "exact-content"
|
|
119
|
+
|| (item.effect === "content-preserved" && item.afterContentDigest === null);
|
|
120
|
+
})) errors.push("observed mutation proof is invalid");
|
|
121
|
+
const identity = mutationProvenanceIdentity(value as MutationProvenanceRecord);
|
|
122
|
+
if (value.recordId !== `provenance.${identity}` || value.evidenceRef !== `mutation.${identity}`) errors.push("record identity is invalid");
|
|
123
|
+
if (!DIGEST.test(String(value.integrityDigest)) || value.integrityDigest !== mutationProvenanceDigest(value as MutationProvenanceRecord)) errors.push("record integrity is invalid");
|
|
124
|
+
return errors;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function validateMutationProvenanceRecord(value: unknown): MutationProvenanceRecord {
|
|
128
|
+
const errors = mutationProvenanceErrors(value);
|
|
129
|
+
if (errors.length) throw new Error(`Invalid mutation provenance record: ${errors.join("; ")}`);
|
|
130
|
+
return structuredClone(value) as MutationProvenanceRecord;
|
|
131
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { currentFileContentDigests } from "../quality/model-mutation-proof.ts";
|
|
2
|
+
import type { ModelMutationEvidenceCompletion, ModelMutationIdentity } from "../session/model-authorship-state.ts";
|
|
3
|
+
import { appendMutationProvenance, type AppendMutationProvenanceOptions } from "./mutation-provenance-store.ts";
|
|
4
|
+
|
|
5
|
+
type RecordMutationResultOptions = {
|
|
6
|
+
projectRoot: string;
|
|
7
|
+
identity: ModelMutationIdentity;
|
|
8
|
+
toolCallId: string;
|
|
9
|
+
toolName: string;
|
|
10
|
+
recordedAt: string;
|
|
11
|
+
successful: boolean;
|
|
12
|
+
currentSnapshot: Record<string, string>;
|
|
13
|
+
completion: ModelMutationEvidenceCompletion;
|
|
14
|
+
shellSnapshotBefore?: Record<string, string>;
|
|
15
|
+
shellChangedPaths: string[];
|
|
16
|
+
protectedPaths: string[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function toolName(value: string): AppendMutationProvenanceOptions["toolName"] {
|
|
20
|
+
const normalized = value.toLowerCase();
|
|
21
|
+
if (normalized === "edit" || normalized === "write" || normalized === "apply_patch") return normalized;
|
|
22
|
+
if (["bash", "shell", "exec"].includes(normalized)) return "shell";
|
|
23
|
+
return "opaque";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function recordMutationResult(options: RecordMutationResultOptions) {
|
|
27
|
+
if (!options.successful) return undefined;
|
|
28
|
+
const changedPaths = [...new Set([...options.completion.changedPaths, ...options.shellChangedPaths])].sort();
|
|
29
|
+
const beforeSnapshot = options.completion.beforeSnapshot ?? options.shellSnapshotBefore;
|
|
30
|
+
if (!beforeSnapshot || changedPaths.length === 0) return undefined;
|
|
31
|
+
const contentDigests = currentFileContentDigests(options.projectRoot, changedPaths);
|
|
32
|
+
return appendMutationProvenance({
|
|
33
|
+
projectRoot: options.projectRoot,
|
|
34
|
+
taskId: options.identity.taskId,
|
|
35
|
+
taskRunId: options.identity.taskRunId,
|
|
36
|
+
sessionId: options.identity.sessionId,
|
|
37
|
+
toolCallId: options.toolCallId,
|
|
38
|
+
toolName: toolName(options.toolName),
|
|
39
|
+
recordedAt: options.recordedAt,
|
|
40
|
+
beforeSnapshot,
|
|
41
|
+
afterSnapshot: options.currentSnapshot,
|
|
42
|
+
changedPaths,
|
|
43
|
+
recordedDigests: options.completion.recordedDigests,
|
|
44
|
+
recordedContentDigests: { ...contentDigests, ...options.completion.recordedContentDigests },
|
|
45
|
+
proofModes: options.completion.proofModes,
|
|
46
|
+
protectedPaths: options.protectedPaths
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { ensurePrivateStateDirectory, resolveLocalStatePath } from "../../extensions/local-state-path.js";
|
|
6
|
+
import { workingTreeEvidenceDigest, workingTreeSnapshotUsesCurrentAlgorithm } from "../../extensions/working-tree-digest.js";
|
|
7
|
+
import { matchesProtectedPath } from "../../extensions/policy-core.js";
|
|
8
|
+
import {
|
|
9
|
+
MUTATION_PROVENANCE_VERSION,
|
|
10
|
+
mutationProvenanceDigest,
|
|
11
|
+
mutationProvenanceIdentity,
|
|
12
|
+
validateMutationProvenanceRecord,
|
|
13
|
+
type MutationProvenanceRecord
|
|
14
|
+
} from "./mutation-provenance-contract.ts";
|
|
15
|
+
import { readTaskBaselineManifest, taskBaselineRetentionState } from "./source-evidence-store.ts";
|
|
16
|
+
|
|
17
|
+
const MAX_RECORDS = 500;
|
|
18
|
+
const MAX_RECORD_BYTES = 128 * 1024;
|
|
19
|
+
|
|
20
|
+
export type AppendMutationProvenanceOptions = {
|
|
21
|
+
projectRoot: string;
|
|
22
|
+
taskId: string;
|
|
23
|
+
taskRunId: string;
|
|
24
|
+
sessionId: string;
|
|
25
|
+
toolCallId: string;
|
|
26
|
+
toolName: "edit" | "write" | "apply_patch" | "shell" | "opaque";
|
|
27
|
+
recordedAt: string;
|
|
28
|
+
beforeSnapshot: Record<string, string>;
|
|
29
|
+
afterSnapshot: Record<string, string>;
|
|
30
|
+
recordedDigests: Record<string, string>;
|
|
31
|
+
recordedContentDigests: Record<string, string>;
|
|
32
|
+
proofModes: Record<string, "full-content" | "exact-replacement">;
|
|
33
|
+
changedPaths: string[];
|
|
34
|
+
protectedPaths?: string[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function hash(value: string | Buffer): string {
|
|
38
|
+
return createHash("sha256").update(value).digest("hex");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function evidenceDirectory(projectRoot: string, taskRunId: string): string {
|
|
42
|
+
return path.join(projectRoot, ".pi", "piagent-state", "source-evidence", `run-${hash(taskRunId)}`, "mutations");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readPrivate(projectRoot: string, file: string): Buffer {
|
|
46
|
+
const safe = resolveLocalStatePath(projectRoot, file, { label: "Mutation provenance", kind: "file" });
|
|
47
|
+
const descriptor = fs.openSync(safe, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
48
|
+
try {
|
|
49
|
+
const stat = fs.fstatSync(descriptor);
|
|
50
|
+
if (!stat.isFile() || stat.size > MAX_RECORD_BYTES) throw new Error("Mutation provenance record is oversized or not regular");
|
|
51
|
+
return fs.readFileSync(descriptor);
|
|
52
|
+
} finally { fs.closeSync(descriptor); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function publishPrivate(projectRoot: string, target: string, content: Buffer): void {
|
|
56
|
+
const parent = ensurePrivateStateDirectory(projectRoot, path.dirname(target), "Mutation provenance directory");
|
|
57
|
+
const safeTarget = resolveLocalStatePath(projectRoot, target, { label: "Mutation provenance" });
|
|
58
|
+
const temporary = path.join(parent, `${path.basename(target)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
59
|
+
const descriptor = fs.openSync(temporary, "wx", 0o600);
|
|
60
|
+
try { fs.writeFileSync(descriptor, content); fs.fsyncSync(descriptor); fs.fchmodSync(descriptor, 0o600); }
|
|
61
|
+
finally { fs.closeSync(descriptor); }
|
|
62
|
+
try { fs.linkSync(temporary, safeTarget); }
|
|
63
|
+
catch (error) {
|
|
64
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
65
|
+
if (!readPrivate(projectRoot, safeTarget).equals(content)) throw new Error("Mutation provenance identity collision");
|
|
66
|
+
} finally { fs.rmSync(temporary, { force: true }); }
|
|
67
|
+
try { fs.chmodSync(parent, 0o700); fs.chmodSync(safeTarget, 0o600); } catch {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function safePath(value: string): boolean {
|
|
71
|
+
return Boolean(value && !path.posix.isAbsolute(value) && !path.win32.isAbsolute(value)
|
|
72
|
+
&& !value.split("/").includes("..") && !value.includes("\0") && value.length <= 4096);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function appendMutationProvenance(options: AppendMutationProvenanceOptions): MutationProvenanceRecord | undefined {
|
|
76
|
+
const manifest = readTaskBaselineManifest(options.projectRoot, options.taskRunId);
|
|
77
|
+
if (!manifest || manifest.taskId !== options.taskId || manifest.captureState !== "current"
|
|
78
|
+
|| taskBaselineRetentionState(manifest, new Date(options.recordedAt)) !== "active") return undefined;
|
|
79
|
+
if (!workingTreeSnapshotUsesCurrentAlgorithm(options.beforeSnapshot) || !workingTreeSnapshotUsesCurrentAlgorithm(options.afterSnapshot)) return undefined;
|
|
80
|
+
const paths = [...new Set(options.changedPaths)].sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
81
|
+
if (paths.length < 1 || paths.length > 100) return undefined;
|
|
82
|
+
const evidenceMode = paths.every((repoPath) => options.recordedDigests[repoPath] && options.proofModes[repoPath])
|
|
83
|
+
? "exact-runtime" as const : "observed-runtime" as const;
|
|
84
|
+
const previous = evidenceMode === "observed-runtime" ? readMutationProvenance(options.projectRoot, options.taskRunId).records : [];
|
|
85
|
+
const changes = [];
|
|
86
|
+
for (const repoPath of paths) {
|
|
87
|
+
const before = options.beforeSnapshot[repoPath] ?? null;
|
|
88
|
+
const after = options.afterSnapshot[repoPath];
|
|
89
|
+
const content = options.recordedContentDigests[repoPath] ?? null;
|
|
90
|
+
const proofMode = evidenceMode === "exact-runtime" ? options.proofModes[repoPath] : null;
|
|
91
|
+
if (!safePath(repoPath) || matchesProtectedPath(repoPath, options.protectedPaths ?? [])
|
|
92
|
+
|| before === (after ?? null) || (content !== null && !/^[a-f0-9]{64}$/.test(content))) return undefined;
|
|
93
|
+
if (evidenceMode === "exact-runtime" && (!after || after !== options.recordedDigests[repoPath] || !content || !proofMode)) return undefined;
|
|
94
|
+
const priorContent = [...previous].reverse().flatMap((record) => record.changes)
|
|
95
|
+
.find((change) => Buffer.from(change.repoPathBase64, "base64url").toString("utf8") === repoPath)?.afterContentDigest ?? null;
|
|
96
|
+
const effect = evidenceMode === "exact-runtime" ? "exact-content"
|
|
97
|
+
: content && priorContent === `sha256:${content}` ? "content-preserved"
|
|
98
|
+
: content ? "content-changed" : "unknown";
|
|
99
|
+
changes.push({
|
|
100
|
+
pathDigest: `sha256:${hash(repoPath)}`,
|
|
101
|
+
repoPathBase64: Buffer.from(repoPath, "utf8").toString("base64url"),
|
|
102
|
+
beforeCarrierDigest: before,
|
|
103
|
+
afterCarrierDigest: after ?? null,
|
|
104
|
+
afterContentDigest: content ? `sha256:${content}` : null,
|
|
105
|
+
proofMode,
|
|
106
|
+
effect
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const beforeTreeDigest = workingTreeEvidenceDigest(options.beforeSnapshot);
|
|
110
|
+
const afterTreeDigest = workingTreeEvidenceDigest(options.afterSnapshot);
|
|
111
|
+
if (beforeTreeDigest === afterTreeDigest) return undefined;
|
|
112
|
+
const core = {
|
|
113
|
+
taskRunId: options.taskRunId,
|
|
114
|
+
toolCallIdentityHash: `sha256:${hash(`tool-call\0${options.toolCallId}`)}`,
|
|
115
|
+
recordedAt: options.recordedAt,
|
|
116
|
+
afterTreeDigest,
|
|
117
|
+
changes
|
|
118
|
+
};
|
|
119
|
+
const identity = mutationProvenanceIdentity(core);
|
|
120
|
+
const payload: Omit<MutationProvenanceRecord, "integrityDigest"> = {
|
|
121
|
+
schemaVersion: 1, version: MUTATION_PROVENANCE_VERSION,
|
|
122
|
+
recordId: `provenance.${identity}`, evidenceRef: `mutation.${identity}`,
|
|
123
|
+
taskId: options.taskId, taskRunId: options.taskRunId,
|
|
124
|
+
sessionIdentityHash: `sha256:${hash(`session\0${options.sessionId}`)}`,
|
|
125
|
+
toolCallIdentityHash: core.toolCallIdentityHash,
|
|
126
|
+
toolName: options.toolName, recordedAt: options.recordedAt, evidenceMode,
|
|
127
|
+
beforeTreeDigest, afterTreeDigest, changes
|
|
128
|
+
};
|
|
129
|
+
const record = validateMutationProvenanceRecord({ ...payload, integrityDigest: mutationProvenanceDigest(payload) });
|
|
130
|
+
const directory = evidenceDirectory(options.projectRoot, options.taskRunId);
|
|
131
|
+
let existing = 0;
|
|
132
|
+
try { existing = fs.readdirSync(resolveLocalStatePath(options.projectRoot, directory, { label: "Mutation provenance", kind: "directory" })).filter((file) => file.endsWith(".json")).length; }
|
|
133
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
134
|
+
const target = path.join(directory, `${record.recordId}.json`);
|
|
135
|
+
if (existing >= MAX_RECORDS && !fs.existsSync(target)) throw new Error("Mutation provenance record quota is exhausted");
|
|
136
|
+
publishPrivate(options.projectRoot, target, Buffer.from(`${JSON.stringify(record, null, 2)}\n`));
|
|
137
|
+
return record;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function readMutationProvenance(
|
|
141
|
+
projectRoot: string,
|
|
142
|
+
taskRunId: string
|
|
143
|
+
): { records: MutationProvenanceRecord[]; corruptions: string[] } {
|
|
144
|
+
const directory = evidenceDirectory(projectRoot, taskRunId);
|
|
145
|
+
let files: string[];
|
|
146
|
+
try { files = fs.readdirSync(resolveLocalStatePath(projectRoot, directory, { label: "Mutation provenance", kind: "directory" })).filter((file) => file.endsWith(".json")).sort(); }
|
|
147
|
+
catch (error) {
|
|
148
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { records: [], corruptions: [] };
|
|
149
|
+
return { records: [], corruptions: ["provenance-directory-unavailable"] };
|
|
150
|
+
}
|
|
151
|
+
if (files.length > MAX_RECORDS) return { records: [], corruptions: ["provenance-record-limit"] };
|
|
152
|
+
const records: MutationProvenanceRecord[] = [], corruptions: string[] = [];
|
|
153
|
+
for (const file of files) {
|
|
154
|
+
try {
|
|
155
|
+
const record = validateMutationProvenanceRecord(JSON.parse(readPrivate(projectRoot, path.join(directory, file)).toString("utf8")));
|
|
156
|
+
if (file !== `${record.recordId}.json` || record.taskRunId !== taskRunId) throw new Error("identity mismatch");
|
|
157
|
+
records.push(record);
|
|
158
|
+
} catch { corruptions.push(`record.${hash(file)}`); }
|
|
159
|
+
}
|
|
160
|
+
return { records: corruptions.length ? [] : records.sort((left, right) => left.recordedAt.localeCompare(right.recordedAt)), corruptions };
|
|
161
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { readProjectProfileDocument } from "../../capabilities/project-profile.js";
|
|
5
|
+
import { effectiveProtectedPaths } from "../../extensions/context-index-policy.js";
|
|
6
|
+
import { collectServers, unreadableLayers } from "../../mcp/mcp-config-layers.js";
|
|
7
|
+
|
|
8
|
+
function readRequiredJsonObject(file: string): Record<string, unknown> {
|
|
9
|
+
const stat = fs.lstatSync(file);
|
|
10
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1_048_576) throw new Error("inspection-policy-unavailable");
|
|
11
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
12
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("inspection-policy-unavailable");
|
|
13
|
+
return value as Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function runtimeProtectedPaths(packageRoot: string, cwd: string): string[] {
|
|
17
|
+
const policy = readRequiredJsonObject(path.join(packageRoot, "packages", "piagent-core", "policies", "base-policy.json"));
|
|
18
|
+
const profileFile = path.join(cwd, ".pi", "piagent-profile.json");
|
|
19
|
+
let profile: Record<string, unknown> = {};
|
|
20
|
+
try {
|
|
21
|
+
const stat = fs.lstatSync(profileFile);
|
|
22
|
+
if (stat.isFile() && !stat.isSymbolicLink() && stat.size <= 1_048_576) {
|
|
23
|
+
profile = readProjectProfileDocument(packageRoot, profileFile).profile ?? {};
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// Missing or unsafe project profiles never remove the platform policy.
|
|
27
|
+
}
|
|
28
|
+
return effectiveProtectedPaths(policy, profile).readProtectedPaths;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function runtimeConnectionDefinitions(cwd: string): {
|
|
32
|
+
servers: ReturnType<typeof collectServers>;
|
|
33
|
+
unreadableLayerCount: number;
|
|
34
|
+
} {
|
|
35
|
+
return { servers: collectServers({ projectPath: cwd }), unreadableLayerCount: unreadableLayers({ projectPath: cwd }).length };
|
|
36
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const REVIEW_EVIDENCE_VERSION = "piagent-webui-review-evidence-v1";
|
|
4
|
+
|
|
5
|
+
export type ReviewView = "task" | "working-tree" | "staged";
|
|
6
|
+
export type ReviewTarget = {
|
|
7
|
+
view: ReviewView;
|
|
8
|
+
fileRef: string;
|
|
9
|
+
diffRef: string;
|
|
10
|
+
taskRevision: string;
|
|
11
|
+
workspaceRevision: string;
|
|
12
|
+
indexRevision: string | null;
|
|
13
|
+
viewRevision: string;
|
|
14
|
+
fileRevision: string;
|
|
15
|
+
baseDigest: string | null;
|
|
16
|
+
currentDigest: string | null;
|
|
17
|
+
patchPreimage: string;
|
|
18
|
+
contentDigest: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ReviewEvidenceRecord = {
|
|
22
|
+
schemaVersion: 1;
|
|
23
|
+
version: typeof REVIEW_EVIDENCE_VERSION;
|
|
24
|
+
recordId: string;
|
|
25
|
+
evidenceRef: string;
|
|
26
|
+
sequence: number;
|
|
27
|
+
taskId: string;
|
|
28
|
+
taskRunId: string;
|
|
29
|
+
projectRef: string;
|
|
30
|
+
runtimeInstanceId: string;
|
|
31
|
+
sessionRef: string;
|
|
32
|
+
commandId: string;
|
|
33
|
+
idempotencyKeyDigest: string;
|
|
34
|
+
actionDigest: string;
|
|
35
|
+
reviewState: "reviewed" | "unreviewed";
|
|
36
|
+
target: ReviewTarget;
|
|
37
|
+
requestedAt: string;
|
|
38
|
+
recordedAt: string;
|
|
39
|
+
retentionUntil: string;
|
|
40
|
+
observedRevisions: Record<string, string | null>;
|
|
41
|
+
integrityDigest: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const REF = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
|
|
45
|
+
const PUBLIC_REF = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/;
|
|
46
|
+
const REVISION = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/;
|
|
47
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
48
|
+
const REVISION_KEYS = ["runtimeRevision", "taskRevision", "controlRevision", "workspaceRevision", "indexRevision",
|
|
49
|
+
"approvalRevision", "sessionOptionRevision", "queueRevision"];
|
|
50
|
+
|
|
51
|
+
export function canonicalReviewValue(value: unknown): string {
|
|
52
|
+
if (Array.isArray(value)) return `[${value.map(canonicalReviewValue).join(",")}]`;
|
|
53
|
+
if (value && typeof value === "object") return `{${Object.entries(value as Record<string, unknown>)
|
|
54
|
+
.sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalReviewValue(item)}`).join(",")}}`;
|
|
55
|
+
return JSON.stringify(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function reviewDigest(value: unknown): string {
|
|
59
|
+
return `sha256:${createHash("sha256").update(canonicalReviewValue(value)).digest("hex")}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function reviewRecordDigest(record: Omit<ReviewEvidenceRecord, "integrityDigest">): string {
|
|
63
|
+
return reviewDigest(record);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function timestamp(value: unknown): value is string {
|
|
67
|
+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return false;
|
|
68
|
+
const parsed = Date.parse(value); return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function validateReviewTarget(value: unknown): ReviewTarget {
|
|
72
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Review target is invalid");
|
|
73
|
+
const target = value as ReviewTarget;
|
|
74
|
+
const keys = ["view", "fileRef", "diffRef", "taskRevision", "workspaceRevision", "indexRevision", "viewRevision", "fileRevision",
|
|
75
|
+
"baseDigest", "currentDigest", "patchPreimage", "contentDigest"];
|
|
76
|
+
if (canonicalReviewValue(Object.keys(target).sort()) !== canonicalReviewValue(keys.sort())
|
|
77
|
+
|| !["task", "working-tree", "staged"].includes(target.view) || !REF.test(target.fileRef) || !REF.test(target.diffRef)
|
|
78
|
+
|| ![target.taskRevision, target.workspaceRevision, target.viewRevision, target.fileRevision].every((item) => REVISION.test(item))
|
|
79
|
+
|| target.indexRevision !== null && !REVISION.test(target.indexRevision)
|
|
80
|
+
|| ![target.baseDigest, target.currentDigest].every((item) => item === null || DIGEST.test(item))
|
|
81
|
+
|| !DIGEST.test(target.patchPreimage) || target.contentDigest !== target.patchPreimage) throw new Error("Review target is invalid");
|
|
82
|
+
return structuredClone(target);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function validateReviewEvidenceRecord(value: unknown): ReviewEvidenceRecord {
|
|
86
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Review evidence is invalid");
|
|
87
|
+
const record = value as ReviewEvidenceRecord;
|
|
88
|
+
const keys = ["schemaVersion", "version", "recordId", "evidenceRef", "sequence", "taskId", "taskRunId", "projectRef", "runtimeInstanceId", "sessionRef", "commandId",
|
|
89
|
+
"idempotencyKeyDigest", "actionDigest", "reviewState", "target", "requestedAt", "recordedAt", "retentionUntil", "observedRevisions", "integrityDigest"];
|
|
90
|
+
if (canonicalReviewValue(Object.keys(record).sort()) !== canonicalReviewValue(keys.sort()) || record.schemaVersion !== 1
|
|
91
|
+
|| record.version !== REVIEW_EVIDENCE_VERSION || ![record.recordId, record.evidenceRef, record.projectRef, record.runtimeInstanceId, record.sessionRef, record.commandId].every((item) => REF.test(item))
|
|
92
|
+
|| ![record.taskId, record.taskRunId].every((item) => PUBLIC_REF.test(item))
|
|
93
|
+
|| !Number.isInteger(record.sequence) || record.sequence < 1 || record.sequence > 2_000
|
|
94
|
+
|| ![record.idempotencyKeyDigest, record.actionDigest, record.integrityDigest].every((item) => DIGEST.test(item))
|
|
95
|
+
|| !["reviewed", "unreviewed"].includes(record.reviewState) || ![record.requestedAt, record.recordedAt, record.retentionUntil].every(timestamp)
|
|
96
|
+
|| Date.parse(record.requestedAt) > Date.parse(record.recordedAt) || Date.parse(record.recordedAt) >= Date.parse(record.retentionUntil)) {
|
|
97
|
+
throw new Error("Review evidence is invalid");
|
|
98
|
+
}
|
|
99
|
+
validateReviewTarget(record.target);
|
|
100
|
+
const revisions = record.observedRevisions;
|
|
101
|
+
if (!revisions || typeof revisions !== "object" || Array.isArray(revisions)
|
|
102
|
+
|| canonicalReviewValue(Object.keys(revisions).sort()) !== canonicalReviewValue([...REVISION_KEYS].sort())
|
|
103
|
+
|| !REVISION.test(String(revisions.runtimeRevision ?? ""))
|
|
104
|
+
|| REVISION_KEYS.slice(1).some((key) => revisions[key] !== null && !REVISION.test(String(revisions[key])))) throw new Error("Review revisions are invalid");
|
|
105
|
+
const { integrityDigest, ...payload } = record;
|
|
106
|
+
if (reviewRecordDigest(payload) !== integrityDigest) throw new Error("Review evidence integrity mismatch");
|
|
107
|
+
return structuredClone(record);
|
|
108
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { DiffDocument } from "./diff-projection.ts";
|
|
2
|
+
import { canonicalReviewValue, reviewDigest, type ReviewEvidenceRecord, type ReviewTarget, type ReviewView } from "./review-state-contract.ts";
|
|
3
|
+
import type { WebUiIdentity } from "./source-change-projection.ts";
|
|
4
|
+
|
|
5
|
+
export type ReviewStateProjection = {
|
|
6
|
+
schemaVersion: 1;
|
|
7
|
+
version: "piagent-webui-review-state-v1";
|
|
8
|
+
generatedAt: string;
|
|
9
|
+
identity: WebUiIdentity;
|
|
10
|
+
state: "reviewed" | "unreviewed" | "stale" | "unavailable";
|
|
11
|
+
target: ReviewTarget | null;
|
|
12
|
+
recordedState: "reviewed" | "unreviewed" | null;
|
|
13
|
+
recordedAt: string | null;
|
|
14
|
+
evidenceRef: string | null;
|
|
15
|
+
reasonCode: string | null;
|
|
16
|
+
health: { state: "ok" | "degraded" | "unavailable" | "error"; reasonCode: string | null; message: string | null };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type DeriveReviewTargetOptions = {
|
|
20
|
+
diff: DiffDocument;
|
|
21
|
+
taskId: string;
|
|
22
|
+
taskRunId: string;
|
|
23
|
+
taskRevision: string;
|
|
24
|
+
workspaceRevision: string;
|
|
25
|
+
indexRevision: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function targetCore(options: DeriveReviewTargetOptions): Omit<ReviewTarget, "diffRef" | "patchPreimage" | "contentDigest"> & { basisRef: string } {
|
|
29
|
+
const diff = options.diff as Record<string, any>, file = diff.file as Record<string, any>, observed = diff.observed as Record<string, any>;
|
|
30
|
+
return {
|
|
31
|
+
view: diff.view as ReviewView, fileRef: String(file.fileRef), taskRevision: options.taskRevision,
|
|
32
|
+
workspaceRevision: options.workspaceRevision, indexRevision: options.indexRevision,
|
|
33
|
+
viewRevision: String(observed.viewRevision), fileRevision: String(observed.fileRevision),
|
|
34
|
+
baseDigest: observed.baseDigest ?? null, currentDigest: observed.currentDigest ?? null,
|
|
35
|
+
basisRef: String(diff.basis?.basisRef ?? file.basisRef)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function deriveReviewTarget(options: DeriveReviewTargetOptions): ReviewTarget | null {
|
|
40
|
+
const diff = options.diff as Record<string, any>;
|
|
41
|
+
if (!options.taskId || !options.taskRunId || !options.taskRevision || !options.workspaceRevision
|
|
42
|
+
|| diff.availability?.state !== "current" || diff.fallback?.kind !== "none"
|
|
43
|
+
|| diff.truncation?.truncated !== false || diff.redaction?.applied !== false || diff.redaction?.truncated !== false
|
|
44
|
+
|| diff.file?.status === "C" || diff.file?.content?.kind !== "text" || diff.file?.content?.access !== "available") return null;
|
|
45
|
+
const core = targetCore(options), preimage = reviewDigest({ schemaVersion: 1, taskId: options.taskId, taskRunId: options.taskRunId, ...core });
|
|
46
|
+
return { view: core.view, fileRef: core.fileRef, diffRef: `diff.${preimage.slice("sha256:".length)}`, taskRevision: core.taskRevision,
|
|
47
|
+
workspaceRevision: core.workspaceRevision, indexRevision: core.indexRevision, viewRevision: core.viewRevision,
|
|
48
|
+
fileRevision: core.fileRevision, baseDigest: core.baseDigest, currentDigest: core.currentDigest,
|
|
49
|
+
patchPreimage: preimage, contentDigest: preimage };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sameTarget(left: ReviewTarget, right: ReviewTarget): boolean {
|
|
53
|
+
return canonicalReviewValue(left) === canonicalReviewValue(right);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function projectReviewState(options: {
|
|
57
|
+
identity: WebUiIdentity;
|
|
58
|
+
target: ReviewTarget | null;
|
|
59
|
+
records: ReviewEvidenceRecord[];
|
|
60
|
+
corruptions?: string[];
|
|
61
|
+
generatedAt?: string;
|
|
62
|
+
unavailableReason?: string;
|
|
63
|
+
}): ReviewStateProjection {
|
|
64
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
65
|
+
const base = { schemaVersion: 1 as const, version: "piagent-webui-review-state-v1" as const, generatedAt,
|
|
66
|
+
identity: structuredClone(options.identity) };
|
|
67
|
+
if (options.corruptions?.length) return { ...base, state: "unavailable", target: null, recordedState: null, recordedAt: null,
|
|
68
|
+
evidenceRef: null, reasonCode: "review-evidence-corrupt", health: { state: "error", reasonCode: "review-evidence-corrupt", message: "Review evidence is corrupt" } };
|
|
69
|
+
if (!options.target) {
|
|
70
|
+
const reasonCode = options.unavailableReason ?? "review-target-unavailable";
|
|
71
|
+
return { ...base, state: "unavailable", target: null, recordedState: null, recordedAt: null, evidenceRef: null, reasonCode,
|
|
72
|
+
health: { state: "unavailable", reasonCode, message: "The exact review target is unavailable" } };
|
|
73
|
+
}
|
|
74
|
+
const relevant = options.records.filter((record) => record.taskId === options.identity.taskId && record.taskRunId === options.identity.taskRunId
|
|
75
|
+
&& record.target.view === options.target!.view && record.target.fileRef === options.target!.fileRef);
|
|
76
|
+
const latest = relevant.at(-1);
|
|
77
|
+
if (!latest) return { ...base, state: "unreviewed", target: structuredClone(options.target), recordedState: null, recordedAt: null,
|
|
78
|
+
evidenceRef: null, reasonCode: null, health: { state: "ok", reasonCode: null, message: null } };
|
|
79
|
+
if (!sameTarget(latest.target, options.target)) {
|
|
80
|
+
const priorReviewed = [...relevant].reverse().find((record) => record.reviewState === "reviewed");
|
|
81
|
+
if (priorReviewed) return { ...base, state: "stale", target: structuredClone(options.target), recordedState: "reviewed",
|
|
82
|
+
recordedAt: priorReviewed.recordedAt, evidenceRef: priorReviewed.evidenceRef, reasonCode: "review-target-changed",
|
|
83
|
+
health: { state: "degraded", reasonCode: "review-target-changed", message: "The file or diff changed after it was reviewed" } };
|
|
84
|
+
return { ...base, state: "unreviewed", target: structuredClone(options.target), recordedState: null, recordedAt: null,
|
|
85
|
+
evidenceRef: null, reasonCode: null, health: { state: "ok", reasonCode: null, message: null } };
|
|
86
|
+
}
|
|
87
|
+
if (latest.reviewState === "unreviewed") return { ...base, state: "unreviewed", target: structuredClone(options.target),
|
|
88
|
+
recordedState: "unreviewed", recordedAt: latest.recordedAt, evidenceRef: latest.evidenceRef, reasonCode: null,
|
|
89
|
+
health: { state: "ok", reasonCode: null, message: null } };
|
|
90
|
+
return { ...base, state: "reviewed", target: structuredClone(options.target), recordedState: "reviewed",
|
|
91
|
+
recordedAt: latest.recordedAt, evidenceRef: latest.evidenceRef, reasonCode: null,
|
|
92
|
+
health: { state: "ok", reasonCode: null, message: null } };
|
|
93
|
+
}
|