agy-context-saver 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to `Agy-Context-Saver` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.0.1] - 2026-11-15
9
+
10
+ ### Added
11
+ - **Pinned `rtk v1.1.0` Release Checksums (`scripts/provision-rtk.mjs`, `tests/test-rtk-ledger-fresh-install.mjs`)**: Pinned verified SHA-256 digests from upstream `rtk v1.1.0` `checksums.txt` across all 5 supported platform archives (`win32-x64`, `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`) so clean installs without a pre-installed `rtk` binary can automatically download and verify `rtk v1.1.0` fail-closed.
12
+
13
+ ### Security
14
+ - **Operation-Bound, Single-Invocation Capability Grants (`lib/transcript-core.mjs`, `bin/agy-context.mjs`, `scripts/execution-guard-hook.mjs`)**: Bound `verifyTranscriptCoreAccess()` to canonical subcommand (`read`, `query`, `step`, `health`, `list`) and recorded `targetConvId`, and cleared the in-memory `processVerifiedGrant` synchronously upon path resolution (before async stream I/O begins) and on any early error (`L39`) so grants cannot be reused within the same process, shared across concurrent `Promise.all` calls, or repurposed across mismatched subcommands or conversation targets.
15
+ - **Independent `--file` Brain-Directory Containment (`lib/transcript-core.mjs`, `scripts/execution-guard-hook.mjs`)**: Enforced independent `isContainedInDir()` checks on `--file` (`explicitFile`) inside `resolveTranscriptPath()` (`<brainDir>/<callerConvId>` in `self-only` mode and `<brainDir>` whenever any parent access policy or first-line launch restriction is active) and updated `extractConvId()` to resolve conversation folder IDs relative to `getBrainDir()` (`L38`).
16
+
17
+ ### Changed
18
+ - **Documentation & Release Hygiene (`README.md`, `SECURITY.md`, `docs/installation.md`, `docs/governance.md`, `docs/architecture.md`, `docs/plans/LEDGER.md`, `scripts/release-preflight.mjs`)**: Added a `v1.x` to `v2.0` migration note and explicit `RTK >= 1.1.0` prerequisite details to `README.md` (`L37`), clarified application-layer `PreToolUse`/CLI defense-in-depth boundaries vs. OS-kernel sandboxing and `--id` vs. `--file` containment rules (`L40`), and enforced `plugin.json` / `package.json` / `CHANGELOG.md` version parity in `scripts/release-preflight.mjs`.
19
+
8
20
  ## [2.0.0] - 2026-11-14
9
21
 
10
22
  ### Added
package/README.md CHANGED
@@ -22,7 +22,7 @@ The repository ships four components:
22
22
 
23
23
  - **Node.js:** `>= 22.0.0`
24
24
  - **Google Antigravity:** Antigravity 2.0+ (Desktop IDE or `agy` CLI)
25
- - **RTK:** `>= 1.1.0` on `PATH` (or provisioned into the plugin `bin/` directory during setup)
25
+ - **RTK:** `>= 1.1.0` (`brew install rtk`, `cargo install --locked rtk`, or downloaded automatically into `bin/` during `agy-context setup` with pinned SHA-256 verification)
26
26
 
27
27
  ### Install and verify
28
28
 
@@ -37,6 +37,8 @@ npm run setup
37
37
  agy-context status
38
38
  ```
39
39
 
40
+ > **Upgrading from v1.x:** Running `agy-context setup` automatically removes legacy `mcp_config.json` entries, sweeps stale `~/.gemini/antigravity/mcp/agy-context-saver*` schema caches, and replaces the retired v1.x MCP server with the on-demand `agy-context` CLI and updated `PreToolUse` hook.
41
+
40
42
  To remove the plugin link, hook entries, and CLI shims:
41
43
 
42
44
  ```bash
@@ -17,7 +17,8 @@ import {
17
17
  checkContextHealth,
18
18
  getBrainDir,
19
19
  manageTranscriptAccessPolicy,
20
- verifyTranscriptCoreAccess
20
+ verifyTranscriptCoreAccess,
21
+ consumeTranscriptAccessGrant
21
22
  } from "../lib/transcript-core.mjs";
