gentle-pi 2.0.0 → 2.1.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.
@@ -1,7 +1,16 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, lstatSync, readFileSync } from "node:fs";
3
3
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
- import { resolveGentleAiReleaseAsset } from "../scripts/gentle-ai-installer.mjs";
4
+ import {
5
+ GENTLE_AI_INSTALL_METHOD,
6
+ GENTLE_AI_WINDOWS_SOURCE_MODULE,
7
+ GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
8
+ GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH,
9
+ GENTLE_AI_WINDOWS_SOURCE_TAG,
10
+ isGentleAiWindowsGoVersionSupported,
11
+ isWindowsGoSumdbSourceTarget,
12
+ resolveGentleAiReleaseAsset,
13
+ } from "../scripts/gentle-ai-installer.mjs";
5
14
  import { fileURLToPath } from "node:url";
6
15
 
7
16
  export const GENTLE_AI_BINARY_MISSING_CODE = "package-local-binary-missing";
@@ -49,6 +58,50 @@ function assertPosixExecutable(path: string, platform: string): void {
49
58
  }
50
59
  }
51
60
 
61
+ function signedReleaseManifest(asset: { name: string; sha256: string; binarySha256: string }): Record<string, string> {
62
+ return { version: GENTLE_AI_VERSION, asset: asset.name, assetSha256: asset.sha256, binarySha256: asset.binarySha256 };
63
+ }
64
+
65
+ function windowsSourceManifest(manifest: Record<string, unknown>, binarySha256: string, platform: string): Record<string, string> {
66
+ if (!isWindowsGoSumdbSourceTarget(platform, process.arch)) throw new Error("unsupported Windows source architecture");
67
+ const architecture = process.arch === "x64" ? "x64" : "arm64";
68
+ const goVersion = manifest.goVersion;
69
+ if (manifest.moduleChecksum !== GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM || typeof goVersion !== "string" || !isGentleAiWindowsGoVersionSupported(goVersion)) throw new Error("invalid Windows source provenance");
70
+ return {
71
+ version: GENTLE_AI_VERSION,
72
+ method: GENTLE_AI_INSTALL_METHOD.GO_SUMDB_SOURCE_BUILD,
73
+ package: GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH,
74
+ module: GENTLE_AI_WINDOWS_SOURCE_MODULE,
75
+ tag: GENTLE_AI_WINDOWS_SOURCE_TAG,
76
+ architecture,
77
+ binarySha256,
78
+ moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
79
+ goVersion,
80
+ goos: "windows",
81
+ goarch: process.arch === "x64" ? "amd64" : "arm64",
82
+ buildMode: "exe",
83
+ compiler: "gc",
84
+ cgoEnabled: "0",
85
+ };
86
+ }
87
+
88
+ function expectedRuntimeManifest(platform: string, binarySha256: string, manifest: Record<string, unknown>): Record<string, string> {
89
+ if (platform === "win32") return windowsSourceManifest(manifest, binarySha256, platform);
90
+ const asset = resolveGentleAiReleaseAsset(platform, process.arch);
91
+ if (binarySha256 !== asset.binarySha256) throw new Error("runtime binary does not match pinned release digest");
92
+ return signedReleaseManifest(asset);
93
+ }
94
+
95
+ function isCanonicalManifest(contents: string, manifest: Record<string, unknown>, expected: Record<string, string>): boolean {
96
+ return contents === `${JSON.stringify(expected)}\n`
97
+ && Object.keys(manifest).length === Object.keys(expected).length
98
+ && Object.entries(expected).every(([key, value]) => manifest[key] === value);
99
+ }
100
+
101
+ function sameFile(before: ReturnType<typeof lstatSync>, after: ReturnType<typeof lstatSync>): boolean {
102
+ return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs;
103
+ }
104
+
52
105
  export function resolveGentleAiBinary(
53
106
  packageRoot = dirname(dirname(fileURLToPath(import.meta.url))),
54
107
  platform = process.platform,
@@ -66,22 +119,16 @@ export function resolveGentleAiBinary(
66
119
  assertRegularNonSymlink(binaryPath);
67
120
  assertPosixExecutable(binaryPath, platform);
68
121
  assertRegularNonSymlink(manifestPath);
69
- const before = lstatSync(binaryPath);
70
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>;
71
- const asset = resolveGentleAiReleaseAsset(platform, process.arch);
72
- if (
73
- Object.keys(manifest).length !== 4 ||
74
- ["version", "asset", "assetSha256", "binarySha256"].some((key) => !(key in manifest)) ||
75
- manifest.version !== GENTLE_AI_VERSION ||
76
- manifest.asset !== asset.name ||
77
- manifest.assetSha256 !== asset.sha256 ||
78
- typeof manifest.binarySha256 !== "string" ||
79
- manifest.binarySha256 !== asset.binarySha256 ||
80
- !/^[0-9a-f]{64}$/.test(manifest.binarySha256) ||
81
- sha256(readBinary(binaryPath)) !== manifest.binarySha256
82
- ) throw new Error("invalid runtime integrity manifest");
83
- const after = lstatSync(binaryPath);
84
- if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) throw new Error("runtime replaced during verification");
122
+ const beforeBinary = lstatSync(binaryPath);
123
+ const beforeManifest = lstatSync(manifestPath);
124
+ const manifestContents = readFileSync(manifestPath, "utf8");
125
+ const manifest = JSON.parse(manifestContents) as Record<string, unknown>;
126
+ const binarySha256 = sha256(readBinary(binaryPath));
127
+ const expected = expectedRuntimeManifest(platform, binarySha256, manifest);
128
+ if (!isCanonicalManifest(manifestContents, manifest, expected)) throw new Error("invalid runtime integrity manifest");
129
+ const afterBinary = lstatSync(binaryPath);
130
+ const afterManifest = lstatSync(manifestPath);
131
+ if (!sameFile(beforeBinary, afterBinary) || !sameFile(beforeManifest, afterManifest)) throw new Error("runtime replaced during verification");
85
132
  assertPosixExecutable(binaryPath, platform);
86
133
  return binaryPath;
87
134
  } catch {
@@ -106,6 +106,7 @@ export interface NativeReviewCli {
106
106
  reviewStatus(request: NativeReviewStatusRequest): Promise<NativeReviewStatusResult>;
107
107
  capabilities?(request?: NativeCapabilitiesRequest): Promise<ReviewCapabilitiesV2>;
108
108
  targetStatus?(request: NativeTargetStatusRequest): Promise<ReviewStatusV3>;
109
+ answerConsent?(request: NativeReviewConsentAnswerRequest): Promise<NativeReviewConsentAnswerResult>;
109
110
  reclaim?(request: NativeReviewReclaimRequest): Promise<NativeReviewRecoveryResult>;
110
111
  recover?(request: NativeReviewRecoverRequest): Promise<NativeReviewRecoveryResult>;
111
112
  abandon?(request: NativeReviewAbandonRequest): Promise<NativeReviewRecoveryResult>;
@@ -371,6 +372,21 @@ export interface NativeReviewVerificationEvidenceV2 {
371
372
  }
372
373
 
373
374
  export interface NativeStartRequest { cwd: string; baseRef?: string; committedOnly?: boolean; lineageId?: string; policyPath?: string; focus?: string; projection?: "workspace" | "staged"; signal?: AbortSignal; }
375
+ export const NATIVE_REVIEW_CONSENT_ANSWER = { GRANTED: "granted", DECLINED: "declined" } as const;
376
+ export type NativeReviewConsentAnswer = (typeof NATIVE_REVIEW_CONSENT_ANSWER)[keyof typeof NATIVE_REVIEW_CONSENT_ANSWER];
377
+ export interface NativeReviewConsentAnswerRequest { cwd: string; consent: ReviewConsentV2; answer: NativeReviewConsentAnswer; signal?: AbortSignal; }
378
+ export interface NativeReviewConsentDeclinedResult {
379
+ kind: "declined";
380
+ targetIdentity: string;
381
+ projection: "workspace" | "staged";
382
+ riskLevel: "medium" | "high";
383
+ changedFiles: number;
384
+ changedLines: number;
385
+ consent: "declined_this_candidate";
386
+ raw: Readonly<Record<string, unknown>>;
387
+ }
388
+ export interface NativeReviewConsentStartedResult { kind: "started"; start: NativeStartResult; }
389
+ export type NativeReviewConsentAnswerResult = NativeReviewConsentStartedResult | NativeReviewConsentDeclinedResult;
374
390
  export interface NativeFinalizeLensResult { lens: string; document: unknown; }
375
391
  export interface NativeFinalizeRequest extends NativeReviewFinalizeCapturedResults {
376
392
  cwd: string;
@@ -559,7 +575,13 @@ const REVIEW_RISK_SUBJECT_BY_SIGNAL: Readonly<Record<string, string>> = Object.f
559
575
  // an unmapped signal degrades to this rather than dropping the path entirely.
560
576
  const REVIEW_RISK_UNKNOWN_SIGNAL_SUBJECT = "a sensitive area";
561
577
 
562
- function nativeRiskEvidenceSubject(reason: Record<string, unknown>): string {
578
+ interface NativeRiskEvidenceReason {
579
+ readonly code?: string;
580
+ readonly signal?: string;
581
+ readonly path?: string;
582
+ }
583
+
584
+ function nativeRiskEvidenceSubject(reason: NativeRiskEvidenceReason): string {
563
585
  const code = typeof reason.code === "string" ? reason.code : "";
564
586
  if (code === "hot_path") {
565
587
  const signal = typeof reason.signal === "string" ? reason.signal : "";
@@ -568,7 +590,7 @@ function nativeRiskEvidenceSubject(reason: Record<string, unknown>): string {
568
590
  return REVIEW_RISK_SUBJECT_BY_CODE[code] ?? "";
569
591
  }
570
592
 
571
- function nativeRiskEvidencePhrase(reason: Record<string, unknown>): string {
593
+ function nativeRiskEvidencePhrase(reason: NativeRiskEvidenceReason): string {
572
594
  const path = typeof reason.path === "string" ? reason.path.trim() : "";
573
595
  // An empty file is named first and described second. Every other subject
574
596
  // reads "<what changed> in <path>", which for a file with no bytes would
@@ -581,7 +603,7 @@ function nativeRiskEvidencePhrase(reason: Record<string, unknown>): string {
581
603
  return `${subject} in ${path}`;
582
604
  }
583
605
 
584
- export function nativeRiskEvidencePhrases(riskLevel: string, reasons: readonly Record<string, unknown>[]): readonly string[] {
606
+ export function nativeRiskEvidencePhrases(riskLevel: string, reasons: readonly NativeRiskEvidenceReason[]): readonly string[] {
585
607
  if (riskLevel !== "high" && riskLevel !== "medium") return [];
586
608
  const phrases = reasons.map((reason) => nativeRiskEvidencePhrase(reason)).filter((phrase) => phrase !== "");
587
609
  return riskLevel === "medium" ? [REVIEW_MEDIUM_RISK_REASON, ...phrases] : phrases;
@@ -1121,6 +1143,7 @@ function nativeError(code: NativeReviewErrorCode, operation: NativeReviewOperati
1121
1143
  interface NativeJsonExecution {
1122
1144
  body: Record<string, unknown>;
1123
1145
  exitCode: number;
1146
+ process: ExecFileResult;
1124
1147
  }
1125
1148
 
1126
1149
  export class NativeReviewCliV214 {
@@ -1617,12 +1640,13 @@ export class NativeReviewIntegrationError extends Error {
1617
1640
  }
1618
1641
 
1619
1642
  // Raised when negotiated START answers `consent/v2` (action:
1620
- // "consent_required") instead of `start/v3`. Pi stays headless and never
1621
- // sends `--consent relay`, so this is a blocking question surfaced to the
1622
- // caller rather than answered on its behalf.
1643
+ // "consent_required") instead of `start/v3`. The provider has frozen no
1644
+ // authority yet: Pi must relay this complete candidate-scoped question and may
1645
+ // answer only through one of the exact invocations carried by the envelope.
1623
1646
  export class NativeReviewConsentRequiredError extends Error {
1624
1647
  readonly consent: ReviewConsentV2;
1625
1648
  readonly launchAttempted = true;
1649
+ readonly mutationOutcome = "none";
1626
1650
  constructor(consent: ReviewConsentV2) {
1627
1651
  super(consent.headline);
1628
1652
  this.name = "NativeReviewConsentRequiredError";
@@ -1630,6 +1654,100 @@ export class NativeReviewConsentRequiredError extends Error {
1630
1654
  }
1631
1655
  }
1632
1656
 
1657
+ function splitNativeConsentInvocation(invocation: string): readonly string[] {
1658
+ const words: string[] = [];
1659
+ let current = "";
1660
+ let quote: "'" | '"' | undefined;
1661
+ let escaping = false;
1662
+ let started = false;
1663
+ for (const character of invocation.trim()) {
1664
+ if (escaping) {
1665
+ current += character;
1666
+ escaping = false;
1667
+ started = true;
1668
+ continue;
1669
+ }
1670
+ if (character === "\\" && quote !== "'") {
1671
+ escaping = true;
1672
+ started = true;
1673
+ continue;
1674
+ }
1675
+ if (quote !== undefined) {
1676
+ if (character === quote) quote = undefined;
1677
+ else current += character;
1678
+ started = true;
1679
+ continue;
1680
+ }
1681
+ if (character === "'" || character === '"') {
1682
+ quote = character;
1683
+ started = true;
1684
+ continue;
1685
+ }
1686
+ if (/\s/.test(character)) {
1687
+ if (started) {
1688
+ words.push(current);
1689
+ current = "";
1690
+ started = false;
1691
+ }
1692
+ continue;
1693
+ }
1694
+ current += character;
1695
+ started = true;
1696
+ }
1697
+ if (quote !== undefined || escaping) throw new TypeError("Native consent invocation has invalid quoting");
1698
+ if (started) words.push(current);
1699
+ return words;
1700
+ }
1701
+
1702
+ function exactConsentOption(arguments_: readonly string[], name: string): string {
1703
+ const values: string[] = [];
1704
+ for (let index = 0; index < arguments_.length; index += 1) {
1705
+ const token = arguments_[index]!;
1706
+ if (token === name) {
1707
+ const value = arguments_[index + 1];
1708
+ if (value === undefined) throw new TypeError(`Native consent invocation ${name} is missing its value`);
1709
+ values.push(value);
1710
+ index += 1;
1711
+ } else if (token.startsWith(`${name}=`)) values.push(token.slice(name.length + 1));
1712
+ }
1713
+ if (values.length !== 1) throw new TypeError(`Native consent invocation requires exactly one ${name}`);
1714
+ return values[0]!;
1715
+ }
1716
+
1717
+ function consentInvocationArguments(request: NativeReviewConsentAnswerRequest): readonly string[] {
1718
+ const choice = request.consent.choices.find((candidate) => candidate.answer === request.answer);
1719
+ if (choice === undefined) throw new TypeError("Native consent answer must be granted or declined");
1720
+ const words = splitNativeConsentInvocation(choice.invocation);
1721
+ if (words[0] !== "gentle-ai" || words[1] !== "review" || words[2] !== "start") throw new TypeError("Native consent invocation is not a provider review START");
1722
+ const arguments_ = words.slice(1);
1723
+ if (exactConsentOption(arguments_, "--contract") !== REVIEW_INTEGRATION_CONTRACT) throw new TypeError("Native consent invocation contract changed");
1724
+ if (exactConsentOption(arguments_, "--cwd") !== request.cwd) throw new TypeError("Native consent invocation repository binding changed");
1725
+ if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new TypeError("Native consent invocation target binding changed");
1726
+ if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new TypeError("Native consent invocation projection binding changed");
1727
+ if (exactConsentOption(arguments_, "--consent") !== request.answer || arguments_.at(-1) !== request.answer) throw new TypeError("Native consent invocation answer binding changed");
1728
+ return arguments_;
1729
+ }
1730
+
1731
+ function decodeDeclinedConsentStart(value: unknown, expected: NativeReviewConsentAnswerRequest): NativeReviewConsentDeclinedResult {
1732
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("Native declined consent result must be an object");
1733
+ const body = value as Record<string, unknown>;
1734
+ if (body.operation !== "review/start" || body.action !== "declined" || body.consent !== "declined_this_candidate") throw new TypeError("Native declined consent result has an invalid identity");
1735
+ if (body.target_identity !== expected.consent.targetIdentity || body.projection !== expected.consent.projection || body.risk_level !== expected.consent.riskLevel) throw new TypeError("Native declined consent result target binding changed");
1736
+ if (body.lenses_required !== false || !Array.isArray(body.selected_lenses) || body.selected_lenses.length !== 0 || !Array.isArray(body.lens_bindings) || body.lens_bindings.length !== 0) throw new TypeError("Native declined consent result must create no review authority");
1737
+ if (typeof body.changed_files !== "number" || !Number.isSafeInteger(body.changed_files) || body.changed_files < 0 || typeof body.changed_lines !== "number" || !Number.isSafeInteger(body.changed_lines) || body.changed_lines < 0) throw new TypeError("Native declined consent result has invalid change counts");
1738
+ if (body.lineage_id !== "" || body.state !== "" || body.correction_budget !== 0) throw new TypeError("Native declined consent result cannot carry review authority");
1739
+ return {
1740
+ kind: "declined",
1741
+ targetIdentity: expected.consent.targetIdentity,
1742
+ projection: expected.consent.projection,
1743
+ riskLevel: expected.consent.riskLevel,
1744
+ changedFiles: body.changed_files,
1745
+ changedLines: body.changed_lines,
1746
+ consent: "declined_this_candidate",
1747
+ raw: body,
1748
+ };
1749
+ }
1750
+
1633
1751
  type NativeExecutableDigestResolver = (path: string) => string;
1634
1752
  const nativeCapabilitiesByDigest = new Map<string, Promise<ReviewCapabilitiesV2>>();
1635
1753
 
@@ -1841,14 +1959,12 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1841
1959
  ...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
1842
1960
  ...(request.policyPath === undefined ? [] : ["--policy", request.policyPath]),
1843
1961
  ...(request.focus === undefined ? [] : ["--focus", request.focus]),
1844
- // A headless START that reviews without asking still succeeds; its
1845
- // consent notices are console output, never a failure signal.
1846
- ], true, request.signal, REVIEW_CONSENT_NOTICES);
1962
+ "--consent", "relay",
1963
+ ], true, request.signal);
1847
1964
  // A negotiated v2 START may answer `consent/v2` (action:
1848
1965
  // "consent_required") instead of `start/v3` when the provider needs an
1849
- // explicit answer it cannot infer. Discriminated BEFORE decode: the two
1850
- // envelopes share no required shape, and Pi never sends `--consent
1851
- // relay`, so this is always surfaced to the caller, never answered here.
1966
+ // explicit answer it cannot infer. Discriminate before decode and surface
1967
+ // the complete envelope; only the caller can map a human answer.
1852
1968
  if (execution.body.action === "consent_required") {
1853
1969
  throw new NativeReviewConsentRequiredError(decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body)));
1854
1970
  }
@@ -1882,6 +1998,36 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1882
1998
  };
1883
1999
  }
1884
2000
 
2001
+ async answerConsent(request: NativeReviewConsentAnswerRequest): Promise<NativeReviewConsentAnswerResult> {
2002
+ const arguments_ = consentInvocationArguments(request);
2003
+ const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, arguments_, true, request.signal);
2004
+ if (request.answer === NATIVE_REVIEW_CONSENT_ANSWER.DECLINED) {
2005
+ return decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeDeclinedConsentStart(execution.body, request));
2006
+ }
2007
+ const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartV3(execution.body));
2008
+ const answeredTarget = result.targetIdentity ?? result.repositoryContext?.targetIdentity;
2009
+ if (answeredTarget !== request.consent.targetIdentity) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent answer target mismatch");
2010
+ const lineageId = exactConsentOption(arguments_, "--lineage");
2011
+ if (result.lineageId !== lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent answer lineage mismatch");
2012
+ return { kind: "started", start: {
2013
+ lineageId: result.lineageId,
2014
+ state: result.state as NativeStartResult["state"],
2015
+ riskLevel: result.riskLevel,
2016
+ selectedLenses: result.selectedLenses,
2017
+ changedFiles: result.changedFiles,
2018
+ changedLines: result.changedLines,
2019
+ correctionBudget: result.correctionBudget,
2020
+ action: result.action as NativeStartAction,
2021
+ lensesRequired: result.lensesRequired,
2022
+ riskReasons: result.riskReasons.map((reason) => ({ ...reason })),
2023
+ ...(() => {
2024
+ const evidence = nativeRiskEvidencePhrases(result.riskLevel, result.riskReasons);
2025
+ return evidence.length === 0 ? {} : { riskEvidence: evidence };
2026
+ })(),
2027
+ raw: result.raw,
2028
+ } };
2029
+ }
2030
+
1885
2031
  private async stageDocument(directory: string, name: string, document: unknown): Promise<string> {
1886
2032
  const path = join(directory, `${name}.json`);
1887
2033
  await writeFile(path, JSON.stringify(document), { encoding: "utf8", mode: 0o600 });
@@ -798,11 +798,15 @@ export class CandidateViewRegistry {
798
798
  const base = head.tree === descriptor.baseTree ? head : resolveCandidateBaseTree(root, descriptor.baseTree, this.gitExecutor);
799
799
  const committedOnly = head.tree === descriptor.currentCandidateTree && base.tree !== head.tree;
800
800
  if (!committedOnly && head.tree !== descriptor.baseTree) throw new CandidateViewError("native projection base no longer matches HEAD");
801
- // The descriptor's own `projection` label ("staged" = a committed range,
802
- // "workspace" = dirty-inclusive) must agree with what Pi independently
803
- // derives from the base/candidate/HEAD relationship. Neither trusting
804
- // nor silently overriding a mislabeled claim is safe: fail closed.
805
- if ((descriptor.projection === "staged") !== committedOnly) {
801
+ // Native `staged` covers both a committed HEAD range and the exact current
802
+ // index over HEAD. Re-derive the latter from Git instead of trusting the
803
+ // label; this also rejects dirty-inclusive snapshots mislabeled as staged.
804
+ const stagedIndex = !committedOnly && descriptor.baseTree === head.tree &&
805
+ git(root, ["write-tree"], process.env, this.gitExecutor) === descriptor.currentCandidateTree;
806
+ if (
807
+ (descriptor.projection === "staged" && !committedOnly && !stagedIndex) ||
808
+ (descriptor.projection === "workspace" && committedOnly)
809
+ ) {
806
810
  throw new CandidateViewError("native projection commit-state does not match its declared projection kind", "projection-kind-drift");
807
811
  }
808
812
  const tree = parseTree(root, descriptor.currentCandidateTree, this.gitExecutor);
@@ -1303,10 +1303,14 @@ export function decodeReviewConsentV2(value: unknown): ReviewConsentV2 {
1303
1303
  if (body.action !== "consent_required") throw new TypeError("consent.action must be consent_required");
1304
1304
  if (body.blocking !== true) throw new TypeError("consent.blocking must be true");
1305
1305
 
1306
+ const targetIdentity = sha256(body.target_identity, "consent.target_identity");
1306
1307
  const choicesArray = body.choices;
1307
1308
  if (!Array.isArray(choicesArray) || choicesArray.length !== 2) throw new TypeError("consent.choices must have exactly 2 items");
1308
1309
  const granted = decodeConsentChoice(choicesArray[0], "consent.choices[0]", "granted");
1309
1310
  const declined = decodeConsentChoice(choicesArray[1], "consent.choices[1]", "declined");
1311
+ for (const choice of [granted, declined]) {
1312
+ if (!choice.invocation.includes(` --target ${targetIdentity} `)) throw new TypeError(`consent choice ${choice.answer} invocation must bind consent.target_identity`);
1313
+ }
1310
1314
 
1311
1315
  const offPathSource = exactRecord(body.off_path, "consent.off_path", ["note", "command"]);
1312
1316
  if (offPathSource.command !== "gentle-ai review mode disable") throw new TypeError("consent.off_path.command is unsupported");
@@ -1317,7 +1321,7 @@ export function decodeReviewConsentV2(value: unknown): ReviewConsentV2 {
1317
1321
  operation: "review.start",
1318
1322
  action: "consent_required",
1319
1323
  blocking: true,
1320
- targetIdentity: sha256(body.target_identity, "consent.target_identity"),
1324
+ targetIdentity,
1321
1325
  projection: enumeration(body.projection, REQUIRED_PROJECTIONS, "consent.projection"),
1322
1326
  riskLevel: enumeration(body.risk_level, ["medium", "high"] as const, "consent.risk_level"),
1323
1327
  changedFiles: integer(body.changed_files, "consent.changed_files"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -2,7 +2,16 @@
2
2
  import { createHash } from "node:crypto";
3
3
  import { existsSync, lstatSync, readFileSync } from "node:fs";
4
4
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
- import { resolveGentleAiReleaseAsset } from "../scripts/gentle-ai-installer.mjs";
5
+ import {
6
+ GENTLE_AI_INSTALL_METHOD,
7
+ GENTLE_AI_WINDOWS_SOURCE_MODULE,
8
+ GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
9
+ GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH,
10
+ GENTLE_AI_WINDOWS_SOURCE_TAG,
11
+ isGentleAiWindowsGoVersionSupported,
12
+ isWindowsGoSumdbSourceTarget,
13
+ resolveGentleAiReleaseAsset,
14
+ } from "../scripts/gentle-ai-installer.mjs";
6
15
  import { fileURLToPath } from "node:url";
7
16
 
8
17
  export const GENTLE_AI_BINARY_MISSING_CODE = "package-local-binary-missing";
@@ -50,6 +59,50 @@ function assertPosixExecutable(path , platform ) {
50
59
  }
51
60
  }
52
61
 
62
+ function signedReleaseManifest(asset ) {
63
+ return { version: GENTLE_AI_VERSION, asset: asset.name, assetSha256: asset.sha256, binarySha256: asset.binarySha256 };
64
+ }
65
+
66
+ function windowsSourceManifest(manifest , binarySha256 , platform ) {
67
+ if (!isWindowsGoSumdbSourceTarget(platform, process.arch)) throw new Error("unsupported Windows source architecture");
68
+ const architecture = process.arch === "x64" ? "x64" : "arm64";
69
+ const goVersion = manifest.goVersion;
70
+ if (manifest.moduleChecksum !== GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM || typeof goVersion !== "string" || !isGentleAiWindowsGoVersionSupported(goVersion)) throw new Error("invalid Windows source provenance");
71
+ return {
72
+ version: GENTLE_AI_VERSION,
73
+ method: GENTLE_AI_INSTALL_METHOD.GO_SUMDB_SOURCE_BUILD,
74
+ package: GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH,
75
+ module: GENTLE_AI_WINDOWS_SOURCE_MODULE,
76
+ tag: GENTLE_AI_WINDOWS_SOURCE_TAG,
77
+ architecture,
78
+ binarySha256,
79
+ moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
80
+ goVersion,
81
+ goos: "windows",
82
+ goarch: process.arch === "x64" ? "amd64" : "arm64",
83
+ buildMode: "exe",
84
+ compiler: "gc",
85
+ cgoEnabled: "0",
86
+ };
87
+ }
88
+
89
+ function expectedRuntimeManifest(platform , binarySha256 , manifest ) {
90
+ if (platform === "win32") return windowsSourceManifest(manifest, binarySha256, platform);
91
+ const asset = resolveGentleAiReleaseAsset(platform, process.arch);
92
+ if (binarySha256 !== asset.binarySha256) throw new Error("runtime binary does not match pinned release digest");
93
+ return signedReleaseManifest(asset);
94
+ }
95
+
96
+ function isCanonicalManifest(contents , manifest , expected ) {
97
+ return contents === `${JSON.stringify(expected)}\n`
98
+ && Object.keys(manifest).length === Object.keys(expected).length
99
+ && Object.entries(expected).every(([key, value]) => manifest[key] === value);
100
+ }
101
+
102
+ function sameFile(before , after ) {
103
+ return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs;
104
+ }
105
+
53
106
  export function resolveGentleAiBinary(
54
107
  packageRoot = dirname(dirname(fileURLToPath(import.meta.url))),
55
108
  platform = process.platform,
@@ -67,22 +120,16 @@ export function resolveGentleAiBinary(
67
120
  assertRegularNonSymlink(binaryPath);
68
121
  assertPosixExecutable(binaryPath, platform);
69
122
  assertRegularNonSymlink(manifestPath);
70
- const before = lstatSync(binaryPath);
71
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) ;
72
- const asset = resolveGentleAiReleaseAsset(platform, process.arch);
73
- if (
74
- Object.keys(manifest).length !== 4 ||
75
- ["version", "asset", "assetSha256", "binarySha256"].some((key) => !(key in manifest)) ||
76
- manifest.version !== GENTLE_AI_VERSION ||
77
- manifest.asset !== asset.name ||
78
- manifest.assetSha256 !== asset.sha256 ||
79
- typeof manifest.binarySha256 !== "string" ||
80
- manifest.binarySha256 !== asset.binarySha256 ||
81
- !/^[0-9a-f]{64}$/.test(manifest.binarySha256) ||
82
- sha256(readBinary(binaryPath)) !== manifest.binarySha256
83
- ) throw new Error("invalid runtime integrity manifest");
84
- const after = lstatSync(binaryPath);
85
- if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) throw new Error("runtime replaced during verification");
123
+ const beforeBinary = lstatSync(binaryPath);
124
+ const beforeManifest = lstatSync(manifestPath);
125
+ const manifestContents = readFileSync(manifestPath, "utf8");
126
+ const manifest = JSON.parse(manifestContents) ;
127
+ const binarySha256 = sha256(readBinary(binaryPath));
128
+ const expected = expectedRuntimeManifest(platform, binarySha256, manifest);
129
+ if (!isCanonicalManifest(manifestContents, manifest, expected)) throw new Error("invalid runtime integrity manifest");
130
+ const afterBinary = lstatSync(binaryPath);
131
+ const afterManifest = lstatSync(manifestPath);
132
+ if (!sameFile(beforeBinary, afterBinary) || !sameFile(beforeManifest, afterManifest)) throw new Error("runtime replaced during verification");
86
133
  assertPosixExecutable(binaryPath, platform);
87
134
  return binaryPath;
88
135
  } catch {