diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e552de650..4cdd47b54 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "genie", - "version": "5.260831.3", + "version": "5.260831.5", "source": "./plugins/genie", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, wish them into plans, make with parallel agents, ship as one team. A coding genie that grows with your project." } diff --git a/package.json b/package.json index 79d027a67..9f48d43fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@automagik/genie", - "version": "5.260831.3", + "version": "5.260831.5", "description": "Collaborative terminal toolkit for human + AI workflows. NOTE: npm distribution discontinued 2026-05-09 — install via `curl -fsSL https://raw.githubusercontent.com/automagik-dev/genie/main/install.sh | bash` (cosign + SLSA verified). See https://automagik.dev/genie/release-process", "license": "MIT", "type": "module", diff --git a/plugins/genie/.claude-plugin/plugin.json b/plugins/genie/.claude-plugin/plugin.json index d91fea75b..aaee8684b 100644 --- a/plugins/genie/.claude-plugin/plugin.json +++ b/plugins/genie/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260831.3", + "version": "5.260831.5", "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.", "author": { "name": "Namastex Labs" diff --git a/plugins/genie/.codex-plugin/plugin.json b/plugins/genie/.codex-plugin/plugin.json index 874cbbf0a..7b1f5fb40 100644 --- a/plugins/genie/.codex-plugin/plugin.json +++ b/plugins/genie/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260831.3", + "version": "5.260831.5", "description": "Plan, execute, review, and ship software with Genie workflows in Codex.", "author": { "name": "Namastex Labs", diff --git a/plugins/genie/.kimi-plugin/plugin.json b/plugins/genie/.kimi-plugin/plugin.json index 8f97a36fb..eaaf8599d 100644 --- a/plugins/genie/.kimi-plugin/plugin.json +++ b/plugins/genie/.kimi-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genie", - "version": "5.260831.3", + "version": "5.260831.5", "description": "Human-AI partnership for Kimi Code CLI. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /genie:work, validate with /genie:review, and ship as one team.", "author": { "name": "Namastex Labs", diff --git a/plugins/genie/orca-plugin.json b/plugins/genie/orca-plugin.json index 9d31c6289..bf31012f0 100644 --- a/plugins/genie/orca-plugin.json +++ b/plugins/genie/orca-plugin.json @@ -3,7 +3,7 @@ "id": "genie", "publisher": "automagik", "name": "Genie", - "version": "5.260831.3", + "version": "5.260831.5", "description": "Genie workflows backed by Orca as the sole lifecycle authority.", "author": { "name": "Namastex Labs", diff --git a/plugins/genie/package.json b/plugins/genie/package.json index 5a5010786..b30966dc9 100644 --- a/plugins/genie/package.json +++ b/plugins/genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-plugin", - "version": "5.260831.3", + "version": "5.260831.5", "private": true, "description": "Runtime dependencies for genie bundled CLIs", "license": "MIT", diff --git a/plugins/hermes-genie/plugin.yaml b/plugins/hermes-genie/plugin.yaml index bd0678cd8..fa4d95ccf 100644 --- a/plugins/hermes-genie/plugin.yaml +++ b/plugins/hermes-genie/plugin.yaml @@ -1,5 +1,5 @@ name: genie -version: 5.260831.3 +version: 5.260831.5 description: "Native Hermes surface for Genie orchestration: read-only status, work-plan and review-plan tools, hooks, commands, and a thin cockpit skill." provides_tools: # Exactly three native read-only planning/status tools. diff --git a/plugins/pi-genie/package.json b/plugins/pi-genie/package.json index b8133df1b..5ce42183f 100644 --- a/plugins/pi-genie/package.json +++ b/plugins/pi-genie/package.json @@ -1,6 +1,6 @@ { "name": "genie-pi-plugin", - "version": "5.260831.3", + "version": "5.260831.5", "private": true, "description": "Pi extension manifest for the Genie pi plugin — the plugin payload is plugins/pi-genie/extension.ts", "license": "MIT", diff --git a/src/genie.ts b/src/genie.ts index 5e728917d..2b24a68d6 100644 --- a/src/genie.ts +++ b/src/genie.ts @@ -43,7 +43,21 @@ program .option('--staging-root ') .option('--expected-version ') .option('--self-test') - .action((options: InstallPromoteCommandOptions) => installPromoteCommand(options)); + .action((options: InstallPromoteCommandOptions) => { + try { + installPromoteCommand(options); + } catch (error) { + // Preflight/link failures are operator-fixable environment problems + // (e.g. a group-writable ~/.local/bin); print the remedy, never a stack. + const name = error instanceof Error ? error.name : ''; + if (name === 'CanonicalInstallLinkError' || name === 'InstallPromoteCommandError') { + console.error(`\u2716 ${(error as Error).message}`); + process.exitCode = 1; + return; + } + throw error; + } + }); // Global --no-interactive flag: disables all interactive prompts (scripting safety) program.option('--no-interactive', 'Disable interactive prompts (exit 2 instead of prompting)'); diff --git a/src/lib/install-link.test.ts b/src/lib/install-link.test.ts index d296e4c72..4733f14cb 100644 --- a/src/lib/install-link.test.ts +++ b/src/lib/install-link.test.ts @@ -15,7 +15,13 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { CanonicalInstallLinkError, prepareCanonicalInstallLink, verifyCanonicalInstallLink } from './install-link.js'; +import { + CanonicalInstallLinkError, + classifyOwnedDirectorySafety, + preflightCanonicalInstallLink, + prepareCanonicalInstallLink, + verifyCanonicalInstallLink, +} from './install-link.js'; const roots: string[] = []; @@ -130,6 +136,40 @@ describe('canonical installer link', () => { }); for (const unsafeAncestor of ['.local', '.local/bin'] as const) { + test('accepts a 0775 ~/.local/bin owned by the user and their effective group (user-private-group umask 002)', () => { + const f = fixture(); + const localBin = join(f.home, '.local', 'bin'); + mkdirSync(localBin, { recursive: true, mode: 0o755 }); + chmodSync(join(f.home, '.local'), 0o775); + chmodSync(localBin, 0o775); + // gid of a fresh dir is the process egid on non-setgid parents, matching the relaxation. + expect(() => + preflightCanonicalInstallLink({ + trustedHome: f.home, + linkPath: join(localBin, 'genie'), + targetPath: join(f.home, '.genie', 'bin', 'genie'), + }), + ).not.toThrow(); + }); + + test('classifyOwnedDirectorySafety: pure verdicts', () => { + const uid = 1000n; + const gid = 1000n; + const mk = (mode: number, o: Partial<{ uid: bigint; gid: bigint; nlink: bigint }> = {}) => + ({ uid: o.uid ?? uid, gid: o.gid ?? gid, nlink: o.nlink ?? 2n, mode: BigInt(0o40000 | mode) }) as never; + expect(classifyOwnedDirectorySafety(mk(0o755), { uid, gid })).toEqual({ ok: true }); + expect(classifyOwnedDirectorySafety(mk(0o775), { uid, gid })).toEqual({ ok: true }); + const foreign = classifyOwnedDirectorySafety(mk(0o775, { gid: 999n }), { uid, gid }); + expect(foreign.ok).toBe(false); + if (!foreign.ok) expect(foreign.reason).toContain('group 999'); + const world = classifyOwnedDirectorySafety(mk(0o777), { uid, gid }); + expect(world.ok).toBe(false); + if (!world.ok) expect(world.reason).toContain('world-writable (mode 777)'); + const sudo = classifyOwnedDirectorySafety(mk(0o755, { uid: 0n }), { uid, gid }); + expect(sudo.ok).toBe(false); + if (!sudo.ok) expect(sudo.reason).toContain('owned by uid 0'); + }); + test(`rejects a group/world-writable ${unsafeAncestor} PATH ancestor`, () => { const f = fixture(); const localBin = join(f.home, '.local', 'bin'); @@ -138,7 +178,7 @@ describe('canonical installer link', () => { expect(() => prepareCanonicalInstallLink({ trustedHome: f.home, linkPath: f.link, targetPath: f.target }), - ).toThrow('safe permissions'); + ).toThrow('writable'); expect(existsSync(f.link)).toBe(false); }); } diff --git a/src/lib/install-link.ts b/src/lib/install-link.ts index 07374d501..bf36711f3 100644 --- a/src/lib/install-link.ts +++ b/src/lib/install-link.ts @@ -69,6 +69,48 @@ function currentUid(): bigint { return BigInt(process.getuid()); } +function currentGid(): bigint { + if (process.getegid === undefined) + throw new CanonicalInstallLinkError('canonical link requires a POSIX group identity'); + return BigInt(process.getegid()); +} + +/** + * Pure safety classifier for a parent directory of the canonical install link. + * Ownership contract: owned by the current uid, at least one hard link, never + * world-writable, and group-writable ONLY when the directory's group is the + * process's effective group — the Debian/Ubuntu user-private-group layout + * (umask 002 → `~/.local/bin` is 0775 :), which is as private as + * 0755. A group-writable directory owned by any other group stays rejected: + * unknown group members could swap the `genie` link. + */ +export function classifyOwnedDirectorySafety( + stat: Pick, + identity: { uid: bigint; gid: bigint }, +): { ok: true } | { ok: false; reason: string; remedy: string } { + const mode = Number(stat.mode & 0o777n); + const octal = mode.toString(8).padStart(3, '0'); + if (stat.uid !== identity.uid) { + return { + ok: false, + reason: `is owned by uid ${stat.uid}, not the current user (uid ${identity.uid})`, + remedy: 'chown it to your user (it may have been created with sudo), then retry', + }; + } + if (stat.nlink < 1n) return { ok: false, reason: 'has no hard links', remedy: 'recreate the directory, then retry' }; + if ((mode & 0o002) !== 0) { + return { ok: false, reason: `is world-writable (mode ${octal})`, remedy: 'run: chmod o-w , then retry' }; + } + if ((mode & 0o020) !== 0 && stat.gid !== identity.gid) { + return { + ok: false, + reason: `is writable by group ${stat.gid}, which is not your effective group (gid ${identity.gid}); mode ${octal}`, + remedy: 'run: chmod g-w , then retry', + }; + } + return { ok: true }; +} + function fdReferencePath(fd: number): string { if (process.platform === 'linux') return `/proc/self/fd/${fd}`; if (process.platform === 'darwin') return `/dev/fd/${fd}`; @@ -127,8 +169,9 @@ function assertSafeOwnedDirectoryStat(stat: BigIntStats, label: string): void { if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new CanonicalInstallLinkError(`${label} is not a physical directory`); } - if (stat.uid !== currentUid() || stat.nlink < 1n || Number(stat.mode & 0o022n) !== 0) { - throw new CanonicalInstallLinkError(`${label} is not current-user-owned with safe permissions`); + const verdict = classifyOwnedDirectorySafety(stat, { uid: currentUid(), gid: currentGid() }); + if (!verdict.ok) { + throw new CanonicalInstallLinkError(`${label} ${verdict.reason} — ${verdict.remedy.replace('', label)}`); } }