Skip to content

Commit fc6fb10

Browse files
authored
Merge pull request #40 from vitry/fix/rescue-origin-route-resolution
fix: resolve Rescue execution through origin worktree routes
2 parents 051a6f5 + 825da43 commit fc6fb10

22 files changed

Lines changed: 2130 additions & 187 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ All notable changes follow Semantic Versioning.
44

55
## Unreleased
66

7+
- Restored compatibility with the legacy PR #39 Rescue route: after origin-to-worktree binding, a child launched or resumed from the conversation root can resolve the immutable execution target. This qualification does not broaden authority beyond that bound target.
78
- Added compatible Rescue worktree late binding: lifecycle proof retains the origin workspace while the first trusted prepare automatically and immutably binds one execution workspace from the same canonical Git common-dir, without manual handoff. Role preview and children cannot claim; unrelated repositories fail closed; Root Stop, a new prompt, and SessionEnd revoke or replace authority before target cleanup.
89
- Accepted ZCode CLI 0.16.3's captured initial empty-session revision and pre-turn settings snapshots while retaining exact empty-state, event-sequence, identity, workspace, and activity checks. Real qualification now proves two visible responses through each turn's exact persisted user-root parent chain when the CLI remaps request input IDs.
910
- Removed the plugin-defined ordinary Rescue completion deadline while retaining finite request, review-gate, qualification, caller credential, and one-shot preparation budgets. Active parent authority is now hook-lifecycle-bound; same-parent-turn continuation replaces consumed preparation generation 1 with an executor-bound generation 2, follows up the exact stopped child, and reuses the exact binding and ZCode session. Root Stop, replacement prompts, SessionEnd, explicit cancellation, SIGINT, and SIGTERM remain authoritative boundaries. Role readiness now distinguishes `caller-unavailable` and `inspection-unavailable` from managed setup states; existing owned Roles require the normal one-time setup upgrade.

docs/superpowers/plans/2026-08-22-rescue-origin-route-resolution.md

Lines changed: 314 additions & 0 deletions
Large diffs are not rendered by default.

docs/superpowers/specs/2026-08-22-rescue-origin-route-resolution-design.md

Lines changed: 342 additions & 0 deletions
Large diffs are not rendered by default.

hooks/lib/hook-state.mjs

Lines changed: 117 additions & 14 deletions
Large diffs are not rendered by default.