22
23
  import {
23
24
  runInstall,
@@ -165,6 +166,7 @@ function validateExplicitFileFlag(fileArg) {
165
166
  }
166
167
 
167
168
  function emitError(message, asJson = false, extra = {}) {
169
+ consumeTranscriptAccessGrant();
168
170
  if (asJson) {
169
171
  console.error(JSON.stringify({ isError: true, error: message, ...extra }, null, 2));
170
172
  } else {
@@ -213,7 +215,8 @@ async function handleTranscriptCommand(argv) {
213
215
  includeToolCalls: true,
214
216
  fullArgs: Boolean(opts["full-args"]),
215
217
  includeThinking: Boolean(opts["include-thinking"]),
216
- allowExternalPaths: false
218
+ allowExternalPaths: false,
219
+ subcommand: sub
217
220
  });
218
221
  if (res.isError) {
219
222
  emitError(res.text, Boolean(opts.json), res.candidates ? { candidates: res.candidates } : {});
@@ -317,7 +320,8 @@ async function handleTranscriptCommand(argv) {
317
320
  fullArgs: Boolean(opts["full-args"]),
318
321
  field: opts.field,
319
322
  includeThinking: Boolean(opts["include-thinking"]),
320
- allowExternalPaths: false
323
+ allowExternalPaths: false,
324
+ subcommand: sub
321
325
  });
322
326
 
323
327
  if (res.isError) {
@@ -61,8 +61,29 @@ export function getBrainDir() {
61
61
  return process.env.AGY_BRAIN_DIR || path.join(getProfileHomeDir(), ".gemini", "antigravity", "brain");
62
62
  }
63
63
 
64
- export function extractConvId(p) {
65
- const normalized = String(p || "").replace(/\\/g, "/");
64
+ export function extractConvId(p, { strictBrainContainment = false } = {}) {
65
+ const rawStr = String(p || "");
66
+ try {
67
+ const brainRoot = realOrNull(getBrainDir()) || path.resolve(getBrainDir());
68
+ const targetResolved = realOrNull(rawStr) || path.resolve(rawStr);
69
+ const norm = (s) => (os.platform() === "win32" ? s.toLowerCase() : s);
70
+ const rel = path.relative(norm(brainRoot), norm(targetResolved));
71
+ if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) {
72
+ const origRel = path.relative(brainRoot, targetResolved);
73
+ const segments = origRel.split(path.sep).filter(Boolean);
74
+ const firstSeg = segments[0];
75
+ if (firstSeg && firstSeg !== "." && firstSeg !== "..") {
76
+ if (strictBrainContainment && segments.length === 1 && firstSeg.toLowerCase().endsWith(".jsonl")) {
77
+ return null;
78
+ }
79
+ return firstSeg;
80
+ }
81
+ }
82
+ } catch {}
83
+ if (strictBrainContainment) {
84
+ return null;
85
+ }
86
+ const normalized = rawStr.replace(/\\/g, "/");
66
87
  const brainMatch = normalized.match(/\/brain\/([^/]+)\//);
67
88
  if (brainMatch) return brainMatch[1];
68
89
  const logsMatch = normalized.match(/\/([^/]+)\/\.system_generated\/logs\//);
@@ -230,19 +251,53 @@ function hasActiveLaunchRestrictedSession() {
230
251
  return false;
231
252
  }
232
253
 
254
+ export function canonicalizeTranscriptSubcommand(sub) {
255
+ const s = String(sub || "").trim().toLowerCase();
256
+ if (s === "read" || s === "outline" || s === "summary") return "read";
257
+ if (s === "query" || s === "grep" || s === "search" || s === "find" || s === "tools") return "query";
258
+ if (s === "step") return "step";
259
+ if (s === "health" || s === "stats" || s === "info") return "health";
260
+ if (s === "list" || s === "ls") return "list";
261
+ if (s === "resolve") return "resolve";
262
+ return s;
263
+ }
264
+
265
+ export function clearProcessTranscriptGrant() {
266
+ processVerifiedGrant = null;
267
+ }
268
+
269
+ export function consumeTranscriptAccessGrant() {
270
+ processVerifiedGrant = null;
271
+ try {
272
+ const store = readTranscriptAccessStore();
273
+ if (store.lastHookGrant) {
274
+ store.lastHookGrant = null;
275
+ writeTranscriptAccessStore(store);
276
+ }
277
+ } catch {}
278
+ }
279
+
233
280
  /**
234
281
  * Defense-in-depth runtime verification inside transcript-core.mjs:
235
282
  * When any parent policy restriction or active launch-directive restriction exists,
236
- * verifies that PreToolUse issued a fresh (< 5s) capability grant for this operation.
283
+ * verifies that PreToolUse issued a fresh (< 10s), operation-bound single-use capability grant.
237
284
  */
238
- export function verifyTranscriptCoreAccess({ targetConvId = null, subcommand = "read" } = {}) {
285
+ export function verifyTranscriptCoreAccess({
286
+ targetConvId = null,
287
+ subcommand = "read",
288
+ consumeGrant = true,
289
+ isImplicitCurrent = false
290
+ } = {}) {
239
291
  const store = readTranscriptAccessStore();
240
292
  const activePolicies = Object.values(store.policies || {}).filter(
241
293
  (p) => p.mode !== "open" || (Array.isArray(p.deny) && p.deny.length > 0) || (Array.isArray(p.allow) && p.allow.length > 0)
242
294
  );
243
295
  const hasRestriction = activePolicies.length > 0 || hasActiveLaunchRestrictedSession();
244
296
  if (!hasRestriction) {
245
- return { allowed: true, mode: "open" };
297
+ if (consumeGrant) {
298
+ processVerifiedGrant = null;
299
+ }
300
+ return { allowed: true, mode: "open", hasRestriction: false };
246
301
  }
247
302
 
248
303
  let grant = null;
@@ -257,29 +312,69 @@ export function verifyTranscriptCoreAccess({ targetConvId = null, subcommand = "
257
312
  }
258
313
 
259
314
  if (!grant) {
315
+ processVerifiedGrant = null;
260
316
  return {
261
317
  allowed: false,
318
+ hasRestriction: true,
262
319
  error:
263
320
  "Antigravity Execution Governance: Transcript access is currently restricted by parent governance policy; direct or unverified transcript-core invocation outside an authorized PreToolUse CLI call is blocked."
264
321
  };
265
322
  }
266
323
 
324
+ const reqCanon = canonicalizeTranscriptSubcommand(subcommand);
325
+ const grantCanon = canonicalizeTranscriptSubcommand(grant.subcommand);
326
+ if (!reqCanon || !grantCanon || reqCanon !== grantCanon) {
327
+ processVerifiedGrant = null;
328
+ return {
329
+ allowed: false,
330
+ hasRestriction: true,
331
+ error: `Antigravity Execution Governance: Capability grant subcommand mismatch (grant authorized '${grant.subcommand}', but '${subcommand}' was requested).`
332
+ };
333
+ }
334
+
267
335
  if (grant.mode === "self-only") {
268
- if (subcommand === "list" || subcommand === "ls") {
336
+ if (reqCanon === "list") {
337
+ processVerifiedGrant = null;
269
338
  return {
270
339
  allowed: false,
340
+ hasRestriction: true,
271
341
  error: `Antigravity Execution Governance: 'agy-context transcript ${subcommand}' is disabled in self-only mode (caller: ${grant.callerConvId}).`
272
342
  };
273
343
  }
274
- if (targetConvId && grant.callerConvId && grant.callerConvId !== "global" && targetConvId !== grant.callerConvId) {
344
+ if (targetConvId !== null && (!grant.callerConvId || grant.callerConvId === "global" || targetConvId !== grant.callerConvId)) {
345
+ processVerifiedGrant = null;
275
346
  return {
276
347
  allowed: false,
348
+ hasRestriction: true,
277
349
  error: `Antigravity Execution Governance: Cross-session transcript inspection of '${targetConvId}' is disabled in self-only mode (caller: ${grant.callerConvId}).`
278
350
  };
279
351
  }
280
352
  }
281
353
 
282
- return { allowed: true, mode: grant.mode || "open", callerConvId: grant.callerConvId };
354
+ if (targetConvId !== null && grant.targetConvId) {
355
+ const isGrantCurrent = String(grant.targetConvId).toLowerCase() === "current";
356
+ if (isGrantCurrent ? !isImplicitCurrent : targetConvId !== grant.targetConvId) {
357
+ processVerifiedGrant = null;
358
+ return {
359
+ allowed: false,
360
+ hasRestriction: true,
361
+ error: `Antigravity Execution Governance: Capability grant target mismatch (grant authorized '${grant.targetConvId}', but '${targetConvId}' was requested).`
362
+ };
363
+ }
364
+ }
365
+
366
+ if (consumeGrant) {
367
+ processVerifiedGrant = null;
368
+ }
369
+
370
+ return {
371
+ allowed: true,
372
+ hasRestriction: true,
373
+ mode: grant.mode || "open",
374
+ callerConvId: grant.callerConvId,
375
+ targetConvId: grant.targetConvId || null,
376
+ subcommand: grant.subcommand || null
377
+ };
283
378
  }
284
379
 
285
380
  export function formatTranscriptAccessStatus(store = null) {
@@ -452,7 +547,10 @@ function isContainedInDir(targetPath, parentDir) {
452
547
  const realTarget = realOrNull(targetPath);
453
548
  const realParent = realOrNull(parentDir);
454
549
  if (!realTarget || !realParent) return false;
455
- return realTarget === realParent || realTarget.startsWith(realParent + path.sep);
550
+ const norm = (p) => (os.platform() === "win32" ? p.toLowerCase() : p);
551
+ const nt = norm(realTarget);
552
+ const np = norm(realParent);
553
+ return nt === np || nt.startsWith(np.endsWith(path.sep) ? np : np + path.sep);
456
554
  }
457
555
 
458
556
  function siblingModePath(filePath, mode) {
@@ -510,51 +608,109 @@ function scanBrainTranscripts(brainDir) {
510
608
  * inside `realpath(getBrainDir())`.
511
609
  */
512
610
  export function resolveTranscriptPath(target, mode = "compact", options = {}) {
513
- const { explicitFile = null, allowExternalPaths = false } = options;
611
+ const {
612
+ explicitFile = null,
613
+ allowExternalPaths = false,
614
+ subcommand = "read",
615
+ retainGrantForFallback = false
616
+ } = options;
514
617
  const isFull = mode === "full";
515
618
  const fileName = isFull ? "transcript_full.jsonl" : "transcript.jsonl";
516
619
  const brainDir = getBrainDir();
517
620
 
518
- const preAccess = verifyTranscriptCoreAccess({ subcommand: "resolve" });
621
+ const hasExplicitFile = explicitFile !== null && explicitFile !== undefined && String(explicitFile).trim() !== "";
622
+ const rawTargetInput = String(target || "").trim();
623
+ const isImplicitCurrent = !hasExplicitFile && (!rawTargetInput || rawTargetInput.toLowerCase() === "current");
624
+
625
+ const preAccess = verifyTranscriptCoreAccess({ subcommand, consumeGrant: false, isImplicitCurrent });
519
626
  if (!preAccess.allowed) {
520
- return { error: preAccess.error };
627
+ return { error: preAccess.error, isGovernanceError: true };
521
628
  }
522
629
 
630
+ const failResolution = (errorMsg, extra = {}) => {
631
+ if (!retainGrantForFallback) {
632
+ consumeTranscriptAccessGrant();
633
+ }
634
+ return { error: errorMsg, ...extra };
635
+ };
636
+
637
+ const enforceResolvedFileContainment = (candidatePath) => {
638
+ if (preAccess.mode === "self-only") {
639
+ const selfBrainDir = path.join(brainDir, String(preAccess.callerConvId || ""));
640
+ if (!preAccess.callerConvId || preAccess.callerConvId === "global" || !isContainedInDir(candidatePath, selfBrainDir)) {
641
+ return failResolution(
642
+ `Antigravity Execution Governance: Cross-session '--file' access outside '${preAccess.callerConvId}' is disabled in self-only mode.`,
643
+ { isGovernanceError: true }
644
+ );
645
+ }
646
+ } else if (preAccess.hasRestriction && !isContainedInDir(candidatePath, brainDir)) {
647
+ return failResolution(
648
+ `Security containment error: '--file' path '${candidatePath}' escapes brain directory '${brainDir}' while a transcript access policy is active.`,
649
+ { isGovernanceError: true }
650
+ );
651
+ }
652
+ return null;
653
+ };
654
+
523
655
  const finalizeResolution = (filePath, convId) => {
524
- const postAccess = verifyTranscriptCoreAccess({ targetConvId: convId, subcommand: "resolve" });
656
+ if (preAccess.hasRestriction && !convId) {
657
+ return failResolution(
658
+ `Security containment error: unable to derive a valid conversation directory inside '${brainDir}' for '${filePath}'.`,
659
+ { isGovernanceError: true }
660
+ );
661
+ }
662
+ const postAccess = verifyTranscriptCoreAccess({
663
+ targetConvId: convId,
664
+ subcommand,
665
+ consumeGrant: !retainGrantForFallback,
666
+ isImplicitCurrent
667
+ });
525
668
  if (!postAccess.allowed) {
526
- return { error: postAccess.error };
669
+ return { error: postAccess.error, isGovernanceError: true };
527
670
  }
528
671
  return { filePath, convId };
529
672
  };
530
673
 
531
674
  // 0. Explicit --file operator path
532
- if (explicitFile !== null && explicitFile !== undefined && String(explicitFile).trim() !== "") {
675
+ if (hasExplicitFile) {
533
676
  const fileStr = String(explicitFile).trim();
534
677
  if (!fileStr.toLowerCase().endsWith(".jsonl")) {
535
- return { error: `Invalid --file path '${fileStr}': explicit transcript file must end with .jsonl.` };
678
+ return failResolution(`Invalid --file path '${fileStr}': explicit transcript file must end with .jsonl.`);
536
679
  }
537
680
  const resolvedFile = path.resolve(fileStr);
538
681
  if (!fs.existsSync(resolvedFile)) {
539
- return { error: `Transcript file not found at --file path: '${resolvedFile}'.` };
682
+ return failResolution(`Transcript file not found at --file path: '${resolvedFile}'.`);
540
683
  }
541
684
  const realFile = realOrNull(resolvedFile);
542
685
  if (!realFile) {
543
- return { error: `Unable to resolve realpath for --file path: '${resolvedFile}'.` };
686
+ return failResolution(`Unable to resolve realpath for --file path: '${resolvedFile}'.`);
544
687
  }
545
688
  try {
546
689
  if (!fs.statSync(realFile).isFile()) {
547
- return { error: `Invalid --file path '${resolvedFile}': target is not a regular file.` };
690
+ return failResolution(`Invalid --file path '${resolvedFile}': target is not a regular file.`);
548
691
  }
549
692
  } catch (err) {
550
- return { error: `Unable to stat --file path '${resolvedFile}': ${err.message}` };
693
+ return failResolution(`Unable to stat --file path '${resolvedFile}': ${err.message}`);
551
694
  }
552
- const finalPath = siblingModePath(realFile, mode);
553
- return finalizeResolution(finalPath, extractConvId(finalPath));
695
+ const modeCandidate = siblingModePath(realFile, mode);
696
+ const finalPath = realOrNull(modeCandidate) || modeCandidate;
697
+ try {
698
+ if (!fs.statSync(finalPath).isFile()) {
699
+ return failResolution(`Invalid --file path '${finalPath}': target is not a regular file.`);
700
+ }
701
+ } catch (err) {
702
+ return failResolution(`Unable to stat --file path '${finalPath}': ${err.message}`);
703
+ }
704
+ const containmentErr = enforceResolvedFileContainment(finalPath);
705
+ if (containmentErr) return containmentErr;
706
+ return finalizeResolution(
707
+ finalPath,
708
+ extractConvId(finalPath, { strictBrainContainment: preAccess.hasRestriction })
709
+ );
554
710
  }
555
711
 
556
712
  // 1. Omitted or "current": check GEMINI_CONVERSATION_ID, else scan <brainDir> with active-swarm disambiguation
557
- let raw = String(target || "").trim();
713
+ let raw = rawTargetInput;
558
714
  if (!raw || raw.toLowerCase() === "current") {
559
715
  if (process.env.GEMINI_CONVERSATION_ID && process.env.GEMINI_CONVERSATION_ID.trim()) {
560
716
  raw = process.env.GEMINI_CONVERSATION_ID.trim();
@@ -569,76 +725,93 @@ export function resolveTranscriptPath(target, mode = "compact", options = {}) {
569
725
  const listing = recent
570
726
  .map((c) => `- ${c.convId} (modified ${Math.max(0, Math.round((now - c.mtimeMs) / 1000))}s ago)`)
571
727
  .join("\n");
572
- return {
573
- error: `Ambiguous active conversation: ${recent.length} transcripts in ${brainDir} were modified in the last 5 minutes (active swarm). Specify an explicit --id <uuid>:\n${listing}`,
574
- candidates: recent.map((c) => c.convId)
575
- };
728
+ return failResolution(
729
+ `Ambiguous active conversation: ${recent.length} transcripts in ${brainDir} were modified in the last 5 minutes (active swarm). Specify an explicit --id <uuid>:\n${listing}`,
730
+ { candidates: recent.map((c) => c.convId) }
731
+ );
576
732
  }
577
733
  if (recent.length === 1) {
578
734
  raw = recent[0].convId;
579
735
  } else if (candidates.length > 0) {
580
736
  raw = candidates[0].convId;
581
737
  } else {
582
- return { error: `No conversation transcripts found in brain directory: ${brainDir}. Specify --id <uuid> or --file <path>.` };
738
+ return failResolution(
739
+ `No conversation transcripts found in brain directory: ${brainDir}. Specify --id <uuid> or --file <path>.`
740
+ );
583
741
  }
584
742
  }
585
743
  }
586
744
 
587
745
  // 2. Reject path traversal / external path tokens in --id unless allowExternalPaths is explicitly enabled
588
746
  const normalized = raw.replace(/\\/g, "/");
589
- if (!allowExternalPaths) {
747
+ if (!allowExternalPaths || preAccess.hasRestriction) {
590
748
  if (raw.includes("\0") || raw.includes("..") || raw.includes("/") || raw.includes("\\") || normalized.endsWith(".jsonl")) {
591
- return {
592
- error: `Invalid conversation ID '${raw}': must be a bare conversation UUID/folder inside ${brainDir}. Use --file <path> for explicit external .jsonl files.`
593
- };
749
+ return failResolution(
750
+ `Invalid conversation ID '${raw}': must be a bare conversation UUID/folder inside ${brainDir}. Use --file <path> for explicit external .jsonl files.`
751
+ );
594
752
  }
595
753
  }
596
754
 
597
- // 3. Direct file path (legacy MCP compatibility when allowExternalPaths === true)
598
- if (allowExternalPaths && normalized.endsWith(".jsonl") && fs.existsSync(normalized)) {
599
- const finalPath = siblingModePath(normalized, mode);
600
- return finalizeResolution(finalPath, extractConvId(finalPath));
755
+ // 3. Direct file path (legacy compatibility when allowExternalPaths === true and no restriction is active)
756
+ if (allowExternalPaths && !preAccess.hasRestriction && normalized.endsWith(".jsonl") && fs.existsSync(normalized)) {
757
+ const modeCandidate = siblingModePath(normalized, mode);
758
+ const finalPath = realOrNull(modeCandidate) || modeCandidate;
759
+ const containmentErr = enforceResolvedFileContainment(finalPath);
760
+ if (containmentErr) return containmentErr;
761
+ return finalizeResolution(
762
+ finalPath,
763
+ extractConvId(finalPath, { strictBrainContainment: preAccess.hasRestriction })
764
+ );
601
765
  }
602
766
 
603
767
  // 4. UUID / Directory resolution under <brainDir>/<id>
604
- const brainCandidate = path.join(brainDir, normalized, ".system_generated", "logs", fileName);
605
- if (fs.existsSync(brainCandidate)) {
606
- if (!allowExternalPaths && !isContainedInDir(brainCandidate, brainDir)) {
607
- return { error: `Security containment error: resolved path '${brainCandidate}' escapes brain directory '${brainDir}'.` };
768
+ const expectedSessionDir = path.join(brainDir, normalized);
769
+ const checkBrainCandidate = (candidatePath) => {
770
+ const realCandidate = realOrNull(candidatePath);
771
+ if (!realCandidate) {
772
+ return failResolution(`Unable to resolve realpath for '${candidatePath}'.`);
608
773
  }
609
- return finalizeResolution(realOrNull(brainCandidate) || brainCandidate, normalized);
774
+ if ((!allowExternalPaths || preAccess.hasRestriction) && (!isContainedInDir(realCandidate, brainDir) || !isContainedInDir(realCandidate, expectedSessionDir))) {
775
+ return failResolution(
776
+ `Security containment error: resolved path '${realCandidate}' escapes conversation directory '${expectedSessionDir}'.`,
777
+ { isGovernanceError: true }
778
+ );
779
+ }
780
+ const derivedConvId = extractConvId(realCandidate, { strictBrainContainment: preAccess.hasRestriction }) || normalized;
781
+ return finalizeResolution(realCandidate, derivedConvId);
782
+ };
783
+
784
+ const brainCandidate = path.join(expectedSessionDir, ".system_generated", "logs", fileName);
785
+ if (fs.existsSync(brainCandidate)) {
786
+ return checkBrainCandidate(brainCandidate);
610
787
  }
611
788
  if (isFull) {
612
- const compactFallback = path.join(brainDir, normalized, ".system_generated", "logs", "transcript.jsonl");
789
+ const compactFallback = path.join(expectedSessionDir, ".system_generated", "logs", "transcript.jsonl");
613
790
  if (fs.existsSync(compactFallback)) {
614
- if (!allowExternalPaths && !isContainedInDir(compactFallback, brainDir)) {
615
- return { error: `Security containment error: resolved path '${compactFallback}' escapes brain directory '${brainDir}'.` };
616
- }
617
- return finalizeResolution(realOrNull(compactFallback) || compactFallback, normalized);
791
+ return checkBrainCandidate(compactFallback);
618
792
  }
619
793
  } else {
620
- const fullFallback = path.join(brainDir, normalized, ".system_generated", "logs", "transcript_full.jsonl");
794
+ const fullFallback = path.join(expectedSessionDir, ".system_generated", "logs", "transcript_full.jsonl");
621
795
  if (fs.existsSync(fullFallback)) {
622
- if (!allowExternalPaths && !isContainedInDir(fullFallback, brainDir)) {
623
- return { error: `Security containment error: resolved path '${fullFallback}' escapes brain directory '${brainDir}'.` };
624
- }
625
- return finalizeResolution(realOrNull(fullFallback) || fullFallback, normalized);
796
+ return checkBrainCandidate(fullFallback);
626
797
  }
627
798
  }
628
799
 
629
- // 5. Legacy local relative/absolute directory path (only when allowExternalPaths === true)
630
- if (allowExternalPaths) {
800
+ // 5. Legacy local relative/absolute directory path (only when allowExternalPaths === true and no restriction is active)
801
+ if (allowExternalPaths && !preAccess.hasRestriction) {
631
802
  const localCandidate = path.resolve(normalized, ".system_generated", "logs", fileName);
632
803
  if (fs.existsSync(localCandidate)) {
633
- return finalizeResolution(localCandidate, extractConvId(localCandidate));
804
+ const realLocal = realOrNull(localCandidate) || localCandidate;
805
+ return finalizeResolution(realLocal, extractConvId(realLocal));
634
806
  }
635
807
  const directCandidate = path.resolve(normalized, fileName);
636
808
  if (fs.existsSync(directCandidate)) {
637
- return finalizeResolution(directCandidate, extractConvId(directCandidate));
809
+ const realDirect = realOrNull(directCandidate) || directCandidate;
810
+ return finalizeResolution(realDirect, extractConvId(realDirect));
638
811
  }
639
812
  }
640
813
 
641
- return { error: `Transcript not found for target: '${target}'. Searched: ${brainCandidate}` };
814
+ return failResolution(`Transcript not found for target: '${target}'. Searched: ${brainCandidate}`);
642
815
  }
643
816
 
644
817
  function isUrlTokenAtOffset(str, offset) {
@@ -1011,7 +1184,11 @@ export async function readTranscript({
1011
1184
  includeThinking = false,
1012
1185
  allowExternalPaths = false
1013
1186
  } = {}) {
1014
- const resolved = resolveTranscriptPath(conversationId, mode, { explicitFile, allowExternalPaths });
1187
+ const resolved = resolveTranscriptPath(conversationId, mode, {
1188
+ explicitFile,
1189
+ allowExternalPaths,
1190
+ subcommand: "read"
1191
+ });
1015
1192
  if (resolved.error) {
1016
1193
  return { isError: true, text: resolved.error, error: resolved.error, candidates: resolved.candidates };
1017
1194
  }
@@ -1400,19 +1577,41 @@ export async function getStepDetail({
1400
1577
  } = {}) {
1401
1578
  const parsedSelector = parseStepSelector(stepIndex, startStep, endStep);
1402
1579
  if (parsedSelector.error) {
1580
+ consumeTranscriptAccessGrant();
1403
1581
  return { isError: true, text: parsedSelector.error, error: parsedSelector.error };
1404
1582
  }
1405
1583
 
1406
1584
  const { indices, isMulti } = parsedSelector;
1407
1585
  const targetSet = new Set(indices);
1408
1586
 
1409
- const resolvedFull = resolveTranscriptPath(conversationId, "full", { explicitFile, allowExternalPaths });
1410
- if (resolvedFull.error && explicitFile) {
1411
- return { isError: true, text: resolvedFull.error, error: resolvedFull.error, candidates: resolvedFull.candidates };
1587
+ let resolvedFull;
1588
+ let resolvedCompact;
1589
+ try {
1590
+ resolvedFull = resolveTranscriptPath(conversationId, "full", {
1591
+ explicitFile,
1592
+ allowExternalPaths,
1593
+ subcommand: "step",
1594
+ retainGrantForFallback: true
1595
+ });
1596
+ if (resolvedFull.error && (explicitFile || resolvedFull.isGovernanceError)) {
1597
+ return { isError: true, text: resolvedFull.error, error: resolvedFull.error, candidates: resolvedFull.candidates };
1598
+ }
1599
+ resolvedCompact = resolveTranscriptPath(conversationId, "compact", {
1600
+ explicitFile,
1601
+ allowExternalPaths,
1602
+ subcommand: "step",
1603
+ retainGrantForFallback: false
1604
+ });
1605
+ } finally {
1606
+ consumeTranscriptAccessGrant();
1607
+ }
1608
+
1609
+ if (resolvedCompact.error && !resolvedFull.filePath) {
1610
+ return { isError: true, text: resolvedCompact.error, error: resolvedCompact.error, candidates: resolvedCompact.candidates };
1412
1611
  }
1413
1612
 
1414
- let usedConvId = resolvedFull.convId;
1415
- let usedFilePath = resolvedFull.filePath;
1613
+ let usedConvId = resolvedFull.convId || resolvedCompact.convId;
1614
+ let usedFilePath = resolvedFull.filePath || resolvedCompact.filePath;
1416
1615
  const foundMap = new Map();
1417
1616
 
1418
1617
  if (resolvedFull && resolvedFull.filePath && fs.existsSync(resolvedFull.filePath)) {
@@ -1424,17 +1623,12 @@ export async function getStepDetail({
1424
1623
 
1425
1624
  if (foundMap.size < targetSet.size) {
1426
1625
  const missingSet = new Set(indices.filter((idx) => !foundMap.has(idx)));
1427
- const resolvedCompact = resolveTranscriptPath(conversationId, "compact", { explicitFile, allowExternalPaths });
1428
- if (resolvedCompact.error && !resolvedFull.filePath) {
1429
- return { isError: true, text: resolvedCompact.error, error: resolvedCompact.error, candidates: resolvedCompact.candidates };
1430
- }
1431
1626
  if (
1432
1627
  resolvedCompact &&
1433
1628
  resolvedCompact.filePath &&
1434
1629
  resolvedCompact.filePath !== resolvedFull.filePath &&
1435
1630
  fs.existsSync(resolvedCompact.filePath)
1436
1631
  ) {
1437
- usedConvId = usedConvId || resolvedCompact.convId;
1438
1632
  if (foundMap.size === 0) usedFilePath = resolvedCompact.filePath;
1439
1633
  const compactMatches = await batchFindFullSteps(resolvedCompact.filePath, missingSet);
1440
1634
  for (const [k, v] of compactMatches.entries()) {
@@ -1527,13 +1721,27 @@ export async function queryTranscript(args = {}) {
1527
1721
  summaryOnly = false,
1528
1722
  stepIndex,
1529
1723
  field,
1530
- allowExternalPaths = false
1724
+ allowExternalPaths = false,
1725
+ subcommand = "query"
1531
1726
  } = args;
1532
1727
 
1533
- if (stepIndex !== undefined && stepIndex !== null) {
1534
- return getStepDetail({ conversationId, filePath: explicitFile, stepIndex, startStep, endStep, field, allowExternalPaths });
1728
+ if (stepIndex !== undefined && stepIndex !== null) {
1729
+ return getStepDetail({
1730
+ conversationId,
1731
+ filePath: explicitFile,
1732
+ stepIndex,
1733
+ startStep,
1734
+ endStep,
1735
+ field,
1736
+ allowExternalPaths
1737
+ });
1535
1738
  }
1536
1739
 
1740
+ const effectiveSubcommand =
1741
+ canonicalizeTranscriptSubcommand(subcommand) === "read" && Boolean(summaryOnly) && !query
1742
+ ? "read"
1743
+ : "query";
1744
+
1537
1745
  const normalizedField = field ? String(field).trim() : null;
1538
1746
  const fieldImpliesTools = Boolean(
1539
1747
  normalizedField === "tool_args" ||
@@ -1545,16 +1753,29 @@ export async function queryTranscript(args = {}) {
1545
1753
 
1546
1754
  const resolved = resolveTranscriptPath(conversationId, mode === "full" ? "full" : "compact", {
1547
1755
  explicitFile,
1548
- allowExternalPaths
1756
+ allowExternalPaths,
1757
+ subcommand: effectiveSubcommand
1549
1758
  });
1550
1759
  if (resolved.error) {
1551
1760
  return { isError: true, text: resolved.error, error: resolved.error, candidates: resolved.candidates };
1552
1761
  }
1553
1762
 
1554
1763
  const { filePath, convId } = resolved;
1555
- const fullSiblingPath = filePath.endsWith("transcript_full.jsonl")
1764
+ const verifiedParentDir = path.dirname(realOrNull(filePath) || filePath);
1765
+ const rawFullSiblingPath = filePath.endsWith("transcript_full.jsonl")
1556
1766
  ? filePath
1557
1767
  : filePath.replace(/transcript\.jsonl$/, "transcript_full.jsonl");
1768
+ let fullSiblingPath = null;
1769
+ if (rawFullSiblingPath === filePath) {
1770
+ fullSiblingPath = filePath;
1771
+ } else if (fs.existsSync(rawFullSiblingPath)) {
1772
+ const realSibling = realOrNull(rawFullSiblingPath);
1773
+ try {
1774
+ if (realSibling && fs.statSync(realSibling).isFile() && isContainedInDir(realSibling, verifiedParentDir)) {
1775
+ fullSiblingPath = realSibling;
1776
+ }
1777
+ } catch {}
1778
+ }
1558
1779
 
1559
1780
  const roleList = Array.isArray(roles)
1560
1781
  ? roles
@@ -1605,7 +1826,7 @@ export async function queryTranscript(args = {}) {
1605
1826
 
1606
1827
  const flushTruncatedCandidates = async (batchIndices) => {
1607
1828
  if (batchIndices.length === 0 || mode !== "auto") return;
1608
- if (!fs.existsSync(fullSiblingPath)) return;
1829
+ if (!fullSiblingPath || !fs.existsSync(fullSiblingPath)) return;
1609
1830
  const fullMap = await batchFindFullSteps(fullSiblingPath, new Set(batchIndices));
1610
1831
  for (const stepIdx of batchIndices) {
1611
1832
  const fullItem = fullMap.get(stepIdx);
@@ -1747,7 +1968,7 @@ export async function queryTranscript(args = {}) {
1747
1968
  : mergedItems;
1748
1969
 
1749
1970
  // Auto-dereferencing for full rendering mode (or when fullArgs / field requires untruncated tool calls)
1750
- if (mode === "auto" && (!summaryOnly || effectiveFullArgs || normalizedField) && filePath !== fullSiblingPath) {
1971
+ if (mode === "auto" && (!summaryOnly || effectiveFullArgs || normalizedField) && fullSiblingPath && filePath !== fullSiblingPath) {
1751
1972
  const hydrateIndices = new Set();
1752
1973
  for (const it of finalItems) {
1753
1974
  if (it.step_index === undefined) continue;
@@ -1918,9 +2139,10 @@ export async function checkContextHealth({
1918
2139
  allowExternalPaths = false
1919
2140
  } = {}) {
1920
2141
  const resolved = explicitFile
1921
- ? resolveTranscriptPath(null, "compact", { explicitFile, allowExternalPaths })
2142
+ ? resolveTranscriptPath(null, "compact", { explicitFile, allowExternalPaths, subcommand: "health" })
1922
2143
  : resolveTranscriptPath(transcriptPath || conversationId || "current", "compact", {
1923
- allowExternalPaths: allowExternalPaths || Boolean(transcriptPath)
2144
+ allowExternalPaths: allowExternalPaths || Boolean(transcriptPath),
2145
+ subcommand: "health"
1924
2146
  });
1925
2147
 
1926
2148
  if (resolved.error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agy-context-saver",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "PreToolUse execution hook and transcript CLI (agy-context) for Google Antigravity, paired with RTK",
5
5
  "main": "lib/transcript-core.mjs",
6
6
  "bin": {
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agy-context-saver",
3
3
  "displayName": "Agy-Context-Saver",
4
- "version": "2.0.0",
4
+ "version": "2.0.1",
5
5
  "description": "PreToolUse execution hook, background polling governor, and transcript CLI (agy-context) for Google Antigravity, paired with RTK.",
6
6
  "logo": "assets/logo.svg",
7
7
  "suggestedPrompts": [
@@ -2212,13 +2212,42 @@ function checkAgyContextTranscriptCommand(segments, convId = "global") {
2212
2212
  };
2213
2213
  }
2214
2214
 
2215
+ let resolvedTargetConvId = targetInfo.targetId || null;
2216
+ if (targetInfo.explicitFile) {
2217
+ if (accessEval.ownerId && !accessEval.isOwner && !isPathInsideDir(targetInfo.explicitFile, getBrainDir())) {
2218
+ return {
2219
+ matched: true,
2220
+ allowed: false,
2221
+ isAccessDenial: true,
2222
+ reason: buildTranscriptAccessDenial({
2223
+ subcommand,
2224
+ mode: accessEval.mode,
2225
+ source: accessEval.source,
2226
+ callerConvId: convId,
2227
+ rosterName: accessEval.rosterName,
2228
+ detail: `'--file' paths outside '${getBrainDir()}' are disabled while a parent transcript access policy is active.`
2229
+ })
2230
+ };
2231
+ }
2232
+ if (isPathInsideDir(targetInfo.explicitFile, getBrainDir())) {
2233
+ const rel = path.relative(
2234
+ tryRealpath(path.resolve(getBrainDir())),
2235
+ tryRealpath(path.resolve(targetInfo.explicitFile))
2236
+ );
2237
+ const firstSeg = rel.split(path.sep)[0];
2238
+ if (firstSeg && firstSeg !== "." && firstSeg !== "..") {
2239
+ resolvedTargetConvId = firstSeg;
2240
+ }
2241
+ }
2242
+ }
2243
+
2215
2244
  return {
2216
2245
  matched: true,
2217
2246
  allowed: true,
2218
2247
  subcommand,
2219
2248
  mode: "open",
2220
2249
  needsReadGrant: true,
2221
- targetConvId: targetInfo.targetId || null
2250
+ targetConvId: resolvedTargetConvId
2222
2251
  };
2223
2252
  }
2224
2253
 
@@ -23,6 +23,13 @@ import { formatTranscriptAccessStatus } from "../lib/transcript-core.mjs";
23
23
  const fsPromises = fs.promises;
24
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
25
  const repoRoot = path.resolve(__dirname, "..");
26
+ const PACKAGE_VERSION = (() => {
27
+ try {
28
+ return JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")).version || "2.0.1";
29
+ } catch {
30
+ return "2.0.1";
31
+ }
32
+ })();
26
33
 
27
34
  const homeDir = process.env.AGY_TEST_PROFILE_DIR || os.homedir();
28
35
  const geminiConfigDir = path.join(homeDir, ".gemini", "config");
@@ -455,9 +462,9 @@ export async function runInstall(options = {}) {
455
462
  if (existing.details.cliShim.exists) {
456
463
  log(` [OK] CLI Shims (agy-context): Installed in ${antigravityBinDir}`);
457
464
  }
458
- log("\nRe-verifying and synchronizing to v2.0.0...");
465
+ log(`\nRe-verifying and synchronizing to v${PACKAGE_VERSION}...`);
459
466
  } else {
460
- log("Fresh installation of Agy-Context-Saver v2.0.0...");
467
+ log(`Fresh installation of Agy-Context-Saver v${PACKAGE_VERSION}...`);
461
468
  }
462
469
 
463
470
  // 0. Pre-flight Validation: Verify configuration directory write access
@@ -779,7 +786,7 @@ export {
779
786
 
780
787
  function printCliHelp() {
781
788
  console.log(`
782
- Agy-Context-Saver Registration & Lifecycle Engine (v2.0.0)
789
+ Agy-Context-Saver Registration & Lifecycle Engine (v${PACKAGE_VERSION})
783
790
 
784
791
  Usage:
785
792
  node scripts/install-register.mjs [command/option]
@@ -791,7 +798,7 @@ Commands & Options:
791
798
  --restore-backups Restore original configuration backup files (*.bak) (standalone or during uninstall)
792
799
  --allow-degraded Allow installation to proceed even if RTK optimizer provisioning fails
793
800
  --help, -h, help Show this help message
794
- (no arguments) Synchronize and verify v2.0.0 CLI-first installation
801
+ (no arguments) Synchronize and verify v${PACKAGE_VERSION} CLI-first installation
795
802
  `);
796
803
  }
797
804
 
@@ -13,9 +13,15 @@ export const RTK_VERSION = "v1.1.0";
13
13
  export const MIN_RTK_VERSION = "1.1.0";
14
14
  export const MIN_COMPATIBLE_RTK_VERSION = MIN_RTK_VERSION;
15
15
 
16
- // Pinned SHA-256 checksums per release asset. Empty until verified v1.1.0 hashes are pinned;
17
- // ensureRtkInstalled fails closed with manual install instructions when an asset hash is absent.
18
- export const RTK_CHECKSUMS = {};
16
+ // Pinned SHA-256 checksums per release asset from https://github.com/rtk-ai/rtk/releases/download/v1.1.0/checksums.txt;
17
+ // ensureRtkInstalled verifies the downloaded archive before extraction and fails closed on any mismatch.
18
+ export const RTK_CHECKSUMS = {
19
+ "rtk-x86_64-pc-windows-msvc.zip": "c9a8f7a8bad471e64a343314168f38d2399a06fa327cdf5772fb0e645d99ea6f",
20
+ "rtk-aarch64-apple-darwin.tar.gz": "8da773b0954cf90b115ec95c4e4eb420f04c39de233c5db102246a6caf073ba7",
21
+ "rtk-x86_64-apple-darwin.tar.gz": "1e23e8b9542b58428e6409c49b1c3fb92c4c0d5125da72e80f002a46086a6c20",
22
+ "rtk-x86_64-unknown-linux-musl.tar.gz": "9454ad1b9489daa57ab0a9f4ebe2faaf4dc82b8a0f26c9e5135151abd7c9378d",
23
+ "rtk-aarch64-unknown-linux-gnu.tar.gz": "a5c00c3ab0129884bcb757d348afc1a650efa0f4f0769da97c341f28f89719ed"
24
+ };
19
25
 
20
26
  export function isCompatibleRtkVersion(versionStr) {
21
27
  if (!versionStr || typeof versionStr !== "string") return false;
@@ -44,6 +44,26 @@ if (pkg) {
44
44
  errors.push(`package.json version is not a valid semver release version: ${JSON.stringify(pkg.version)}.`);
45
45
  }
46
46
 
47
+ const pluginText = readText('plugin.json');
48
+ if (pluginText) {
49
+ try {
50
+ const pluginManifest = JSON.parse(pluginText);
51
+ if (pluginManifest.name !== pkg.name) {
52
+ errors.push(`plugin.json name ${JSON.stringify(pluginManifest.name)} does not match package.json name ${JSON.stringify(pkg.name)}.`);
53
+ }
54
+ if (pluginManifest.version !== pkg.version) {
55
+ errors.push(`plugin.json version ${JSON.stringify(pluginManifest.version)} does not match package.json version ${JSON.stringify(pkg.version)}.`);
56
+ }
57
+ } catch (error) {
58
+ errors.push(`plugin.json is not valid JSON: ${error.message}`);
59
+ }
60
+ }
61
+
62
+ const changelogText = readText('CHANGELOG.md');
63
+ if (changelogText && pkg.version && !changelogText.includes(`## [${pkg.version}]`)) {
64
+ errors.push(`CHANGELOG.md is missing a release heading for current version "## [${pkg.version}]".`);
65
+ }
66
+
47
67
  const requiredMetadata = [
48
68
  ['repository.url', pkg.repository?.url],
49
69
  ['homepage', pkg.homepage],