gentle-pi 2.4.0 → 2.6.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/README.md +292 -25
- package/assets/agents/gentle-ai-worker.md +13 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +39 -11
- package/assets/orchestrator.md +5 -5
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/delegated-verification.md +25 -0
- package/docs/telemetry.md +94 -0
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +159 -25
- package/extensions/codegraph-tools.ts +95 -5
- package/extensions/gentle-agents.ts +1337 -0
- package/extensions/gentle-ai.ts +2916 -386
- package/extensions/gentle-shell.ts +650 -0
- package/extensions/gentle-todo.ts +234 -0
- package/extensions/quiet-tools.ts +2 -1
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +315 -0
- package/lib/agents-history.ts +88 -0
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +501 -0
- package/lib/agents-runner.ts +1012 -0
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +914 -0
- package/lib/agents-widget.ts +241 -0
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +143 -25
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +371 -13
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +427 -0
- package/lib/review-candidate-view.ts +150 -48
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +110 -0
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +243 -11
- package/lib/review-last-event-controller.ts +8 -4
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +240 -0
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +184 -0
- package/lib/shell-card.ts +133 -0
- package/lib/shell-changes-view.ts +530 -0
- package/lib/shell-changes.ts +290 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +115 -0
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +297 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +153 -0
- package/package.json +8 -5
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +370 -12
- package/runtime/review-integration-v2.mjs +243 -11
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +154 -0
- package/scripts/build-runtime-modules.mjs +11 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +38 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +8 -2
- package/skills/_shared/review-ledger-contract.md +20 -2
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +205 -0
- package/tests/agents-fake-child.ts +66 -0
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +198 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +959 -0
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +685 -0
- package/tests/agents-widget.test.ts +141 -0
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +325 -5
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +112 -2
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +110 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +2168 -0
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +103 -0
- package/tests/gentle-ai.test.ts +971 -15
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +818 -0
- package/tests/gentle-todo.test.ts +226 -0
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +27 -1
- package/tests/native-review-cli.test.ts +317 -3
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +43 -29
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-budget.test.ts +69 -0
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/orchestrator-rdd-ownership.test.ts +9 -0
- package/tests/package-manifest.test.ts +243 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +97 -37
- package/tests/rdd-aware-verification-contract.test.ts +226 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +751 -7
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +17 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +884 -7
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +384 -8
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +276 -0
- package/tests/review-last-event-closure.test.ts +112 -3
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +656 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +156 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +139 -0
- package/tests/shell-changes-view.test.ts +609 -0
- package/tests/shell-changes.test.ts +350 -0
- package/tests/shell-prompt.test.ts +140 -0
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +259 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +351 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, fsyncSync, lstatSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { conservativeOwnerDeathProofV1 } from "./review-lock.ts";
|
|
6
|
+
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
8
|
+
const BOOT_UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
|
9
|
+
export interface CandidateViewOwner {
|
|
10
|
+
version: 1;
|
|
11
|
+
uuid: string;
|
|
12
|
+
token: string;
|
|
13
|
+
pid: number;
|
|
14
|
+
host: string | null;
|
|
15
|
+
root: string;
|
|
16
|
+
commonDir: string;
|
|
17
|
+
}
|
|
18
|
+
type Git = (args: readonly string[]) => string;
|
|
19
|
+
export type WindowsAclAuthority = (path: string) => void;
|
|
20
|
+
|
|
21
|
+
let testingWindowsAclAuthority: WindowsAclAuthority | undefined;
|
|
22
|
+
export function setWindowsAclAuthorityForTesting(authority?: WindowsAclAuthority): void {
|
|
23
|
+
testingWindowsAclAuthority = authority;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const WINDOWS_SYSTEM = "S-1-5-18";
|
|
27
|
+
const WINDOWS_ADMINISTRATORS = "S-1-5-32-544";
|
|
28
|
+
const WINDOWS_SYSTEM_DIRECTORY = "\\\\?\\GLOBALROOT\\SystemRoot\\System32";
|
|
29
|
+
|
|
30
|
+
function windowsSystemExecutable(name: "whoami.exe" | "icacls.exe" | "WindowsPowerShell\\v1.0\\powershell.exe"): string {
|
|
31
|
+
try {
|
|
32
|
+
return realpathSync.native(join(WINDOWS_SYSTEM_DIRECTORY, name));
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error("Windows system executable is unavailable");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function windowsUserSid(): string {
|
|
39
|
+
const output = execFileSync(windowsSystemExecutable("whoami.exe"), ["/user", "/fo", "csv", "/nh"], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
40
|
+
const matches = output.match(/S-\d+(?:-\d+)+/gi) ?? [];
|
|
41
|
+
if (matches.length !== 1) throw new Error("Windows user SID is unavailable");
|
|
42
|
+
return matches[0]!.toUpperCase();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function windowsLocalAdministratorSid(): string {
|
|
46
|
+
const script = "$ErrorActionPreference='Stop';$descriptor=New-Object System.Security.AccessControl.RawSecurityDescriptor 'D:(A;;FA;;;LA)';$descriptor.DiscretionaryAcl[0].SecurityIdentifier.Value";
|
|
47
|
+
const systemRoot = dirname(dirname(windowsSystemExecutable("whoami.exe")));
|
|
48
|
+
const output = execFileSync(windowsSystemExecutable("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...process.env, SystemRoot: systemRoot } });
|
|
49
|
+
const matches = output.match(/S-\d+(?:-\d+)+/gi) ?? [];
|
|
50
|
+
if (matches.length !== 1 || !isWindowsSid(matches[0]!)) throw new Error("Windows local Administrator SID is unavailable");
|
|
51
|
+
return matches[0]!.toUpperCase();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function windowsDacl(path: string): string {
|
|
55
|
+
const archive = `.gentle-ai-acl-${randomUUID()}.txt`;
|
|
56
|
+
const archivePath = join(dirname(path), archive);
|
|
57
|
+
try {
|
|
58
|
+
execFileSync(windowsSystemExecutable("icacls.exe"), [path, "/save", archive, "/c"], { cwd: dirname(path), encoding: "utf8", timeout: 5000, maxBuffer: 16384, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
59
|
+
const sddl = readFileSync(archivePath, "utf16le").match(/D:[^\r\n]+/)?.[0];
|
|
60
|
+
if (sddl === undefined) throw new Error("Windows DACL is unavailable");
|
|
61
|
+
return sddl;
|
|
62
|
+
} finally {
|
|
63
|
+
try { unlinkSync(archivePath); } catch {}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type WindowsDaclValidationReason = "protection" | "ace-count" | "ace-shape" | "ace-type" | "ace-flags" | "ace-rights" | "ace-reserved-fields" | "ace-trustee" | "ace-duplicate";
|
|
68
|
+
export type WindowsDaclTrusteeClassification = "OW" | "CO" | "BU" | "AU" | "WD" | "LA" | "sid" | "other" | "missing";
|
|
69
|
+
|
|
70
|
+
export class WindowsDaclValidationError extends Error {
|
|
71
|
+
readonly reason: WindowsDaclValidationReason;
|
|
72
|
+
readonly trustee?: WindowsDaclTrusteeClassification;
|
|
73
|
+
constructor(reason: WindowsDaclValidationReason, trustee?: WindowsDaclTrusteeClassification) {
|
|
74
|
+
super(`Windows DACL validation failed: ${reason}${trustee === undefined ? "" : ` (${trustee})`}`);
|
|
75
|
+
this.name = "WindowsDaclValidationError";
|
|
76
|
+
this.reason = reason;
|
|
77
|
+
this.trustee = trustee;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type WindowsOwnerClassification = "user" | "system" | "administrators" | "sid" | "other" | "missing";
|
|
82
|
+
|
|
83
|
+
export class WindowsOwnerValidationError extends Error {
|
|
84
|
+
readonly owner: WindowsOwnerClassification;
|
|
85
|
+
constructor(owner: WindowsOwnerClassification = "missing") {
|
|
86
|
+
super(`Windows owner validation failed (${owner})`);
|
|
87
|
+
this.name = "WindowsOwnerValidationError";
|
|
88
|
+
this.owner = owner;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function classifyWindowsTrustee(value: string | undefined): WindowsDaclTrusteeClassification {
|
|
93
|
+
const trustee = value?.toUpperCase() ?? "";
|
|
94
|
+
if (["OW", "CO", "BU", "AU", "WD", "LA"].includes(trustee)) return trustee as WindowsDaclTrusteeClassification;
|
|
95
|
+
if (isWindowsSid(trustee)) return "sid";
|
|
96
|
+
return trustee === "" ? "missing" : "other";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isWindowsSid(value: string): boolean {
|
|
100
|
+
return /^S-\d+(?:-\d+)+$/i.test(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function validatePrivateWindowsDacl(dacl: string, user: string, protectedDacl: boolean, localAdministratorSid?: string): void {
|
|
104
|
+
if (protectedDacl && !dacl.startsWith("D:P")) throw new WindowsDaclValidationError("protection");
|
|
105
|
+
const trustees = new Map([[user.toUpperCase(), "user"], [WINDOWS_SYSTEM, "system"], ["SY", "system"], [WINDOWS_ADMINISTRATORS, "administrators"], ["BA", "administrators"]]);
|
|
106
|
+
if (isWindowsSid(user) && localAdministratorSid !== undefined && isWindowsSid(localAdministratorSid) && user.toUpperCase() === localAdministratorSid.toUpperCase()) trustees.set("LA", "user");
|
|
107
|
+
const aces = [...dacl.matchAll(/\(([^()]*)\)/g)].map((match) => match[1]!.split(";"));
|
|
108
|
+
const expectedFlags = protectedDacl ? "OICI" : "ID";
|
|
109
|
+
const granted = new Set<string>();
|
|
110
|
+
for (const ace of aces) {
|
|
111
|
+
const trustee = trustees.get(ace[5]?.toUpperCase() ?? "");
|
|
112
|
+
if (ace.length !== 6) throw new WindowsDaclValidationError("ace-shape");
|
|
113
|
+
if (ace[0] !== "A") throw new WindowsDaclValidationError("ace-type");
|
|
114
|
+
if (ace[1] !== expectedFlags) throw new WindowsDaclValidationError("ace-flags");
|
|
115
|
+
if (ace[2] !== "FA") throw new WindowsDaclValidationError("ace-rights");
|
|
116
|
+
if (ace[3] !== "" || ace[4] !== "") throw new WindowsDaclValidationError("ace-reserved-fields");
|
|
117
|
+
if (trustee === undefined) throw new WindowsDaclValidationError("ace-trustee", classifyWindowsTrustee(ace[5]));
|
|
118
|
+
if (granted.has(trustee)) throw new WindowsDaclValidationError("ace-duplicate");
|
|
119
|
+
granted.add(trustee);
|
|
120
|
+
}
|
|
121
|
+
if (granted.size !== 3) throw new WindowsDaclValidationError("ace-count");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function validatePrivateWindowsOwner(owner: string | undefined, user: string): void {
|
|
125
|
+
const normalizedOwner = owner?.toUpperCase();
|
|
126
|
+
// Owners implicitly control a DACL, so accept only principals that the exact DACL already grants full control.
|
|
127
|
+
if (normalizedOwner === undefined) throw new WindowsOwnerValidationError("missing");
|
|
128
|
+
if (!isWindowsSid(user) || !isWindowsSid(normalizedOwner)) throw new WindowsOwnerValidationError(isWindowsSid(normalizedOwner) ? "sid" : "other");
|
|
129
|
+
if (normalizedOwner === user.toUpperCase()) return;
|
|
130
|
+
if (normalizedOwner === WINDOWS_SYSTEM) return;
|
|
131
|
+
if (normalizedOwner === WINDOWS_ADMINISTRATORS) return;
|
|
132
|
+
throw new WindowsOwnerValidationError("sid");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface WindowsAclIdentity {
|
|
136
|
+
user: string;
|
|
137
|
+
localAdministrator: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
type WindowsObjectKind = "file" | "directory";
|
|
141
|
+
|
|
142
|
+
function windowsAclIdentity(): WindowsAclIdentity {
|
|
143
|
+
return { user: windowsUserSid(), localAdministrator: windowsLocalAdministratorSid() };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function windowsOwnerSid(path: string, kind: WindowsObjectKind): string {
|
|
147
|
+
if (path.length === 0 || path.length > 32767 || path.includes("\0")) throw new WindowsOwnerValidationError();
|
|
148
|
+
const script = kind === "file"
|
|
149
|
+
? "$ErrorActionPreference='Stop';$acl=[System.IO.File]::GetAccessControl($env:GENTLE_PI_CANDIDATE_OWNER_PATH,[System.Security.AccessControl.AccessControlSections]::Owner);$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value"
|
|
150
|
+
: "$ErrorActionPreference='Stop';$acl=[System.IO.Directory]::GetAccessControl($env:GENTLE_PI_CANDIDATE_OWNER_PATH,[System.Security.AccessControl.AccessControlSections]::Owner);$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value";
|
|
151
|
+
const systemRoot = dirname(dirname(windowsSystemExecutable("whoami.exe")));
|
|
152
|
+
let output: string;
|
|
153
|
+
try {
|
|
154
|
+
output = execFileSync(windowsSystemExecutable("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { encoding: "utf8", timeout: 5000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...process.env, SystemRoot: systemRoot, GENTLE_PI_CANDIDATE_OWNER_PATH: path } });
|
|
155
|
+
} catch {
|
|
156
|
+
throw new WindowsOwnerValidationError();
|
|
157
|
+
}
|
|
158
|
+
const matches = output.match(/S-\d+(?:-\d+)+/gi) ?? [];
|
|
159
|
+
if (matches.length !== 1 || !isWindowsSid(matches[0]!)) throw new WindowsOwnerValidationError();
|
|
160
|
+
return matches[0]!.toUpperCase();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function assertTrustedWindowsOwner(path: string, kind: WindowsObjectKind): void {
|
|
164
|
+
validatePrivateWindowsOwner(windowsOwnerSid(path, kind), windowsUserSid());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function assertPrivateWindowsDacl(path: string, kind: WindowsObjectKind, protectedDacl: boolean, identity: WindowsAclIdentity = windowsAclIdentity()): void {
|
|
168
|
+
validatePrivateWindowsOwner(windowsOwnerSid(path, kind), identity.user);
|
|
169
|
+
validatePrivateWindowsDacl(windowsDacl(path), identity.user, protectedDacl, identity.localAdministrator);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function enforcePrivateWindowsDacl(path: string, identity: WindowsAclIdentity = windowsAclIdentity()): void {
|
|
173
|
+
validatePrivateWindowsOwner(windowsOwnerSid(path, "directory"), identity.user);
|
|
174
|
+
const { user, localAdministrator } = identity;
|
|
175
|
+
const sddl = `D:P(A;OICI;FA;;;${user})(A;OICI;FA;;;${WINDOWS_SYSTEM})(A;OICI;FA;;;${WINDOWS_ADMINISTRATORS})`;
|
|
176
|
+
const script = "$ErrorActionPreference='Stop';$acl=New-Object System.Security.AccessControl.DirectorySecurity;$acl.SetSecurityDescriptorSddlForm($env:GENTLE_PI_CANDIDATE_ACL_SDDL,[System.Security.AccessControl.AccessControlSections]::Access);[System.IO.Directory]::SetAccessControl($env:GENTLE_PI_CANDIDATE_ACL_PATH,$acl)";
|
|
177
|
+
const systemRoot = dirname(dirname(windowsSystemExecutable("whoami.exe")));
|
|
178
|
+
execFileSync(windowsSystemExecutable("WindowsPowerShell\\v1.0\\powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { encoding: "utf8", timeout: 5000, maxBuffer: 16384, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...process.env, SystemRoot: systemRoot, GENTLE_PI_CANDIDATE_ACL_PATH: path, GENTLE_PI_CANDIDATE_ACL_SDDL: sddl } });
|
|
179
|
+
assertPrivateWindowsDacl(path, "directory", true, identity);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function privateWindowsDacl(path: string, kind: WindowsObjectKind, protectedDacl: boolean, enforce = false): void {
|
|
183
|
+
if (testingWindowsAclAuthority !== undefined) return testingWindowsAclAuthority(path);
|
|
184
|
+
if (enforce) enforcePrivateWindowsDacl(path);
|
|
185
|
+
else assertPrivateWindowsDacl(path, kind, protectedDacl);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function privateWindowsCandidateOwnerBoundary(commonDir: string, enforce = false): void {
|
|
189
|
+
const boundary = [commonDir, join(commonDir, "gentle-ai"), join(commonDir, "gentle-ai", "candidate-views")];
|
|
190
|
+
if (testingWindowsAclAuthority !== undefined) {
|
|
191
|
+
for (const path of boundary) testingWindowsAclAuthority(path);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const identity = windowsAclIdentity();
|
|
195
|
+
if (enforce) {
|
|
196
|
+
for (const path of boundary) validatePrivateWindowsOwner(windowsOwnerSid(path, "directory"), identity.user);
|
|
197
|
+
for (const path of boundary) enforcePrivateWindowsDacl(path, identity);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
for (const path of boundary) assertPrivateWindowsDacl(path, "directory", true, identity);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// A hostname or a repository-local nonce cannot prove that a PID is local.
|
|
204
|
+
// Bind to the kernel boot and, on Linux, its PID namespace. Unsupported or
|
|
205
|
+
// unavailable provenance disables reclamation (including across reboots).
|
|
206
|
+
function localHost(): string | null {
|
|
207
|
+
try {
|
|
208
|
+
if (process.platform === "darwin") {
|
|
209
|
+
const boot = execFileSync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], { encoding: "utf8", timeout: 1000, maxBuffer: 4096, stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
210
|
+
if (BOOT_UUID.test(boot)) return `darwin:${boot.toLowerCase()}`;
|
|
211
|
+
}
|
|
212
|
+
if (process.platform === "linux") {
|
|
213
|
+
const boot = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
214
|
+
const namespace = readlinkSync("/proc/self/ns/pid");
|
|
215
|
+
if (BOOT_UUID.test(boot) && /^pid:\[\d+\]$/.test(namespace)) return `linux:${boot}:${namespace}`;
|
|
216
|
+
}
|
|
217
|
+
} catch { /* No remote/PID-only fallback. */ }
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function samePath(path: string, expected: string, platform: NodeJS.Platform): boolean {
|
|
222
|
+
if (platform !== "win32") return path === expected;
|
|
223
|
+
const canonical = (value: string): string => {
|
|
224
|
+
try {
|
|
225
|
+
return realpathSync.native(value).replaceAll("\\", "/").toLowerCase();
|
|
226
|
+
} catch {
|
|
227
|
+
return join(realpathSync.native(dirname(value)), basename(value)).replaceAll("\\", "/").toLowerCase();
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
return canonical(path) === canonical(expected);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function directory(path: string, privateMode = false, platform: NodeJS.Platform = process.platform): string {
|
|
234
|
+
const stat = lstatSync(path);
|
|
235
|
+
const uid = process.getuid?.();
|
|
236
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || !samePath(realpathSync(path), path, platform) ||
|
|
237
|
+
(privateMode && platform !== "win32" && (uid === undefined || stat.uid !== uid || (stat.mode & 0o077) !== 0))) throw new Error("Unsafe candidate owner directory");
|
|
238
|
+
if (privateMode && platform === "win32") privateWindowsDacl(path, "directory", true);
|
|
239
|
+
return `${stat.dev}:${stat.ino}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function assertCandidateOwnerParent(commonDir: string, platform: NodeJS.Platform = process.platform): string {
|
|
243
|
+
directory(commonDir, false, platform);
|
|
244
|
+
const control = join(commonDir, "gentle-ai");
|
|
245
|
+
directory(control, false, platform);
|
|
246
|
+
const parent = join(commonDir, "gentle-ai", "candidate-views");
|
|
247
|
+
directory(parent, false, platform);
|
|
248
|
+
if (platform === "win32") privateWindowsCandidateOwnerBoundary(commonDir);
|
|
249
|
+
else directory(parent, true, platform);
|
|
250
|
+
return parent;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function prepareCandidateOwnerParent(commonDir: string, platform: NodeJS.Platform = process.platform): string {
|
|
254
|
+
directory(commonDir, false, platform);
|
|
255
|
+
const control = join(commonDir, "gentle-ai");
|
|
256
|
+
directory(control, false, platform);
|
|
257
|
+
const parent = join(commonDir, "gentle-ai", "candidate-views");
|
|
258
|
+
directory(parent, false, platform);
|
|
259
|
+
if (platform === "win32") {
|
|
260
|
+
privateWindowsCandidateOwnerBoundary(commonDir, true);
|
|
261
|
+
return parent;
|
|
262
|
+
}
|
|
263
|
+
return assertCandidateOwnerParent(commonDir, platform);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function regular(path: string, privateMode = false, platform: NodeJS.Platform = process.platform): string {
|
|
267
|
+
const stat = lstatSync(path);
|
|
268
|
+
const uid = process.getuid?.();
|
|
269
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || !samePath(realpathSync(path), path, platform) || stat.size > 16384 ||
|
|
270
|
+
(privateMode && platform !== "win32" && (uid === undefined || stat.uid !== uid || (stat.mode & 0o777) !== 0o600))) throw new Error("Unsafe candidate owner file");
|
|
271
|
+
if (privateMode && platform === "win32") privateWindowsDacl(path, "file", false);
|
|
272
|
+
return `${stat.dev}:${stat.ino}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function syncDirectory(path: string): void {
|
|
276
|
+
// Windows does not permit fsync on directory handles; the marker file itself
|
|
277
|
+
// remains fsynced before this platform-specific no-op.
|
|
278
|
+
if (process.platform === "win32") return;
|
|
279
|
+
const fd = openSync(path, "r");
|
|
280
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function removeExactPrivateFile(path: string, identity: string, platform: NodeJS.Platform): void {
|
|
284
|
+
try {
|
|
285
|
+
if (regular(path, true, platform) !== identity) return;
|
|
286
|
+
unlinkSync(path);
|
|
287
|
+
syncDirectory(dirname(path));
|
|
288
|
+
} catch { /* An unproven or replaced file survives. */ }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function exclusiveFile(path: string, content: string, platform: NodeJS.Platform = process.platform, rollbackOnFailure = false): string {
|
|
292
|
+
let identity: string | undefined;
|
|
293
|
+
try {
|
|
294
|
+
const fd = openSync(path, "wx", 0o600);
|
|
295
|
+
try {
|
|
296
|
+
identity = regular(path, true, platform);
|
|
297
|
+
writeFileSync(fd, content);
|
|
298
|
+
fsyncSync(fd);
|
|
299
|
+
} finally { closeSync(fd); }
|
|
300
|
+
syncDirectory(dirname(path));
|
|
301
|
+
return identity;
|
|
302
|
+
} catch (error) {
|
|
303
|
+
if (rollbackOnFailure && identity !== undefined) removeExactPrivateFile(path, identity, platform);
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function markerPath(root: string): string { return `${root}.owner.json`; }
|
|
309
|
+
|
|
310
|
+
export function createCandidateOwner(commonDir: string, root: string, platform: NodeJS.Platform = process.platform): CandidateViewOwner {
|
|
311
|
+
const parent = assertCandidateOwnerParent(commonDir, platform);
|
|
312
|
+
const uuid = basename(root);
|
|
313
|
+
if (!UUID.test(uuid) || root !== join(parent, uuid) || lstatSync(root, { throwIfNoEntry: false })) throw new Error("Unsafe candidate owner root");
|
|
314
|
+
const owner: CandidateViewOwner = { version: 1, uuid, token: randomUUID(), pid: process.pid, host: localHost(), root, commonDir };
|
|
315
|
+
// Any write/fsync failure aborts creation BEFORE Git can register the view.
|
|
316
|
+
const marker = markerPath(root);
|
|
317
|
+
let markerIdentity: string | undefined;
|
|
318
|
+
try {
|
|
319
|
+
markerIdentity = exclusiveFile(marker, JSON.stringify(owner), platform, true);
|
|
320
|
+
syncDirectory(dirname(parent));
|
|
321
|
+
syncDirectory(commonDir);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (markerIdentity !== undefined) removeExactPrivateFile(marker, markerIdentity, platform);
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
return Object.freeze(owner);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function readOwner(commonDir: string, root: string, platform: NodeJS.Platform = process.platform): CandidateViewOwner {
|
|
330
|
+
const parent = assertCandidateOwnerParent(commonDir, platform);
|
|
331
|
+
if (!UUID.test(basename(root)) || root !== join(parent, basename(root))) throw new Error("Candidate owner escaped parent");
|
|
332
|
+
regular(markerPath(root), true, platform);
|
|
333
|
+
const owner = JSON.parse(readFileSync(markerPath(root), "utf8")) as CandidateViewOwner;
|
|
334
|
+
if (!owner || Object.keys(owner).sort().join(",") !== "commonDir,host,pid,root,token,uuid,version" || owner.version !== 1 ||
|
|
335
|
+
owner.uuid !== basename(root) || !UUID.test(owner.token) || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 ||
|
|
336
|
+
owner.root !== root || owner.commonDir !== commonDir || !(owner.host === null || typeof owner.host === "string")) throw new Error("Malformed candidate owner");
|
|
337
|
+
return owner;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function dead(owner: CandidateViewOwner): boolean {
|
|
341
|
+
return owner.host !== null && owner.host === localHost() && conservativeOwnerDeathProofV1({
|
|
342
|
+
pid: owner.pid, token: owner.token, owner_hash: "", repository_id: owner.commonDir, authority_id: owner.uuid,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function registration(root: string, commonDir: string, git: Git, platform: NodeJS.Platform = process.platform): string {
|
|
347
|
+
const rows = git(["worktree", "list", "--porcelain", "-z"]).split("\0\0");
|
|
348
|
+
const matching = rows.filter((row) => {
|
|
349
|
+
const worktree = row.split("\0")[0];
|
|
350
|
+
return worktree !== undefined && worktree.startsWith("worktree ") && samePath(worktree.slice(9), root, platform);
|
|
351
|
+
});
|
|
352
|
+
if (matching.length !== 1 || matching[0]!.split("\0").some((field) => /^(locked|prunable)( |$)/.test(field))) throw new Error("Candidate registration is ambiguous");
|
|
353
|
+
const gitFile = join(root, ".git");
|
|
354
|
+
regular(gitFile);
|
|
355
|
+
const pointer = readFileSync(gitFile, "utf8");
|
|
356
|
+
if (!pointer.startsWith("gitdir: ") || !pointer.endsWith("\n")) throw new Error("Unsafe candidate Git pointer");
|
|
357
|
+
const admin = pointer.slice(8, -1);
|
|
358
|
+
if (!samePath(dirname(admin), join(commonDir, "worktrees"), platform)) throw new Error("Candidate admin escaped common directory");
|
|
359
|
+
directory(join(commonDir, "worktrees"));
|
|
360
|
+
directory(admin);
|
|
361
|
+
regular(join(admin, "gitdir"));
|
|
362
|
+
regular(join(admin, "commondir"));
|
|
363
|
+
const gitdir = readFileSync(join(admin, "gitdir"), "utf8");
|
|
364
|
+
if (!gitdir.endsWith("\n") || !samePath(gitdir.slice(0, -1), gitFile, platform) ||
|
|
365
|
+
!samePath(resolve(admin, readFileSync(join(admin, "commondir"), "utf8").trim()), commonDir, platform)) throw new Error("Candidate Git backlinks changed");
|
|
366
|
+
return matching[0]! + pointer;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function removeCandidateOwner(owner: CandidateViewOwner, git: Git, makeWritable: (root: string) => void, orphan = false, platform: NodeJS.Platform = process.platform): void {
|
|
370
|
+
const { root, commonDir } = owner;
|
|
371
|
+
const parent = assertCandidateOwnerParent(commonDir, platform);
|
|
372
|
+
const expected = JSON.stringify(owner);
|
|
373
|
+
const parentIdentity = directory(parent, true, platform);
|
|
374
|
+
const markerIdentity = regular(markerPath(root), true, platform);
|
|
375
|
+
const checkOwner = (): void => {
|
|
376
|
+
if (directory(parent, true, platform) !== parentIdentity || regular(markerPath(root), true, platform) !== markerIdentity ||
|
|
377
|
+
JSON.stringify(readOwner(commonDir, root, platform)) !== expected || (orphan ? !dead(owner) : owner.pid !== process.pid)) throw new Error("Candidate ownership changed");
|
|
378
|
+
};
|
|
379
|
+
checkOwner();
|
|
380
|
+
const lock = `${root}.reaper-lock`;
|
|
381
|
+
const token = randomUUID();
|
|
382
|
+
exclusiveFile(lock, token); // EEXIST is final; unknown/stale locks are never stolen.
|
|
383
|
+
const lockIdentity = regular(lock, true, platform);
|
|
384
|
+
const checkLock = (): void => {
|
|
385
|
+
if (regular(lock, true, platform) !== lockIdentity || readFileSync(lock, "utf8") !== token) throw new Error("Candidate reaper lock changed");
|
|
386
|
+
};
|
|
387
|
+
try {
|
|
388
|
+
checkOwner();
|
|
389
|
+
const identity = [directory(parent, true, platform), directory(root), regular(markerPath(root), true, platform)];
|
|
390
|
+
const registered = registration(root, commonDir, git, platform);
|
|
391
|
+
checkOwner();
|
|
392
|
+
checkLock();
|
|
393
|
+
makeWritable(root);
|
|
394
|
+
// Git and chmod are race boundaries: repeat path, owner, lock, and exact
|
|
395
|
+
// registration proofs immediately before asking Git to remove this root.
|
|
396
|
+
if (registration(root, commonDir, git, platform) !== registered ||
|
|
397
|
+
JSON.stringify([directory(parent, true, platform), directory(root), regular(markerPath(root), true, platform)]) !== JSON.stringify(identity)) throw new Error("Candidate cleanup identity changed");
|
|
398
|
+
checkOwner();
|
|
399
|
+
checkLock();
|
|
400
|
+
git(["worktree", "remove", "--force", root]);
|
|
401
|
+
// Git failure or incomplete removal must never trigger recursive rm.
|
|
402
|
+
if (lstatSync(root, { throwIfNoEntry: false }) || git(["worktree", "list", "--porcelain", "-z"]).split("\0\0").some((row) => {
|
|
403
|
+
const worktree = row.split("\0")[0];
|
|
404
|
+
return worktree !== undefined && worktree.startsWith("worktree ") && samePath(worktree.slice(9), root, platform);
|
|
405
|
+
})) throw new Error("Candidate removal is incomplete");
|
|
406
|
+
checkOwner();
|
|
407
|
+
checkLock();
|
|
408
|
+
unlinkSync(markerPath(root));
|
|
409
|
+
syncDirectory(parent);
|
|
410
|
+
} finally {
|
|
411
|
+
// Only release this exact lock, even when the deletion itself failed.
|
|
412
|
+
try { assertCandidateOwnerParent(commonDir, platform); checkLock(); unlinkSync(lock); syncDirectory(parent); } catch {}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function sweepCandidateOwners(commonDir: string, git: Git, makeWritable: (root: string) => void, platform: NodeJS.Platform = process.platform): void {
|
|
417
|
+
try {
|
|
418
|
+
const parent = assertCandidateOwnerParent(commonDir, platform);
|
|
419
|
+
for (const name of readdirSync(parent)) {
|
|
420
|
+
if (!name.endsWith(".owner.json")) continue;
|
|
421
|
+
try {
|
|
422
|
+
const owner = readOwner(commonDir, join(parent, name.slice(0, -11)), platform);
|
|
423
|
+
if (dead(owner)) removeCandidateOwner(owner, git, makeWritable, true, platform);
|
|
424
|
+
} catch { /* Unknown, legacy, unsafe, unregistered, and contended entries survive. */ }
|
|
425
|
+
}
|
|
426
|
+
} catch { /* Startup/materialization sweeps are best-effort; never create a store. */ }
|
|
427
|
+
}
|