marketplace/.agents/plugins/provenance.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"packageVersion": "0.1.0",
33
"pluginVersion": "0.1.0",
4-
"sourceRef": "b171d171816e6f2cb88f5d20fa1de4ea32cf00aa",
5-
"sourceSha": "b171d171816e6f2cb88f5d20fa1de4ea32cf00aa",
4+
"sourceRef": "bdae40eac05a1680f5e81a9e7ad21be87c2df481",
5+
"sourceSha": "bdae40eac05a1680f5e81a9e7ad21be87c2df481",
66
"dependencyLock": {
77
"file": "npm-shrinkwrap.json",
88
"sha256": "fa927194e6ca0b25c1d3f428859b2ab4798b8eabb4d31bc87188d73c630f9938"

marketplace/plugins/zcode/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ All notable changes follow Semantic Versioning.
44

55
## Unreleased
66

7+
- Restored compatibility with the legacy PR #39 Rescue route: after origin-to-worktree binding, a child launched or resumed from the conversation root can resolve the immutable execution target. This qualification does not broaden authority beyond that bound target.
78
- Added compatible Rescue worktree late binding: lifecycle proof retains the origin workspace while the first trusted prepare automatically and immutably binds one execution workspace from the same canonical Git common-dir, without manual handoff. Role preview and children cannot claim; unrelated repositories fail closed; Root Stop, a new prompt, and SessionEnd revoke or replace authority before target cleanup.
89
- Accepted ZCode CLI 0.16.3's captured initial empty-session revision and pre-turn settings snapshots while retaining exact empty-state, event-sequence, identity, workspace, and activity checks. Real qualification now proves two visible responses through each turn's exact persisted user-root parent chain when the CLI remaps request input IDs.
910
- Removed the plugin-defined ordinary Rescue completion deadline while retaining finite request, review-gate, qualification, caller credential, and one-shot preparation budgets. Active parent authority is now hook-lifecycle-bound; same-parent-turn continuation replaces consumed preparation generation 1 with an executor-bound generation 2, follows up the exact stopped child, and reuses the exact binding and ZCode session. Root Stop, replacement prompts, SessionEnd, explicit cancellation, SIGINT, and SIGTERM remain authoritative boundaries. Role readiness now distinguishes `caller-unavailable` and `inspection-unavailable` from managed setup states; existing owned Roles require the normal one-time setup upgrade.

marketplace/plugins/zcode/hooks/lib/hook-state.mjs

Lines changed: 117 additions & 14 deletions
Large diffs are not rendered by default.

marketplace/plugins/zcode/scripts/lib/fs.mjs

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -76,19 +76,19 @@ export async function ensurePrivateDirectoryWithin(root, path) {
7676
/**
7777
* Reads one trusted directory with containment and identity checks around the
7878
* enumeration so callers can validate a stable bounded filename set.
79-
* @param {string} root @param {string} path @param {number} maximumEntries
79+
* @param {string} root @param {string} path @param {number} maximumEntries @param {{ requirePrivatePermissions?: boolean, platform?: NodeJS.Platform }} [options]
8080
*/
81-
export async function readPrivateDirectory(root, path, maximumEntries) {
81+
export async function readPrivateDirectory(root, path, maximumEntries, options = {}) {
8282
if (!Number.isSafeInteger(maximumEntries) || maximumEntries < 0) throw new TypeError('maximumEntries must be a nonnegative safe integer');
83-
const before = await safeContainedDirectoryStats(root, path);
83+
const before = await safeContainedDirectoryStats(root, path, options);
8484
const directory = await opendir(path); const entries = [];
8585
try {
8686
for await (const entry of directory) {
8787
entries.push(entry);
8888
if (entries.length > maximumEntries) throw unsafePrivatePath(path);
8989
}
9090
} finally { await directory.close().catch(() => {}); }
91-
const after = await safeContainedDirectoryStats(root, path);
91+
const after = await safeContainedDirectoryStats(root, path, options);
9292
if (!sameIdentity(before, after)) throw unsafePrivatePath(path);
9393
return entries;
9494
}
@@ -98,20 +98,22 @@ export async function readPrivateDirectory(root, path, maximumEntries) {
9898
* declared size before allocating, and reads at most maximumBytes + 1 so a
9999
* concurrent growth cannot bypass the bound.
100100
* @param {string} root @param {string} path @param {number} maximumBytes
101-
* @param {{platform?:NodeJS.Platform}} [options]
101+
* @param {{platform?:NodeJS.Platform, requirePrivatePermissions?:boolean}} [options]
102102
*/
103103
export async function readBoundedJsonFile(root, path, maximumBytes, options = {}) {
104104
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) throw new TypeError('maximumBytes must be a positive safe integer');
105105
const platform = options.platform ?? process.platform;
106106
const maximumFileSize = BigInt(maximumBytes);
107-
const parent = dirname(path); const parentBefore = await safeContainedDirectoryStats(root, parent);
107+
const parent = dirname(path); const parentBefore = await safeContainedDirectoryStats(root, parent, options);
108108
const pathBefore = await lstat(path, { bigint: true });
109-
if (pathBefore.isSymbolicLink() || !pathBefore.isFile() || pathBefore.size > maximumFileSize) throw unsafePrivatePath(path);
109+
if (pathBefore.isSymbolicLink() || !pathBefore.isFile() || pathBefore.size > maximumFileSize
110+
|| options.requirePrivatePermissions === true && platform !== 'win32' && Number(pathBefore.mode & 0o777n) !== 0o600) throw unsafePrivatePath(path);
110111
let handle;
111112
try {
112113
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
113114
const handleBefore = await handle.stat({ bigint: true });
114-
if (!handleBefore.isFile() || handleBefore.size > maximumFileSize) throw unsafePrivatePath(path);
115+
if (!handleBefore.isFile() || handleBefore.size > maximumFileSize
116+
|| options.requirePrivatePermissions === true && platform !== 'win32' && Number(handleBefore.mode & 0o777n) !== 0o600) throw unsafePrivatePath(path);
115117
const bytes = Buffer.alloc(maximumBytes + 1); let offset = 0;
116118
while (offset < bytes.length) {
117119
const result = await handle.read(bytes, offset, bytes.length - offset, offset);
@@ -125,7 +127,7 @@ export async function readBoundedJsonFile(root, path, maximumBytes, options = {}
125127
try {
126128
current = await currentHandle.stat({ bigint: true });
127129
} finally { await currentHandle.close(); }
128-
const [pathAfter, parentAfter] = await Promise.all([lstat(path, { bigint: true }), safeContainedDirectoryStats(root, parent)]);
130+
const [pathAfter, parentAfter] = await Promise.all([lstat(path, { bigint: true }), safeContainedDirectoryStats(root, parent, options)]);
129131
if (pathAfter.isSymbolicLink() || !pathAfter.isFile() || pathAfter.size > maximumFileSize
130132
|| !handleAfter.isFile() || handleAfter.size > maximumFileSize || !current.isFile() || current.size > maximumFileSize
131133
|| !sameFileSnapshot(pathBefore, pathAfter) || !sameFileSnapshot(handleBefore, handleAfter)
@@ -247,7 +249,7 @@ export async function readJsonFile(path) {
247249
* @template T
248250
* @param {string} lockPath
249251
* @param {() => Promise<T>} operation
250-
* @param {{ pollIntervalMs?: number, timeoutMs?: number, beforeLockOpen?: () => Promise<void>, signal?:AbortSignal }} [options]
252+
* @param {{ pollIntervalMs?: number, timeoutMs?: number, beforeLockOpen?: () => Promise<void>, createLayout?: boolean, signal?:AbortSignal }} [options]
251253
* @returns {Promise<T>}
252254
*/
253255
export async function withFileLock(lockPath, operation, options = {}) {
@@ -256,6 +258,7 @@ export async function withFileLock(lockPath, operation, options = {}) {
256258
if (
257259
!Number.isSafeInteger(settings.timeoutMs) || settings.timeoutMs < 0
258260
|| !Number.isSafeInteger(settings.pollIntervalMs) || settings.pollIntervalMs <= 0
261+
|| typeof settings.createLayout !== 'undefined' && typeof settings.createLayout !== 'boolean'
259262
) {
260263
throw new PluginError('LOCK_OPTIONS_INVALID', 'Lock timing options must be safe integer milliseconds.', {
261264
category: 'storage',
@@ -264,31 +267,43 @@ export async function withFileLock(lockPath, operation, options = {}) {
264267
});
265268
}
266269
const startedAt = Date.now();
267-
await ensureLockLayout(lockPath);
268-
options.signal?.throwIfAborted();
269270
const lockFilePath = join(lockPath, 'advisory.lock');
270-
const lockDirectoryStats = await safeLockStats(lockPath, 'lock directory', 'directory');
271-
const lockFileStats = await safeLockStats(lockFilePath, 'advisory lock file', 'file');
272271
let handle;
273272
let confirmationHandle;
274273
try {
274+
if (settings.createLayout !== false) await ensureLockLayout(lockPath);
275+
options.signal?.throwIfAborted();
276+
const lockDirectoryStats = await safeLockStats(lockPath, 'lock directory', 'directory');
277+
await safeLockStats(lockFilePath, 'advisory lock file', 'file');
278+
const lockFileStats = await lstat(lockFilePath, { bigint: true });
279+
if (settings.createLayout === false && process.platform !== 'win32'
280+
&& ((lockDirectoryStats.mode & 0o777) !== 0o700 || Number(lockFileStats.mode & 0o777n) !== 0o600)) throw unsafeLockPath(lockFilePath, 'private advisory lock layout');
275281
handle = await open(lockFilePath, constants.O_RDWR | (constants.O_NOFOLLOW ?? 0));
276282
options.signal?.throwIfAborted();
277-
const openedStats = await handle.stat();
283+
const openedStats = await handle.stat({ bigint: true });
278284
await options.beforeLockOpen?.();
279285
options.signal?.throwIfAborted();
280286
confirmationHandle = await open(lockFilePath, constants.O_RDWR | (constants.O_NOFOLLOW ?? 0));
281-
const [confirmationStats, currentDirectoryStats, currentFileStats] = await Promise.all([
282-
confirmationHandle.stat(),
287+
const [confirmationStats, openedCurrentStats, currentDirectoryStats, currentFileStats] = await Promise.all([
288+
confirmationHandle.stat({ bigint: true }),
289+
handle.stat({ bigint: true }),
283290
safeLockStats(lockPath, 'lock directory', 'directory'),
284-
safeLockStats(lockFilePath, 'advisory lock file', 'file'),
291+
lstat(lockFilePath, { bigint: true }),
285292
]);
286293
if (!sameIdentity(lockDirectoryStats, currentDirectoryStats)
294+
|| currentFileStats.isSymbolicLink() || !currentFileStats.isFile()
287295
|| !sameIdentity(lockFileStats, currentFileStats)
288-
|| !sameIdentity(openedStats, confirmationStats)) throw unsafeLockPath(lockFilePath, 'advisory lock file');
296+
|| !samePathHandleIdentity(lockFileStats, openedStats)
297+
|| !samePathHandleIdentity(lockFileStats, openedCurrentStats)
298+
|| !samePathHandleIdentity(lockFileStats, confirmationStats)
299+
|| settings.createLayout === false && process.platform !== 'win32'
300+
&& ((currentDirectoryStats.mode & 0o777) !== 0o700
301+
|| Number(currentFileStats.mode & 0o777n) !== 0o600
302+
|| Number(openedCurrentStats.mode & 0o777n) !== 0o600
303+
|| Number(confirmationStats.mode & 0o777n) !== 0o600)) throw unsafeLockPath(lockFilePath, 'advisory lock file');
289304
await confirmationHandle.close();
290305
confirmationHandle = undefined;
291-
await handle.chmod(0o600);
306+
if (settings.createLayout !== false) await handle.chmod(0o600);
292307
options.signal?.throwIfAborted();
293308
} catch (error) {
294309
if (confirmationHandle) await confirmationHandle.close().catch(() => {});
@@ -426,12 +441,13 @@ async function safeLockStats(path, kind, type) {
426441
return stats;
427442
}
428443

429-
/** @param {string} root @param {string} path */
430-
async function safeContainedDirectoryStats(root, path) {
444+
/** @param {string} root @param {string} path @param {{ requirePrivatePermissions?: boolean, platform?: NodeJS.Platform }} [options] */
445+
async function safeContainedDirectoryStats(root, path, options = {}) {
431446
const { rootPath, targetPath } = containedPath(root, path);
432447
const [rootStats, targetStats] = await Promise.all([lstat(rootPath), lstat(targetPath)]);
433448
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()
434-
|| targetStats.isSymbolicLink() || !targetStats.isDirectory()) throw unsafePrivatePath(targetPath);
449+
|| targetStats.isSymbolicLink() || !targetStats.isDirectory()
450+
|| options.requirePrivatePermissions === true && (options.platform ?? process.platform) !== 'win32' && (targetStats.mode & 0o777) !== 0o700) throw unsafePrivatePath(targetPath);
435451
const [canonicalRoot, canonicalTarget] = await Promise.all([realpath(rootPath), realpath(targetPath)]);
436452
if (!pathIsWithin(canonicalRoot, canonicalTarget)) throw unsafePrivatePath(targetPath);
437453
return targetStats;
@@ -479,6 +495,11 @@ export function samePathHandleFileSnapshot(pathStats, handleStats, platform = pr
479495
&& sameFileMetadata(pathStats, handleStats);
480496
}
481497

498+
/** @param {{dev:number|bigint,ino:number|bigint}} pathStats @param {{dev:number|bigint,ino:number|bigint}} handleStats @param {NodeJS.Platform} [platform] */
499+
function samePathHandleIdentity(pathStats, handleStats, platform = process.platform) {
500+
return pathStats.ino === handleStats.ino && (platform === 'win32' || pathStats.dev === handleStats.dev);
501+
}
502+
482503
/**
483504
* @param {{dev:number|bigint,ino:number|bigint,size:number|bigint,mtimeNs:bigint,ctimeNs:bigint}} left
484505
* @param {{dev:number|bigint,ino:number|bigint,size:number|bigint,mtimeNs:bigint,ctimeNs:bigint}} right

0 commit comments

Comments
 (0)