diff --git a/openspec/changes/agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54/.openspec.yaml b/openspec/changes/agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54/.openspec.yaml new file mode 100644 index 0000000..e8d4ccf --- /dev/null +++ b/openspec/changes/agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-08 diff --git a/openspec/changes/agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54/proposal.md b/openspec/changes/agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54/proposal.md new file mode 100644 index 0000000..bccb84e --- /dev/null +++ b/openspec/changes/agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54/proposal.md @@ -0,0 +1,44 @@ +## Why + +A fresh agent worktree is a clean checkout: gitignored files (`.env`, +`node_modules`, `.venv`) don't exist, so agents start broken until someone +copies/links them. gitguardex already auto-provisions this — but only for the +hardcoded `apps/*` monorepo shape (symlink `apps//.env`, assign dev ports). +Any other repo layout gets nothing. workmux solves this declaratively with a +per-repo config (`files.copy`/`files.symlink` + `post_create` hooks); this ports +that idea so any repo can describe its own provisioning. + +## What Changes + +- Add a per-repo **`.guardex.json`** config (parsed with the existing + `jsonc-parser`, so comments are allowed — no new dependency) with a + `provision` block: + + ```jsonc + { "provision": { + "files": { "copy": [".env", "apps/*/.env"], "symlink": ["node_modules", ".venv"] }, + "postCreate": ["pnpm install --offline"] } } + ``` + +- Add `src/scaffold/provision-config.js`: loader + a minimal dependency-free glob + (literal segments + a single-segment `*`, e.g. `apps/*/.env`) + appliers for + copy, symlink, and postCreate hooks. All best-effort: a missing config, a + no-match pattern, or a failing hook never throws fatally. +- Wire it into `prepareAgentWorktree` (already auto-invoked on worktree + creation) so declarative provisioning runs for **any** repo, ahead of the + existing `apps/*` convenience which stays as the zero-config default. + +## Impact + +- Additive only; the existing `apps/*` env-symlink + dev-port behavior is + unchanged (no longer gated behind an early-return, so non-monorepo repos now + also get declarative provisioning). +- `copy`/`symlink` are pure filesystem ops. **Trust model:** `postCreate` + executes shell commands from the repo owner's committed `.guardex.json` (same + trust as `package.json` scripts or workmux `post_create`); it reads only the + trusted repo-root config, logs each command, is non-fatal, and is disabled + with `GUARDEX_PROVISION_HOOKS=0`. +- Glob is intentionally minimal (one `*` per segment, no `**`/braces); patterns + with absolute paths or `..` are rejected to keep provisioning inside the repo. +- Affected: `src/scaffold/provision-config.js` (new), + `src/scaffold/agent-worktree-prep.js`, `test/provision-config.test.js` (new). diff --git a/openspec/changes/agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54/specs/workmux-w2-declarative-guardex-yaml-worktree-provisioning-files-copy-symlink-and-post-create-hooks/spec.md b/openspec/changes/agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54/specs/workmux-w2-declarative-guardex-yaml-worktree-provisioning-files-copy-symlink-and-post-create-hooks/spec.md new file mode 100644 index 0000000..8e92ecf --- /dev/null +++ b/openspec/changes/agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54/specs/workmux-w2-declarative-guardex-yaml-worktree-provisioning-files-copy-symlink-and-post-create-hooks/spec.md @@ -0,0 +1,64 @@ +## ADDED Requirements + +### Requirement: Declarative worktree provisioning config +The system SHALL read an optional `.guardex.json` at the repo root and, when it +contains a `provision` object, apply it to a freshly created agent worktree. The +config SHALL be parsed permitting comments and trailing commas. A missing file, +unparseable JSON, or a missing `provision` block SHALL be treated as "no +declarative provisioning" without error. `provision.files.copy`, +`provision.files.symlink`, and `provision.postCreate` SHALL each normalize to a +list of non-empty strings, dropping any other value. + +#### Scenario: Config is loaded and normalized +- **WHEN** a repo's `.guardex.json` has `provision.files.copy` of `[".env", 5]` +- **THEN** the loaded copy list is `[".env"]` (the non-string is dropped). + +#### Scenario: Absent or malformed config is inert +- **WHEN** there is no `.guardex.json`, or it is malformed, or it has no + `provision` block +- **THEN** provisioning loads nothing and the worktree is created normally. + +### Requirement: Copy and symlink provisioning into the worktree +For each `files.copy` pattern the system SHALL copy each matching repo-root file +into the worktree at the same relative path; for each `files.symlink` pattern it +SHALL create a symlink in the worktree pointing at the repo-root path. Existing +worktree paths SHALL NOT be overwritten. Copy SHALL apply to files only +(directories are skipped with a note; use symlink for directories). Patterns +SHALL support literal segments and a single-segment `*` wildcard (e.g. +`apps/*/.env`); patterns that are absolute or contain `..` SHALL be rejected. + +#### Scenario: Files copied, directories symlinked +- **WHEN** `copy` is `[".env"]` and `symlink` is `["node_modules"]` for a repo + that has both +- **THEN** the worktree gets a real copy of `.env` and a symlink `node_modules` + pointing at the repo root +- **AND** re-running leaves the already-present entries unchanged. + +#### Scenario: Unsafe pattern rejected +- **WHEN** a pattern is `../secrets` or `/etc/passwd` +- **THEN** it matches nothing and no file outside the repo is touched. + +### Requirement: post_create hooks with a trust boundary +For each `provision.postCreate` command the system SHALL run it as a shell +command with the worktree as the working directory and with `GUARDEX_WORKTREE` +and `GUARDEX_REPO_ROOT` in the environment. Hooks SHALL run only from the trusted +repo-root config, SHALL be non-fatal (a failing hook is recorded, not thrown), +and SHALL be skippable via `GUARDEX_PROVISION_HOOKS=0`. + +#### Scenario: Hook runs in the worktree +- **WHEN** `postCreate` is `["pnpm install"]` +- **THEN** the command runs with cwd = the worktree and `GUARDEX_WORKTREE` set. + +#### Scenario: Hooks disabled +- **WHEN** `GUARDEX_PROVISION_HOOKS=0` is set +- **THEN** no postCreate command runs and each is recorded as skipped. + +### Requirement: Provisioning runs for any repo layout +Declarative provisioning SHALL run on worktree creation regardless of whether the +repo has an `apps/*` monorepo layout, and SHALL NOT regress the existing `apps/*` +env-symlink and per-app dev-port behavior. + +#### Scenario: Non-monorepo repo is provisioned +- **WHEN** a repo with no `apps/` directory has a `.guardex.json` provision block + and a worktree is prepared +- **THEN** the declared copy/symlink operations are applied to the worktree. diff --git a/openspec/changes/agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54/tasks.md b/openspec/changes/agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54/tasks.md new file mode 100644 index 0000000..32b5794 --- /dev/null +++ b/openspec/changes/agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54/tasks.md @@ -0,0 +1,35 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54`; branch=`agent//`; scope=`.guardex.json declarative worktree provisioning`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54` on branch `agent//`. Work inside the existing sandbox, review `openspec/changes/agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent// --base main --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54`. +- [x] 1.2 Define normative requirements in `specs/workmux-w2-declarative-guardex-yaml-worktree-provisioning-files-copy-symlink-and-post-create-hooks/spec.md`. + +## 2. Implementation + +- [x] 2.1 Add `src/scaffold/provision-config.js` (loader + minimal glob + copy/symlink/postCreate appliers). +- [x] 2.2 Wire `provisionFromConfig` into `prepareAgentWorktree` (runs for any repo, ahead of apps/* default). +- [x] 2.3 Add `test/provision-config.test.js` regression coverage (8 cases incl. trust-boundary + unsafe patterns). + +## 3. Verification + +- [x] 3.1 `node --test test/provision-config.test.js` (8 pass); full-suite failing set byte-identical to base (28=28, zero new). +- [x] 3.2 Run `openspec validate agent-claude-workmux-w2-declarative-guardex-yaml-work-2026-06-08-08-54 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent// --base main --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/src/scaffold/agent-worktree-prep.js b/src/scaffold/agent-worktree-prep.js index 88f891f..7484783 100644 --- a/src/scaffold/agent-worktree-prep.js +++ b/src/scaffold/agent-worktree-prep.js @@ -20,6 +20,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); +const { provisionFromConfig } = require('./provision-config'); const ENV_FILE_CANDIDATES = [ '.env', @@ -190,10 +191,16 @@ function prepareAgentWorktree(repoRoot, worktreePath) { if (!repoRoot || !worktreePath) return []; if (repoRoot === worktreePath) return []; if (!fs.existsSync(worktreePath)) return []; - const apps = detectAppPackages(repoRoot); - if (apps.length === 0) return []; const operations = []; + + // Declarative `.guardex.json` provisioning runs for ANY repo (monorepo or + // not) — copy/symlink gitignored files and run post_create hooks. + operations.push(...provisionFromConfig(repoRoot, worktreePath)); + + // Built-in apps/* monorepo convenience: env-file symlinks + a free dev port + // per app. Stays as the zero-config default for monorepos. + const apps = detectAppPackages(repoRoot); const takenPorts = new Set(); for (const appName of apps) { operations.push(...symlinkAppEnvFiles(repoRoot, worktreePath, appName)); diff --git a/src/scaffold/provision-config.js b/src/scaffold/provision-config.js new file mode 100644 index 0000000..18c5249 --- /dev/null +++ b/src/scaffold/provision-config.js @@ -0,0 +1,314 @@ +'use strict'; + +// Declarative per-repo worktree provisioning (workmux W2). +// +// A repo may commit a `.guardex.json` at its root describing how to make a +// fresh agent worktree usable — which gitignored files to copy or symlink in, +// and which setup commands to run after creation: +// +// { +// "provision": { +// "files": { +// "copy": [".env", "apps/*/.env"], // per-worktree copies +// "symlink": ["node_modules", ".venv"] // shared via symlink +// }, +// "postCreate": ["pnpm install --offline"] +// } +// } +// +// Parsed with jsonc-parser (comments allowed) — no new dependency. copy/symlink +// are pure filesystem ops. postCreate runs shell commands from the repo owner's +// committed config (same trust as package.json scripts); disable with +// GUARDEX_PROVISION_HOOKS=0. Everything is best-effort and never throws fatally. + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const jsonc = require('jsonc-parser'); + +const CONFIG_BASENAME = '.guardex.json'; +const POST_CREATE_TIMEOUT_MS = 10 * 60 * 1000; + +function toStringArray(value) { + if (!Array.isArray(value)) return []; + return value.filter((item) => typeof item === 'string' && item.trim() !== ''); +} + +// Read + normalize /.guardex.json's `provision` block. Returns null +// when there is no config or no provision block; tolerant of malformed JSON. +function loadProvisionConfig(repoRoot, deps = {}) { + if (!repoRoot) return null; + const readFile = deps.readFile || ((p) => fs.readFileSync(p, 'utf8')); + const configPath = path.join(repoRoot, CONFIG_BASENAME); + + let text; + try { + text = readFile(configPath); + } catch (error) { + if (error && error.code === 'ENOENT') return null; + return null; + } + + const errors = []; + const parsed = jsonc.parse(text, errors, { allowTrailingComma: true }); + if (errors.length > 0 || !parsed || typeof parsed !== 'object') { + return null; + } + + const provision = parsed.provision; + if (!provision || typeof provision !== 'object' || Array.isArray(provision)) { + return null; + } + + const files = provision.files && typeof provision.files === 'object' ? provision.files : {}; + return { + source: configPath, + files: { + copy: toStringArray(files.copy), + symlink: toStringArray(files.symlink), + }, + postCreate: toStringArray(provision.postCreate), + }; +} + +// A provisioning pattern must stay inside the repo: no absolute paths, no `..`. +function isUnsafePattern(pattern) { + if (typeof pattern !== 'string' || pattern.trim() === '') return true; + if (path.isAbsolute(pattern)) return true; + return pattern.split(/[\\/]/).some((segment) => segment === '..'); +} + +function segmentToRegExp(segment) { + // Only `*` is special (matches within a single path segment). Everything else + // is literal. + const escaped = segment.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*'); + return new RegExp(`^${escaped}$`); +} + +// Minimal, dependency-free glob: literal segments plus a `*` wildcard within a +// single path segment (covers `.env`, `apps/*/.env`, `packages/*/.env`, +// `node_modules`). Returns repo-relative paths that exist. +function expandGlob(rootDir, pattern, deps = {}) { + if (isUnsafePattern(pattern)) return []; + const readdir = deps.readdir || ((p) => fs.readdirSync(p, { withFileTypes: true })); + const exists = deps.exists || ((p) => fs.existsSync(p)); + + const segments = pattern.split('/').filter((segment) => segment !== '' && segment !== '.'); + let matches = ['']; + + for (const segment of segments) { + const next = []; + const hasWildcard = segment.includes('*'); + for (const rel of matches) { + const absDir = path.join(rootDir, rel); + if (!hasWildcard) { + const candidate = rel ? `${rel}/${segment}` : segment; + if (exists(path.join(rootDir, candidate))) next.push(candidate); + continue; + } + let entries; + try { + entries = readdir(absDir); + } catch { + continue; + } + const re = segmentToRegExp(segment); + for (const entry of entries) { + const name = entry && entry.name ? entry.name : entry; + if (typeof name === 'string' && re.test(name)) { + next.push(rel ? `${rel}/${name}` : name); + } + } + } + matches = next; + } + + return matches.filter((rel) => rel !== ''); +} + +function ensureParentDir(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function safeRealpath(target) { + try { + return fs.realpathSync(target); + } catch { + return null; + } +} + +// True only when `target` (after resolving symlinks) stays inside `baseReal`. +// isUnsafePattern fences the pattern STRING; this fences the real filesystem so +// an in-repo symlink (e.g. `link -> /etc`) can't escape on read or write. +function withinReal(target, baseReal) { + if (!baseReal) return false; + const real = safeRealpath(target); + if (!real) return false; + return real === baseReal || real.startsWith(baseReal + path.sep); +} + +function targetAlreadyPresent(destPath) { + try { + fs.lstatSync(destPath); + return true; + } catch (error) { + if (error && error.code !== 'ENOENT') throw error; + return false; + } +} + +function applyCopy(repoRoot, worktreePath, patterns, deps = {}) { + const operations = []; + const repoReal = safeRealpath(repoRoot); + const worktreeReal = safeRealpath(worktreePath); + for (const pattern of patterns) { + const rels = expandGlob(repoRoot, pattern, deps); + if (rels.length === 0) { + operations.push({ status: 'skipped', file: pattern, note: 'no match in repo root' }); + continue; + } + for (const rel of rels) { + const src = path.join(repoRoot, rel); + const dest = path.join(worktreePath, rel); + try { + // Source must resolve inside the repo (block in-repo symlink escapes). + if (!withinReal(src, repoReal)) { + operations.push({ status: 'skipped', file: rel, note: 'resolves outside repo root' }); + continue; + } + if (fs.statSync(src).isDirectory()) { + operations.push({ status: 'skipped', file: rel, note: 'copy of a directory unsupported; use symlink' }); + continue; + } + if (targetAlreadyPresent(dest)) { + operations.push({ status: 'unchanged', file: rel, note: 'already present in worktree' }); + continue; + } + ensureParentDir(dest); + // Destination parent must resolve inside the worktree (block writes that + // tunnel through a symlink the worktree may already contain). + if (!withinReal(path.dirname(dest), worktreeReal)) { + operations.push({ status: 'skipped', file: rel, note: 'destination escapes worktree' }); + continue; + } + fs.copyFileSync(src, dest); + operations.push({ status: 'copied', file: rel, note: 'copied from repo root' }); + } catch (error) { + operations.push({ status: 'failed', file: rel, note: `copy failed: ${error.message}` }); + } + } + } + return operations; +} + +function applySymlink(repoRoot, worktreePath, patterns, deps = {}) { + const operations = []; + const repoReal = safeRealpath(repoRoot); + const worktreeReal = safeRealpath(worktreePath); + for (const pattern of patterns) { + const rels = expandGlob(repoRoot, pattern, deps); + if (rels.length === 0) { + operations.push({ status: 'skipped', file: pattern, note: 'no match in repo root' }); + continue; + } + for (const rel of rels) { + const src = path.join(repoRoot, rel); + const dest = path.join(worktreePath, rel); + try { + // The symlink target must resolve inside the repo. + if (!withinReal(src, repoReal)) { + operations.push({ status: 'skipped', file: rel, note: 'resolves outside repo root' }); + continue; + } + if (targetAlreadyPresent(dest)) { + operations.push({ status: 'unchanged', file: rel, note: 'already present in worktree' }); + continue; + } + ensureParentDir(dest); + if (!withinReal(path.dirname(dest), worktreeReal)) { + operations.push({ status: 'skipped', file: rel, note: 'destination escapes worktree' }); + continue; + } + fs.symlinkSync(src, dest); + operations.push({ status: 'linked', file: rel, note: `→ ${path.relative(worktreePath, src)}` }); + } catch (error) { + operations.push({ status: 'failed', file: rel, note: `symlink failed: ${error.message}` }); + } + } + } + return operations; +} + +function hooksDisabled() { + const flag = String(process.env.GUARDEX_PROVISION_HOOKS || '').trim().toLowerCase(); + return ['0', 'false', 'no', 'off'].includes(flag); +} + +function applyPostCreate(repoRoot, worktreePath, commands, deps = {}) { + if (commands.length === 0) return []; + if (hooksDisabled()) { + return commands.map((command) => ({ status: 'skipped', file: command, note: 'GUARDEX_PROVISION_HOOKS disabled' })); + } + const run = deps.run || ((cmd, cwd, env) => spawnSync('sh', ['-lc', cmd], { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: POST_CREATE_TIMEOUT_MS, + })); + + const operations = []; + const env = { ...process.env, GUARDEX_WORKTREE: worktreePath, GUARDEX_REPO_ROOT: repoRoot }; + for (const command of commands) { + let result; + try { + result = run(command, worktreePath, env); + } catch (error) { + operations.push({ status: 'failed', file: command, note: `hook error: ${error.message}` }); + continue; + } + const ok = result && (result.status === 0 || result.status === undefined) && !result.error; + operations.push( + ok + ? { status: 'ran', file: command, note: 'post_create hook ok' } + : { + status: 'failed', + file: command, + note: `post_create exited ${result && result.status}${result && result.error ? `: ${result.error.message}` : ''}`, + }, + ); + } + return operations; +} + +// Apply a normalized provision config to a worktree. Order: copy, symlink, then +// postCreate hooks (so hooks see the env/deps already in place). Best-effort. +function applyProvisionConfig(repoRoot, worktreePath, config, deps = {}) { + if (!config) return []; + const operations = []; + operations.push(...applyCopy(repoRoot, worktreePath, config.files.copy, deps)); + operations.push(...applySymlink(repoRoot, worktreePath, config.files.symlink, deps)); + operations.push(...applyPostCreate(repoRoot, worktreePath, config.postCreate, deps)); + return operations; +} + +// Convenience: load + apply for a freshly created worktree. +function provisionFromConfig(repoRoot, worktreePath, deps = {}) { + if (!repoRoot || !worktreePath || repoRoot === worktreePath) return []; + if (!fs.existsSync(worktreePath)) return []; + const config = loadProvisionConfig(repoRoot, deps); + if (!config) return []; + return applyProvisionConfig(repoRoot, worktreePath, config, deps); +} + +module.exports = { + CONFIG_BASENAME, + loadProvisionConfig, + expandGlob, + isUnsafePattern, + applyCopy, + applySymlink, + applyPostCreate, + applyProvisionConfig, + provisionFromConfig, +}; diff --git a/test/provision-config.test.js b/test/provision-config.test.js new file mode 100644 index 0000000..6ef1b38 --- /dev/null +++ b/test/provision-config.test.js @@ -0,0 +1,165 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const provision = require('../src/scaffold/provision-config'); +const { prepareAgentWorktree } = require('../src/scaffold/agent-worktree-prep'); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gx-provision-')); +} +function write(file, body) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, body); +} + +test('loadProvisionConfig parses JSONC with comments and normalizes', () => { + const repo = mkTmp(); + write(path.join(repo, '.guardex.json'), `{ + // worktree provisioning + "provision": { + "files": { "copy": [".env", 5], "symlink": ["node_modules"] }, + "postCreate": ["pnpm install", ""] + } + }`); + const config = provision.loadProvisionConfig(repo); + assert.deepEqual(config.files.copy, ['.env']); // non-string dropped + assert.deepEqual(config.files.symlink, ['node_modules']); + assert.deepEqual(config.postCreate, ['pnpm install']); // empty dropped + fs.rmSync(repo, { recursive: true, force: true }); +}); + +test('loadProvisionConfig returns null for missing, malformed, or block-less config', () => { + const repo = mkTmp(); + assert.equal(provision.loadProvisionConfig(repo), null); // no file + write(path.join(repo, '.guardex.json'), '{ this is not json'); + assert.equal(provision.loadProvisionConfig(repo), null); // malformed + write(path.join(repo, '.guardex.json'), '{ "other": true }'); + assert.equal(provision.loadProvisionConfig(repo), null); // no provision block + fs.rmSync(repo, { recursive: true, force: true }); +}); + +test('isUnsafePattern rejects absolute paths and traversal', () => { + assert.equal(provision.isUnsafePattern('/etc/passwd'), true); + assert.equal(provision.isUnsafePattern('../secrets'), true); + assert.equal(provision.isUnsafePattern('a/../b'), true); + assert.equal(provision.isUnsafePattern(''), true); + assert.equal(provision.isUnsafePattern('apps/*/.env'), false); +}); + +test('expandGlob matches literals and single-segment wildcards', () => { + const repo = mkTmp(); + write(path.join(repo, '.env'), 'X=1'); + write(path.join(repo, 'apps', 'web', '.env'), 'A=1'); + write(path.join(repo, 'apps', 'api', '.env'), 'B=1'); + fs.mkdirSync(path.join(repo, 'node_modules')); + + assert.deepEqual(provision.expandGlob(repo, '.env'), ['.env']); + assert.deepEqual(provision.expandGlob(repo, 'node_modules'), ['node_modules']); + assert.deepEqual(provision.expandGlob(repo, 'apps/*/.env').sort(), ['apps/api/.env', 'apps/web/.env']); + assert.deepEqual(provision.expandGlob(repo, 'missing/file'), []); + assert.deepEqual(provision.expandGlob(repo, '../escape'), []); // unsafe + fs.rmSync(repo, { recursive: true, force: true }); +}); + +test('applyCopy copies files, skips existing and directories', () => { + const repo = mkTmp(); + const wt = mkTmp(); + write(path.join(repo, '.env'), 'SECRET=1'); + write(path.join(repo, 'apps', 'web', '.env'), 'PORT=3000'); + fs.mkdirSync(path.join(wt, 'apps', 'web'), { recursive: true }); + fs.mkdirSync(path.join(repo, 'node_modules')); // a directory -> copy unsupported + + const ops = provision.applyCopy(repo, wt, ['.env', 'apps/*/.env', 'node_modules', 'nope']); + assert.equal(fs.readFileSync(path.join(wt, '.env'), 'utf8'), 'SECRET=1'); + assert.equal(fs.readFileSync(path.join(wt, 'apps', 'web', '.env'), 'utf8'), 'PORT=3000'); + assert.ok(ops.some((o) => o.status === 'copied' && o.file === '.env')); + assert.ok(ops.some((o) => o.status === 'skipped' && /directory/.test(o.note))); + assert.ok(ops.some((o) => o.status === 'skipped' && o.file === 'nope')); + + // Re-running is idempotent: existing files are left unchanged. + const again = provision.applyCopy(repo, wt, ['.env']); + assert.ok(again.some((o) => o.status === 'unchanged')); + fs.rmSync(repo, { recursive: true, force: true }); + fs.rmSync(wt, { recursive: true, force: true }); +}); + +test('applySymlink links files and directories into the worktree', () => { + const repo = mkTmp(); + const wt = mkTmp(); + fs.mkdirSync(path.join(repo, 'node_modules')); + write(path.join(repo, 'node_modules', 'marker'), 'ok'); + + const ops = provision.applySymlink(repo, wt, ['node_modules']); + assert.ok(ops.some((o) => o.status === 'linked' && o.file === 'node_modules')); + assert.ok(fs.lstatSync(path.join(wt, 'node_modules')).isSymbolicLink()); + assert.equal(fs.readFileSync(path.join(wt, 'node_modules', 'marker'), 'utf8'), 'ok'); + fs.rmSync(repo, { recursive: true, force: true }); + fs.rmSync(wt, { recursive: true, force: true }); +}); + +test('applyPostCreate runs hooks with worktree cwd and gx env, honoring the opt-out', () => { + const captured = []; + const deps = { run: (cmd, cwd, env) => { captured.push({ cmd, cwd, env }); return { status: 0 }; } }; + const ops = provision.applyPostCreate('/repo', '/wt', ['echo hi'], deps); + assert.equal(ops[0].status, 'ran'); + assert.equal(captured[0].cmd, 'echo hi'); + assert.equal(captured[0].cwd, '/wt'); + assert.equal(captured[0].env.GUARDEX_WORKTREE, '/wt'); + assert.equal(captured[0].env.GUARDEX_REPO_ROOT, '/repo'); + + const failed = provision.applyPostCreate('/repo', '/wt', ['boom'], { run: () => ({ status: 2 }) }); + assert.equal(failed[0].status, 'failed'); + + const prev = process.env.GUARDEX_PROVISION_HOOKS; + process.env.GUARDEX_PROVISION_HOOKS = '0'; + const skipped = provision.applyPostCreate('/repo', '/wt', ['echo hi'], deps); + assert.equal(skipped[0].status, 'skipped'); + if (prev === undefined) delete process.env.GUARDEX_PROVISION_HOOKS; + else process.env.GUARDEX_PROVISION_HOOKS = prev; +}); + +test('prepareAgentWorktree applies declarative provisioning even without apps/*', () => { + const repo = mkTmp(); + const wt = mkTmp(); + write(path.join(repo, '.env'), 'TOKEN=abc'); + fs.mkdirSync(path.join(repo, '.venv')); + write(path.join(repo, '.guardex.json'), `{ + "provision": { "files": { "copy": [".env"], "symlink": [".venv"] } } + }`); + + const ops = prepareAgentWorktree(repo, wt); + assert.equal(fs.readFileSync(path.join(wt, '.env'), 'utf8'), 'TOKEN=abc'); + assert.ok(fs.lstatSync(path.join(wt, '.venv')).isSymbolicLink()); + assert.ok(ops.some((o) => o.status === 'copied' && o.file === '.env')); + assert.ok(ops.some((o) => o.status === 'linked' && o.file === '.venv')); + fs.rmSync(repo, { recursive: true, force: true }); + fs.rmSync(wt, { recursive: true, force: true }); +}); + +test('an in-repo symlink cannot escape the repo on copy or symlink', () => { + const outside = mkTmp(); + const repo = mkTmp(); + const wt = mkTmp(); + write(path.join(outside, 'secret'), 'TOPSECRET'); + fs.symlinkSync(outside, path.join(repo, 'evil')); // repo commits evil -> /outside + + // copy through the escaping link is refused; nothing lands in the worktree. + const copyOps = provision.applyCopy(repo, wt, ['evil/secret']); + assert.ok(copyOps.every((o) => o.status !== 'copied')); + assert.ok(copyOps.some((o) => /outside repo root/.test(o.note))); + assert.equal(fs.existsSync(path.join(wt, 'evil', 'secret')), false); + + // symlinking the escaping link itself is refused too. + const linkOps = provision.applySymlink(repo, wt, ['evil']); + assert.ok(linkOps.every((o) => o.status !== 'linked')); + assert.equal(fs.existsSync(path.join(wt, 'evil')), false); + + fs.rmSync(outside, { recursive: true, force: true }); + fs.rmSync(repo, { recursive: true, force: true }); + fs.rmSync(wt, { recursive: true, force: true }); +});