gentle-pi 4.2.0 → 4.3.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 +336 -137
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +9 -9
- package/bin/gentle-shell.mjs +170 -22
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/bundled-install.md +33 -3
- package/docs/delegated-verification.md +10 -8
- package/docs/gentle-agents-activity.md +11 -5
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +5 -4
- package/docs/install-wizard.md +8 -1
- package/docs/permission-modes.md +1 -1
- package/docs/readme-reference.md +107 -19
- package/docs/yolo-mode.md +21 -8
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +99 -12
- package/extensions/gentle-ai.ts +284 -52
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-shell.ts +27 -12
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +365 -0
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agents-protocol.ts +3 -0
- package/lib/agents-runner.ts +55 -6
- package/lib/agents-view.ts +3 -1
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/command-palette-catalog.ts +0 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +84 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-shell-bundled.ts +39 -3
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +145 -11
- package/lib/gentle-shell-uninstall.ts +18 -1
- package/lib/herdr-activity.ts +24 -0
- package/lib/native-review-cli.ts +9 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +9 -0
- package/lib/orchestrator-scope.ts +21 -3
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-scratch.ts +474 -0
- package/lib/shell-bar.ts +25 -25
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-todo.ts +23 -4
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +29 -0
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +38 -2
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +143 -9
- package/runtime/gentle-shell-uninstall.mjs +18 -1
- package/runtime/native-review-cli.mjs +8 -0
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-server.mjs +14 -4
- package/scripts/main-channel.mjs +3 -2
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +19 -13
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-runner.test.ts +260 -3
- package/tests/autonomous-guard.test.ts +122 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +14 -4
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +159 -0
- package/tests/delegate-for-reason-contract.test.ts +91 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +1 -1
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +282 -3
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +169 -6
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +79 -0
- package/tests/gentle-shell-uninstall.test.ts +30 -0
- package/tests/gentle-shell.test.ts +42 -9
- package/tests/gentle-todo.test.ts +179 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +33 -4
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-parity.test.ts +22 -1
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +19 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +66 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-manifest.test.ts +24 -20
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +79 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +59 -2
- package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +945 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/shell-bar.test.ts +7 -0
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +69 -9
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +137 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/windows-hidden-processes.test.ts +5 -0
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-mode.test.ts +18 -0
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -263,6 +263,15 @@ export function readActivity(profile: string, selection: Header): { activity?: A
|
|
|
263
263
|
} catch (error) { return { unavailable: reason(error) }; }
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
/** True only when no discovery sidecar exists for this header: the publisher
|
|
267
|
+
* predates discovery publication. A present but unreadable sidecar is not absent. */
|
|
268
|
+
export function discoveryAbsent(profile: string, h: Header): boolean {
|
|
269
|
+
try {
|
|
270
|
+
fs.lstatSync(join(rootFor(profile), filename(h, "discovery")));
|
|
271
|
+
return false;
|
|
272
|
+
} catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
|
|
273
|
+
}
|
|
274
|
+
|
|
266
275
|
/** Optional derived sidecar; failures never invalidate the schema-1 header. */
|
|
267
276
|
export function readDiscovery(profile: string, h: Header): DiscoveryMetadata | undefined {
|
|
268
277
|
try {
|
|
@@ -41,9 +41,14 @@ export function validRecordedScope(value: any): value is RecordedScope {
|
|
|
41
41
|
/** One bounded derivative snapshot, not a registry or authority. The caller supplies
|
|
42
42
|
* only admitted, unfinished, non-restored owned children and durable registry roots.
|
|
43
43
|
* Membership/cwd changes invalidate even unknown facts; session replacement clears.
|
|
44
|
-
*
|
|
44
|
+
* Recency reorders rebuild caller-ordered snapshots without refreshing visible facts.
|
|
45
|
+
* Resolution stays bounded to the host and first eight tasks/registered roots;
|
|
46
|
+
* newly visible paths are resolved lazily, including after a reorder. */
|
|
45
47
|
export class OrchestratorScopeCache {
|
|
46
48
|
private key = "";
|
|
49
|
+
private membershipKey = "";
|
|
50
|
+
private facts = new Map<string, RepositoryFact>();
|
|
51
|
+
private resolvedAt = 0;
|
|
47
52
|
private snapshot?: RecordedScope;
|
|
48
53
|
private resolver: WorktreeResolver;
|
|
49
54
|
private now: () => number;
|
|
@@ -53,14 +58,24 @@ export class OrchestratorScopeCache {
|
|
|
53
58
|
}
|
|
54
59
|
clear() {
|
|
55
60
|
this.key = "";
|
|
61
|
+
this.membershipKey = "";
|
|
62
|
+
this.facts.clear();
|
|
56
63
|
this.snapshot = undefined;
|
|
57
64
|
}
|
|
58
65
|
project(cwd: string, tasks: readonly { id: string; cwd: string }[], roots: readonly string[]): RecordedScope {
|
|
59
66
|
const registered = [...new Set(roots)];
|
|
60
67
|
const key = createHash("sha256").update(JSON.stringify([cwd, tasks.map(t => [t.id, t.cwd]), registered])).digest("hex");
|
|
61
68
|
if (key === this.key && this.snapshot) return this.snapshot;
|
|
62
|
-
const
|
|
63
|
-
|
|
69
|
+
const membershipKey = createHash("sha256").update(JSON.stringify([
|
|
70
|
+
cwd, tasks.map(t => JSON.stringify([t.id, t.cwd])).sort(), [...registered].sort(),
|
|
71
|
+
])).digest("hex");
|
|
72
|
+
if (membershipKey !== this.membershipKey) {
|
|
73
|
+
this.facts.clear();
|
|
74
|
+
this.resolvedAt = this.now();
|
|
75
|
+
this.membershipKey = membershipKey;
|
|
76
|
+
}
|
|
77
|
+
const resolvedAt = this.resolvedAt;
|
|
78
|
+
const facts = this.facts;
|
|
64
79
|
const fact = (path: string) => {
|
|
65
80
|
if (facts.has(path)) return facts.get(path)!;
|
|
66
81
|
const unknown: RepositoryFact = { root: null, cloneHash: null, resolvedAt, source: "recorded-workspace/git" };
|
|
@@ -74,6 +89,9 @@ export class OrchestratorScopeCache {
|
|
|
74
89
|
this.snapshot = { host: fact(cwd), tasks: tasks.slice(0, 8).map(t => ({ id: t.id, repository: fact(t.cwd) })),
|
|
75
90
|
registered: registered.slice(0, 8).map(fact), omittedTasks: Math.max(0, tasks.length - 8),
|
|
76
91
|
omittedRegistered: Math.max(0, registered.length - 8), complete: tasks.length <= 8 && registered.length <= 8 };
|
|
92
|
+
// Keep fact retention bounded even when an oversized task list rotates.
|
|
93
|
+
const visible = new Set([cwd, ...tasks.slice(0, 8).map(t => t.cwd), ...registered.slice(0, 8)]);
|
|
94
|
+
for (const path of facts.keys()) if (!visible.has(path)) facts.delete(path);
|
|
77
95
|
this.key = key;
|
|
78
96
|
return this.snapshot;
|
|
79
97
|
}
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// release bundle (or its extracted tree) and verifies it fail-closed before
|
|
5
5
|
// any layout, role, schema, or capability inside it is trusted:
|
|
6
6
|
//
|
|
7
|
-
// - exact eight
|
|
7
|
+
// - exact inventory: eight base entries (README.md, manifest.json, 3 schemas,
|
|
8
|
+
// 3 vectors) plus one `orchestration/<runtime>.md` (>= 1.2.0) and one
|
|
9
|
+
// `routing/<runtime>.md` (>= 1.4.0) per runtime the manifest lists;
|
|
8
10
|
// - strict manifest decode: single JSON object, no duplicate keys, no unknown fields;
|
|
9
11
|
// - per-file SHA-256 agreement between the manifest and the actual bytes;
|
|
10
12
|
// - pinned transport capability and supported contract major (1);
|
|
@@ -33,6 +35,8 @@ export const SUPPORTED_PROVIDER_CONTRACT_MAJOR = 1;
|
|
|
33
35
|
export const RUNTIMES_REQUIRED_SINCE_MINOR = 1;
|
|
34
36
|
// `orchestration` became part of the manifest in contract 1.2.0.
|
|
35
37
|
export const ORCHESTRATION_REQUIRED_SINCE_MINOR = 2;
|
|
38
|
+
// `routing` became part of the manifest in contract 1.4.0.
|
|
39
|
+
export const ROUTING_REQUIRED_SINCE_MINOR = 4;
|
|
36
40
|
// Exactly the provider roles gentle-pi supports, in the manifest's strict order.
|
|
37
41
|
export const PROVIDER_CONTRACT_ROLE_IDS = ["lens", "refuter", "targeted-validator"] as const;
|
|
38
42
|
// Every mandatory capability a role may declare. An unknown mandatory
|
|
@@ -46,11 +50,11 @@ const MAX_MANIFEST_BYTES = 64 * 1024;
|
|
|
46
50
|
const BUNDLE_ENTRY_COUNT = 8;
|
|
47
51
|
// Generous fixed safety bound on raw archive/tree entries read BEFORE the
|
|
48
52
|
// manifest itself is parsed (the exact expected count, base entries plus one
|
|
49
|
-
// per `orchestration` runtime, is known only after
|
|
50
|
-
// contracts register a handful of runtimes; this bound
|
|
51
|
-
// absurd smuggled inventory, it is not the exact-count check.
|
|
52
|
-
const
|
|
53
|
-
const MAX_RAW_BUNDLE_ENTRIES = BUNDLE_ENTRY_COUNT +
|
|
53
|
+
// per `orchestration` and one per `routing` runtime, is known only after
|
|
54
|
+
// manifest decode). Real contracts register a handful of runtimes; this bound
|
|
55
|
+
// only rules out an absurd smuggled inventory, it is not the exact-count check.
|
|
56
|
+
const MAX_RUNTIME_FILE_ENTRIES = 64;
|
|
57
|
+
const MAX_RAW_BUNDLE_ENTRIES = BUNDLE_ENTRY_COUNT + 2 * MAX_RUNTIME_FILE_ENTRIES;
|
|
54
58
|
// setuid/setgid/sticky, any execute bit, or world-writable content is never
|
|
55
59
|
// acceptable for a data-only contract bundle.
|
|
56
60
|
const FORBIDDEN_MODE_BITS = 0o7113;
|
|
@@ -99,7 +103,13 @@ export interface VerifiedProviderContractBundle {
|
|
|
99
103
|
* 1.2.0, where `orchestration` does not exist in the manifest.
|
|
100
104
|
*/
|
|
101
105
|
readonly orchestration: ReadonlyMap<string, string>;
|
|
102
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Verified ODD implementation-routing block, runtime identity -> UTF-8
|
|
108
|
+
* `routing/<runtime>.md` contents. Empty before contract 1.4.0, where
|
|
109
|
+
* `routing` does not exist in the manifest.
|
|
110
|
+
*/
|
|
111
|
+
readonly routing: ReadonlyMap<string, string>;
|
|
112
|
+
/** Canonical path -> exact verified bytes for all entries (base eight plus one per orchestration and routing runtime). */
|
|
103
113
|
readonly entries: ReadonlyMap<string, Buffer>;
|
|
104
114
|
/** Canonical path -> SHA-256 of the exact verified bytes. */
|
|
105
115
|
readonly entrySha256: ReadonlyMap<string, string>;
|
|
@@ -321,23 +331,65 @@ function parseManifestRole(value: unknown, index: number): ManifestRole {
|
|
|
321
331
|
};
|
|
322
332
|
}
|
|
323
333
|
|
|
324
|
-
|
|
334
|
+
// One entry of a runtime-file inventory: `orchestration` (>= 1.2.0) or
|
|
335
|
+
// `routing` (>= 1.4.0). Both share the shape `{ runtime, file }` with the file
|
|
336
|
+
// at `<inventory>/<runtime>.md`.
|
|
337
|
+
interface ManifestRuntimeFileEntry {
|
|
325
338
|
readonly runtime: string;
|
|
326
339
|
readonly file: ManifestFileReference;
|
|
327
340
|
}
|
|
328
341
|
|
|
329
|
-
|
|
330
|
-
|
|
342
|
+
type RuntimeFileInventory = "orchestration" | "routing";
|
|
343
|
+
|
|
344
|
+
function parseRuntimeFileEntry(inventory: RuntimeFileInventory, value: unknown, index: number): ManifestRuntimeFileEntry {
|
|
345
|
+
const where = `manifest.${inventory}[${index}]`;
|
|
331
346
|
const object = requireObject(value, where);
|
|
332
347
|
requireExactKeys(object, ["runtime", "file"], ["runtime", "file"], where);
|
|
333
348
|
const runtime = requireString(object.runtime, `${where}.runtime`);
|
|
334
349
|
if (!RUNTIME_IDENTITY_PATTERN.test(runtime)) invalid(`${where}.runtime ${JSON.stringify(runtime)} is not a runtime identity`);
|
|
335
350
|
const file = parseFileReference(object.file, `${where}.file`);
|
|
336
|
-
const expectedPath =
|
|
351
|
+
const expectedPath = `${inventory}/${runtime}.md`;
|
|
337
352
|
if (file.path !== expectedPath) invalid(`${where}.file.path must be ${JSON.stringify(expectedPath)}`);
|
|
338
353
|
return { runtime, file };
|
|
339
354
|
}
|
|
340
355
|
|
|
356
|
+
// Decodes one runtime-file inventory: absent before the contract minor that
|
|
357
|
+
// introduced it, required from it on, strictly sorted, every runtime
|
|
358
|
+
// registered in manifest.runtimes, and `pi` always present.
|
|
359
|
+
function parseRuntimeFileInventory(
|
|
360
|
+
object: Record<string, unknown>,
|
|
361
|
+
inventory: RuntimeFileInventory,
|
|
362
|
+
requiredSinceMinor: number,
|
|
363
|
+
contractSemver: string,
|
|
364
|
+
minor: number,
|
|
365
|
+
runtimes: readonly string[] | undefined,
|
|
366
|
+
): readonly ManifestRuntimeFileEntry[] {
|
|
367
|
+
const required = minor >= requiredSinceMinor;
|
|
368
|
+
if (!Object.prototype.hasOwnProperty.call(object, inventory)) {
|
|
369
|
+
if (required) invalid(`manifest.${inventory} is required from contract 1.${requiredSinceMinor}.0 (got ${contractSemver} without it)`);
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
if (!required) {
|
|
373
|
+
invalid(`manifest.${inventory} is not part of contract ${contractSemver}; it exists from 1.${requiredSinceMinor}.0`);
|
|
374
|
+
}
|
|
375
|
+
const value = object[inventory];
|
|
376
|
+
if (!Array.isArray(value) || value.length === 0) invalid(`manifest.${inventory} must be a non-empty array`);
|
|
377
|
+
const entries = value.map((entry, index) => parseRuntimeFileEntry(inventory, entry, index));
|
|
378
|
+
for (let position = 0; position < entries.length; position += 1) {
|
|
379
|
+
const entry = entries[position] as ManifestRuntimeFileEntry;
|
|
380
|
+
if (position > 0 && (entries[position - 1] as ManifestRuntimeFileEntry).runtime >= entry.runtime) {
|
|
381
|
+
invalid(`manifest.${inventory} must be strictly sorted by runtime and unique`);
|
|
382
|
+
}
|
|
383
|
+
if (runtimes === undefined || !runtimes.includes(entry.runtime)) {
|
|
384
|
+
invalid(`manifest.${inventory}[${position}].runtime ${JSON.stringify(entry.runtime)} is not registered in manifest.runtimes`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (!entries.some((entry) => entry.runtime === PI_RUNTIME_IDENTITY)) {
|
|
388
|
+
invalid(`manifest.${inventory} does not register the ${JSON.stringify(PI_RUNTIME_IDENTITY)} runtime identity`);
|
|
389
|
+
}
|
|
390
|
+
return entries;
|
|
391
|
+
}
|
|
392
|
+
|
|
341
393
|
interface ParsedManifest {
|
|
342
394
|
readonly contractSemver: string;
|
|
343
395
|
readonly major: number;
|
|
@@ -347,14 +399,15 @@ interface ParsedManifest {
|
|
|
347
399
|
readonly runtimes: readonly string[] | undefined;
|
|
348
400
|
readonly readme: ManifestFileReference;
|
|
349
401
|
readonly roles: readonly ManifestRole[];
|
|
350
|
-
readonly orchestration: readonly
|
|
402
|
+
readonly orchestration: readonly ManifestRuntimeFileEntry[];
|
|
403
|
+
readonly routing: readonly ManifestRuntimeFileEntry[];
|
|
351
404
|
}
|
|
352
405
|
|
|
353
406
|
function parseManifest(payload: Buffer): ParsedManifest {
|
|
354
407
|
const object = decodeStrictManifestObject(payload);
|
|
355
408
|
requireExactKeys(
|
|
356
409
|
object,
|
|
357
|
-
["schema", "contract_semver", "transport_capability", "runtimes", "readme", "roles", "orchestration"],
|
|
410
|
+
["schema", "contract_semver", "transport_capability", "runtimes", "readme", "roles", "orchestration", "routing"],
|
|
358
411
|
["schema", "contract_semver", "transport_capability", "readme", "roles"],
|
|
359
412
|
"manifest",
|
|
360
413
|
);
|
|
@@ -398,32 +451,8 @@ function parseManifest(payload: Buffer): ParsedManifest {
|
|
|
398
451
|
invalid(`manifest.runtimes does not register the ${JSON.stringify(PI_RUNTIME_IDENTITY)} runtime identity`);
|
|
399
452
|
}
|
|
400
453
|
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
if (Object.prototype.hasOwnProperty.call(object, "orchestration")) {
|
|
404
|
-
if (!orchestrationRequired) {
|
|
405
|
-
invalid(`manifest.orchestration is not part of contract ${contractSemver}; it exists from 1.${ORCHESTRATION_REQUIRED_SINCE_MINOR}.0`);
|
|
406
|
-
}
|
|
407
|
-
if (!Array.isArray(object.orchestration) || object.orchestration.length === 0) {
|
|
408
|
-
invalid("manifest.orchestration must be a non-empty array");
|
|
409
|
-
}
|
|
410
|
-
const parsedOrchestration = object.orchestration.map((entry, index) => parseOrchestrationEntry(entry, index));
|
|
411
|
-
for (let position = 0; position < parsedOrchestration.length; position += 1) {
|
|
412
|
-
const entry = parsedOrchestration[position] as ManifestOrchestrationEntry;
|
|
413
|
-
if (position > 0 && (parsedOrchestration[position - 1] as ManifestOrchestrationEntry).runtime >= entry.runtime) {
|
|
414
|
-
invalid("manifest.orchestration must be strictly sorted by runtime and unique");
|
|
415
|
-
}
|
|
416
|
-
if (runtimes === undefined || !runtimes.includes(entry.runtime)) {
|
|
417
|
-
invalid(`manifest.orchestration[${position}].runtime ${JSON.stringify(entry.runtime)} is not registered in manifest.runtimes`);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
if (!parsedOrchestration.some((entry) => entry.runtime === PI_RUNTIME_IDENTITY)) {
|
|
421
|
-
invalid(`manifest.orchestration does not register the ${JSON.stringify(PI_RUNTIME_IDENTITY)} runtime identity`);
|
|
422
|
-
}
|
|
423
|
-
orchestration = parsedOrchestration;
|
|
424
|
-
} else if (orchestrationRequired) {
|
|
425
|
-
invalid(`manifest.orchestration is required from contract 1.${ORCHESTRATION_REQUIRED_SINCE_MINOR}.0 (got ${contractSemver} without it)`);
|
|
426
|
-
}
|
|
454
|
+
const orchestration = parseRuntimeFileInventory(object, "orchestration", ORCHESTRATION_REQUIRED_SINCE_MINOR, contractSemver, minor, runtimes);
|
|
455
|
+
const routing = parseRuntimeFileInventory(object, "routing", ROUTING_REQUIRED_SINCE_MINOR, contractSemver, minor, runtimes);
|
|
427
456
|
|
|
428
457
|
if (!Array.isArray(object.roles)) invalid("manifest.roles must be an array");
|
|
429
458
|
const roles = object.roles.map((role, index) => parseManifestRole(role, index));
|
|
@@ -446,6 +475,7 @@ function parseManifest(payload: Buffer): ParsedManifest {
|
|
|
446
475
|
readme: parseFileReference(object.readme, "manifest.readme"),
|
|
447
476
|
roles,
|
|
448
477
|
orchestration,
|
|
478
|
+
routing,
|
|
449
479
|
};
|
|
450
480
|
}
|
|
451
481
|
|
|
@@ -541,8 +571,9 @@ export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<stri
|
|
|
541
571
|
|
|
542
572
|
// The exact expected inventory size is only known once the manifest is
|
|
543
573
|
// decoded: base entries plus exactly one `orchestration/<runtime>.md` per
|
|
544
|
-
// registered orchestration runtime
|
|
545
|
-
|
|
574
|
+
// registered orchestration runtime and one `routing/<runtime>.md` per
|
|
575
|
+
// registered routing runtime.
|
|
576
|
+
const expectedEntryCount = BUNDLE_ENTRY_COUNT + manifest.orchestration.length + manifest.routing.length;
|
|
546
577
|
if (rawEntries.size !== expectedEntryCount) {
|
|
547
578
|
invalid(`bundle inventory has ${rawEntries.size} files; exactly ${expectedEntryCount} are expected`);
|
|
548
579
|
}
|
|
@@ -554,13 +585,18 @@ export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<stri
|
|
|
554
585
|
expected.add(`schemas/${role.id}.schema.json`);
|
|
555
586
|
expected.add(`vectors/${role.id}.json`);
|
|
556
587
|
}
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
588
|
+
const verifyRuntimeFiles = (inventory: RuntimeFileInventory, manifestEntries: readonly ManifestRuntimeFileEntry[]): Map<string, string> => {
|
|
589
|
+
const texts = new Map<string, string>();
|
|
590
|
+
for (const entry of manifestEntries) {
|
|
591
|
+
const path = `${inventory}/${entry.runtime}.md`;
|
|
592
|
+
const payload = verifyFileReference(rawEntries, entry.file, path);
|
|
593
|
+
texts.set(entry.runtime, payload.toString("utf8"));
|
|
594
|
+
expected.add(path);
|
|
595
|
+
}
|
|
596
|
+
return texts;
|
|
597
|
+
};
|
|
598
|
+
const orchestration = verifyRuntimeFiles("orchestration", manifest.orchestration);
|
|
599
|
+
const routing = verifyRuntimeFiles("routing", manifest.routing);
|
|
564
600
|
for (const name of rawEntries.keys()) {
|
|
565
601
|
if (!expected.has(name)) invalid(`bundle has unexpected file ${JSON.stringify(name)}`);
|
|
566
602
|
}
|
|
@@ -591,6 +627,7 @@ export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<stri
|
|
|
591
627
|
vectorSha256: role.vector.sha256,
|
|
592
628
|
})),
|
|
593
629
|
orchestration,
|
|
630
|
+
routing,
|
|
594
631
|
entries,
|
|
595
632
|
entrySha256,
|
|
596
633
|
treeSha256: bundleTreeSha256(entrySha256),
|
|
@@ -745,6 +782,18 @@ export const PROVIDER_CAPABILITIES_BASELINE_SCHEMA = "gentle-pi.provider-contrac
|
|
|
745
782
|
export const PROVIDER_ROLES_BASELINE_FILE = "provider-roles.baseline.json";
|
|
746
783
|
export const PROVIDER_CAPABILITIES_BASELINE_FILE = "provider-capabilities.baseline.json";
|
|
747
784
|
|
|
785
|
+
function runtimeFileBaseline(
|
|
786
|
+
bundle: VerifiedProviderContractBundle,
|
|
787
|
+
inventory: RuntimeFileInventory,
|
|
788
|
+
texts: ReadonlyMap<string, string>,
|
|
789
|
+
): { runtime: string; path: string; sha256: string | undefined }[] {
|
|
790
|
+
return [...texts.keys()].sort().map((runtime) => ({
|
|
791
|
+
runtime,
|
|
792
|
+
path: `${inventory}/${runtime}.md`,
|
|
793
|
+
sha256: bundle.entrySha256.get(`${inventory}/${runtime}.md`),
|
|
794
|
+
}));
|
|
795
|
+
}
|
|
796
|
+
|
|
748
797
|
/**
|
|
749
798
|
* Deterministically derives the provider-owned consumer contract surface from
|
|
750
799
|
* a verified bundle. Pi overlays never live in these files; they are pure
|
|
@@ -773,11 +822,8 @@ export function generateProviderContractBaselines(bundle: VerifiedProviderContra
|
|
|
773
822
|
mandatory_capabilities: mandatory,
|
|
774
823
|
runtimes: bundle.runtimes === undefined ? null : [...bundle.runtimes],
|
|
775
824
|
pi_registered: piRuntimeRegistration(bundle).registered,
|
|
776
|
-
orchestration:
|
|
777
|
-
|
|
778
|
-
path: `orchestration/${runtime}.md`,
|
|
779
|
-
sha256: bundle.entrySha256.get(`orchestration/${runtime}.md`),
|
|
780
|
-
})),
|
|
825
|
+
orchestration: runtimeFileBaseline(bundle, "orchestration", bundle.orchestration),
|
|
826
|
+
routing: runtimeFileBaseline(bundle, "routing", bundle.routing),
|
|
781
827
|
};
|
|
782
828
|
return new Map([
|
|
783
829
|
[PROVIDER_ROLES_BASELINE_FILE, `${JSON.stringify(roles, null, 2)}\n`],
|
|
@@ -208,19 +208,19 @@ function enforcePrivateWindowsDacl(path: string, identity: WindowsAclIdentity =
|
|
|
208
208
|
assertPrivateWindowsDacl(path, "directory", true, identity);
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
function privateWindowsDacl(path: string, kind: WindowsObjectKind, protectedDacl: boolean, enforce = false): void {
|
|
211
|
+
function privateWindowsDacl(path: string, kind: WindowsObjectKind, protectedDacl: boolean, enforce = false, windowsIdentity?: WindowsAclIdentity): void {
|
|
212
212
|
if (testingWindowsAclAuthority !== undefined) return testingWindowsAclAuthority(path);
|
|
213
|
-
if (enforce) enforcePrivateWindowsDacl(path);
|
|
214
|
-
else assertPrivateWindowsDacl(path, kind, protectedDacl);
|
|
213
|
+
if (enforce) enforcePrivateWindowsDacl(path, windowsIdentity);
|
|
214
|
+
else assertPrivateWindowsDacl(path, kind, protectedDacl, windowsIdentity);
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
function privateWindowsCandidateOwnerBoundary(commonDir: string, enforce = false): void {
|
|
217
|
+
function privateWindowsCandidateOwnerBoundary(commonDir: string, enforce = false, windowsIdentity?: WindowsAclIdentity): void {
|
|
218
218
|
const boundary = [commonDir, join(commonDir, "gentle-ai"), join(commonDir, "gentle-ai", "candidate-views")];
|
|
219
219
|
if (testingWindowsAclAuthority !== undefined) {
|
|
220
220
|
for (const path of boundary) testingWindowsAclAuthority(path);
|
|
221
221
|
return;
|
|
222
222
|
}
|
|
223
|
-
const identity = windowsAclIdentity();
|
|
223
|
+
const identity = windowsIdentity ?? windowsAclIdentity();
|
|
224
224
|
if (enforce) {
|
|
225
225
|
for (const path of boundary) validatePrivateWindowsOwner(windowsOwnerSid(path, "directory"), identity.user);
|
|
226
226
|
for (const path of boundary) enforcePrivateWindowsDacl(path, identity);
|
|
@@ -304,16 +304,23 @@ function directory(path: string, privateMode = false, platform: NodeJS.Platform
|
|
|
304
304
|
return `${stat.dev}:${stat.ino}`;
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
-
|
|
307
|
+
function checkedCandidateOwnerParent(commonDir: string, platform: NodeJS.Platform): { parent: string; windowsIdentity?: WindowsAclIdentity } {
|
|
308
308
|
directory(commonDir, false, platform);
|
|
309
309
|
const control = join(commonDir, "gentle-ai");
|
|
310
310
|
directory(control, false, platform);
|
|
311
311
|
const parent = join(commonDir, "gentle-ai", "candidate-views");
|
|
312
312
|
if (platform === "win32") {
|
|
313
313
|
directory(parent, false, platform);
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
314
|
+
const windowsIdentity = testingWindowsAclAuthority === undefined ? windowsAclIdentity() : undefined;
|
|
315
|
+
privateWindowsCandidateOwnerBoundary(commonDir, false, windowsIdentity);
|
|
316
|
+
return { parent, windowsIdentity };
|
|
317
|
+
}
|
|
318
|
+
directory(parent, true, platform, true);
|
|
319
|
+
return { parent };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function assertCandidateOwnerParent(commonDir: string, platform: NodeJS.Platform = process.platform): string {
|
|
323
|
+
return checkedCandidateOwnerParent(commonDir, platform).parent;
|
|
317
324
|
}
|
|
318
325
|
|
|
319
326
|
export function prepareCandidateOwnerParent(commonDir: string, platform: NodeJS.Platform = process.platform): string {
|
|
@@ -329,13 +336,13 @@ export function prepareCandidateOwnerParent(commonDir: string, platform: NodeJS.
|
|
|
329
336
|
return assertCandidateOwnerParent(commonDir, platform);
|
|
330
337
|
}
|
|
331
338
|
|
|
332
|
-
function regular(path: string, privateMode = false, platform: NodeJS.Platform = process.platform): string {
|
|
339
|
+
function regular(path: string, privateMode = false, platform: NodeJS.Platform = process.platform, windowsIdentity?: WindowsAclIdentity): string {
|
|
333
340
|
// Keep 64-bit identity exact without a second stat lookup racing the privacy checks.
|
|
334
341
|
const stat = lstatSync(path, { bigint: true });
|
|
335
342
|
const uid = process.getuid?.();
|
|
336
343
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n || !samePath(realpathSync(path), path, platform) || stat.size > 16384n ||
|
|
337
344
|
(privateMode && platform !== "win32" && (uid === undefined || stat.uid !== BigInt(uid) || (stat.mode & 0o777n) !== 0o600n))) throw new Error("Unsafe candidate owner file");
|
|
338
|
-
if (privateMode && platform === "win32") privateWindowsDacl(path, "file", false);
|
|
345
|
+
if (privateMode && platform === "win32") privateWindowsDacl(path, "file", false, false, windowsIdentity);
|
|
339
346
|
return `${stat.dev}:${stat.ino}`;
|
|
340
347
|
}
|
|
341
348
|
|
|
@@ -347,27 +354,27 @@ function syncDirectory(path: string): void {
|
|
|
347
354
|
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
348
355
|
}
|
|
349
356
|
|
|
350
|
-
function removeExactPrivateFile(path: string, identity: string, platform: NodeJS.Platform): void {
|
|
357
|
+
function removeExactPrivateFile(path: string, identity: string, platform: NodeJS.Platform, windowsIdentity?: WindowsAclIdentity): void {
|
|
351
358
|
try {
|
|
352
|
-
if (regular(path, true, platform) !== identity) return;
|
|
359
|
+
if (regular(path, true, platform, windowsIdentity) !== identity) return;
|
|
353
360
|
unlinkSync(path);
|
|
354
361
|
syncDirectory(dirname(path));
|
|
355
362
|
} catch { /* An unproven or replaced file survives. */ }
|
|
356
363
|
}
|
|
357
364
|
|
|
358
|
-
function exclusiveFile(path: string, content: string, platform: NodeJS.Platform = process.platform, rollbackOnFailure = false): string {
|
|
365
|
+
function exclusiveFile(path: string, content: string, platform: NodeJS.Platform = process.platform, rollbackOnFailure = false, windowsIdentity?: WindowsAclIdentity): string {
|
|
359
366
|
let identity: string | undefined;
|
|
360
367
|
try {
|
|
361
368
|
const fd = openSync(path, "wx", 0o600);
|
|
362
369
|
try {
|
|
363
|
-
identity = regular(path, true, platform);
|
|
370
|
+
identity = regular(path, true, platform, windowsIdentity);
|
|
364
371
|
writeFileSync(fd, content);
|
|
365
372
|
fsyncSync(fd);
|
|
366
373
|
} finally { closeSync(fd); }
|
|
367
374
|
syncDirectory(dirname(path));
|
|
368
375
|
return identity;
|
|
369
376
|
} catch (error) {
|
|
370
|
-
if (rollbackOnFailure && identity !== undefined) removeExactPrivateFile(path, identity, platform);
|
|
377
|
+
if (rollbackOnFailure && identity !== undefined) removeExactPrivateFile(path, identity, platform, windowsIdentity);
|
|
371
378
|
throw error;
|
|
372
379
|
}
|
|
373
380
|
}
|
|
@@ -375,7 +382,10 @@ function exclusiveFile(path: string, content: string, platform: NodeJS.Platform
|
|
|
375
382
|
function markerPath(root: string): string { return `${root}.owner.json`; }
|
|
376
383
|
|
|
377
384
|
export function createCandidateOwner(commonDir: string, root: string, platform: NodeJS.Platform = process.platform): CandidateViewOwner {
|
|
378
|
-
|
|
385
|
+
// Reuse only identity within this module-controlled synchronous creation.
|
|
386
|
+
// Owner/DACL observations stay fresh, including rollback. Nothing is retained
|
|
387
|
+
// across calls or passed into callback-based removal/sweeping operations.
|
|
388
|
+
const { parent, windowsIdentity } = checkedCandidateOwnerParent(commonDir, platform);
|
|
379
389
|
const uuid = basename(root);
|
|
380
390
|
if (!UUID.test(uuid) || root !== join(parent, uuid) || lstatSync(root, { throwIfNoEntry: false })) throw new Error("Unsafe candidate owner root");
|
|
381
391
|
const owner: CandidateViewOwner = { version: 1, uuid, token: randomUUID(), pid: process.pid, host: localHost(), root, commonDir };
|
|
@@ -383,11 +393,11 @@ export function createCandidateOwner(commonDir: string, root: string, platform:
|
|
|
383
393
|
const marker = markerPath(root);
|
|
384
394
|
let markerIdentity: string | undefined;
|
|
385
395
|
try {
|
|
386
|
-
markerIdentity = exclusiveFile(marker, JSON.stringify(owner), platform, true);
|
|
396
|
+
markerIdentity = exclusiveFile(marker, JSON.stringify(owner), platform, true, windowsIdentity);
|
|
387
397
|
syncDirectory(dirname(parent));
|
|
388
398
|
syncDirectory(commonDir);
|
|
389
399
|
} catch (error) {
|
|
390
|
-
if (markerIdentity !== undefined) removeExactPrivateFile(marker, markerIdentity, platform);
|
|
400
|
+
if (markerIdentity !== undefined) removeExactPrivateFile(marker, markerIdentity, platform, windowsIdentity);
|
|
391
401
|
throw error;
|
|
392
402
|
}
|
|
393
403
|
return Object.freeze(owner);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFileSync, type ExecFileSyncOptions } from "node:child_process";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, symlinkSync, utimesSync } from "node:fs";
|
|
3
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, symlinkSync, utimesSync, type Stats } from "node:fs";
|
|
4
4
|
import { mkdtempSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
@@ -650,6 +650,32 @@ function makeReadonly(root: string, entries: readonly CandidateViewEntry[]): voi
|
|
|
650
650
|
for (const directory of candidateDirectories(root, entries)) chmodSync(directory, 0o555);
|
|
651
651
|
}
|
|
652
652
|
|
|
653
|
+
// macOS File Provider (iCloud-synced Desktop/Documents) asynchronously resets
|
|
654
|
+
// directory modes to exactly 0700 while leaving file modes intact, so a view
|
|
655
|
+
// made read-only at creation later reads as writable. Only that exact shape on
|
|
656
|
+
// a directory this user owns is re-tightened; any group or other bit still
|
|
657
|
+
// refuses. Owner-only write grants nothing a same-user process could not do by
|
|
658
|
+
// chmod anyway, and the caller then proves the directory holds exactly the
|
|
659
|
+
// frozen children, closing the ignored-file injection the untracked scan misses.
|
|
660
|
+
function repairFileProviderDirectoryMode(directory: string, metadata: Stats, platform: NodeJS.Platform): boolean {
|
|
661
|
+
if (platform === "win32" || !metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o7777) !== 0o700) return false;
|
|
662
|
+
if (typeof process.getuid !== "function" || metadata.uid !== process.getuid()) return false;
|
|
663
|
+
chmodSync(directory, 0o555);
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function expectedDirectoryChildren(root: string, entries: readonly CandidateViewEntry[]): ReadonlyMap<string, ReadonlySet<string>> {
|
|
668
|
+
const children = new Map<string, Set<string>>([[root, new Set([".git"])]]);
|
|
669
|
+
for (const entry of entries) {
|
|
670
|
+
for (let path = join(root, entry.path); path !== root && isWithin(root, path); path = dirname(path)) {
|
|
671
|
+
const parent = dirname(path);
|
|
672
|
+
if (!children.has(parent)) children.set(parent, new Set());
|
|
673
|
+
children.get(parent)!.add(basename(path));
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return children;
|
|
677
|
+
}
|
|
678
|
+
|
|
653
679
|
function makeWritableForCleanup(path: string): void {
|
|
654
680
|
const entry = lstatSync(path, { throwIfNoEntry: false });
|
|
655
681
|
if (!entry || entry.isSymbolicLink()) return;
|
|
@@ -1050,8 +1076,16 @@ function assertRecordSafe(record: CandidateViewRecord, platform: NodeJS.Platform
|
|
|
1050
1076
|
if (!isWithin(record.parent, root) || !existsSync(root)) throw new CandidateViewError("candidate view is missing or moved");
|
|
1051
1077
|
const rootStat = lstatSync(root);
|
|
1052
1078
|
if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || realpathSync(root) !== root) throw new CandidateViewError("candidate view root is unsafe");
|
|
1079
|
+
let expectedChildren: ReadonlyMap<string, ReadonlySet<string>> | undefined;
|
|
1053
1080
|
for (const directory of candidateDirectories(root, record.entries)) {
|
|
1054
|
-
|
|
1081
|
+
let metadata = lstatSync(directory);
|
|
1082
|
+
if (repairFileProviderDirectoryMode(directory, metadata, platform)) {
|
|
1083
|
+
expectedChildren ??= expectedDirectoryChildren(root, record.entries);
|
|
1084
|
+
const expected = expectedChildren.get(directory) ?? new Set<string>();
|
|
1085
|
+
const actual = readdirSync(directory);
|
|
1086
|
+
if (actual.length !== expected.size || actual.some((name) => !expected.has(name))) throw new CandidateViewError("candidate view directory contains unexpected entries");
|
|
1087
|
+
metadata = lstatSync(directory);
|
|
1088
|
+
}
|
|
1055
1089
|
if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o222) !== 0) throw new CandidateViewError("candidate view directory is unsafe or writable");
|
|
1056
1090
|
}
|
|
1057
1091
|
const gitFile = lstatSync(join(root, ".git"));
|