@bookedsolid/rea 0.1.0 → 0.2.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.
Files changed (90) hide show
  1. package/.husky/commit-msg +130 -0
  2. package/.husky/pre-push +128 -0
  3. package/README.md +5 -5
  4. package/agents/codex-adversarial.md +23 -8
  5. package/commands/codex-review.md +2 -2
  6. package/dist/audit/append.d.ts +62 -0
  7. package/dist/audit/append.js +189 -0
  8. package/dist/audit/codex-event.d.ts +28 -0
  9. package/dist/audit/codex-event.js +15 -0
  10. package/dist/cli/doctor.d.ts +60 -1
  11. package/dist/cli/doctor.js +459 -20
  12. package/dist/cli/index.js +35 -5
  13. package/dist/cli/init.d.ts +13 -0
  14. package/dist/cli/init.js +278 -67
  15. package/dist/cli/install/canonical.d.ts +43 -0
  16. package/dist/cli/install/canonical.js +101 -0
  17. package/dist/cli/install/claude-md.d.ts +48 -0
  18. package/dist/cli/install/claude-md.js +93 -0
  19. package/dist/cli/install/commit-msg.d.ts +30 -0
  20. package/dist/cli/install/commit-msg.js +102 -0
  21. package/dist/cli/install/copy.d.ts +169 -0
  22. package/dist/cli/install/copy.js +455 -0
  23. package/dist/cli/install/fs-safe.d.ts +91 -0
  24. package/dist/cli/install/fs-safe.js +347 -0
  25. package/dist/cli/install/manifest-io.d.ts +12 -0
  26. package/dist/cli/install/manifest-io.js +44 -0
  27. package/dist/cli/install/manifest-schema.d.ts +83 -0
  28. package/dist/cli/install/manifest-schema.js +80 -0
  29. package/dist/cli/install/reagent.d.ts +59 -0
  30. package/dist/cli/install/reagent.js +160 -0
  31. package/dist/cli/install/settings-merge.d.ts +91 -0
  32. package/dist/cli/install/settings-merge.js +239 -0
  33. package/dist/cli/install/sha.d.ts +9 -0
  34. package/dist/cli/install/sha.js +21 -0
  35. package/dist/cli/serve.d.ts +11 -0
  36. package/dist/cli/serve.js +72 -6
  37. package/dist/cli/upgrade.d.ts +67 -0
  38. package/dist/cli/upgrade.js +509 -0
  39. package/dist/gateway/downstream-pool.d.ts +39 -0
  40. package/dist/gateway/downstream-pool.js +93 -0
  41. package/dist/gateway/downstream.d.ts +80 -0
  42. package/dist/gateway/downstream.js +196 -0
  43. package/dist/gateway/middleware/audit-types.d.ts +10 -0
  44. package/dist/gateway/middleware/audit.js +14 -0
  45. package/dist/gateway/middleware/injection.d.ts +59 -2
  46. package/dist/gateway/middleware/injection.js +91 -14
  47. package/dist/gateway/middleware/kill-switch.d.ts +20 -5
  48. package/dist/gateway/middleware/kill-switch.js +57 -35
  49. package/dist/gateway/middleware/redact.d.ts +83 -6
  50. package/dist/gateway/middleware/redact.js +133 -46
  51. package/dist/gateway/observability/codex-probe.d.ts +110 -0
  52. package/dist/gateway/observability/codex-probe.js +234 -0
  53. package/dist/gateway/observability/codex-telemetry.d.ts +93 -0
  54. package/dist/gateway/observability/codex-telemetry.js +221 -0
  55. package/dist/gateway/redact-safe/match-timeout.d.ts +83 -0
  56. package/dist/gateway/redact-safe/match-timeout.js +179 -0
  57. package/dist/gateway/reviewers/claude-self.d.ts +99 -0
  58. package/dist/gateway/reviewers/claude-self.js +316 -0
  59. package/dist/gateway/reviewers/codex.d.ts +64 -0
  60. package/dist/gateway/reviewers/codex.js +80 -0
  61. package/dist/gateway/reviewers/select.d.ts +64 -0
  62. package/dist/gateway/reviewers/select.js +102 -0
  63. package/dist/gateway/reviewers/types.d.ts +85 -0
  64. package/dist/gateway/reviewers/types.js +14 -0
  65. package/dist/gateway/server.d.ts +51 -0
  66. package/dist/gateway/server.js +258 -0
  67. package/dist/gateway/session.d.ts +9 -0
  68. package/dist/gateway/session.js +17 -0
  69. package/dist/policy/loader.d.ts +59 -0
  70. package/dist/policy/loader.js +65 -0
  71. package/dist/policy/profiles.d.ts +80 -0
  72. package/dist/policy/profiles.js +94 -0
  73. package/dist/policy/types.d.ts +38 -0
  74. package/dist/registry/loader.d.ts +98 -0
  75. package/dist/registry/loader.js +153 -0
  76. package/dist/registry/types.d.ts +44 -0
  77. package/dist/registry/types.js +6 -0
  78. package/dist/scripts/read-policy-field.d.ts +36 -0
  79. package/dist/scripts/read-policy-field.js +96 -0
  80. package/hooks/push-review-gate.sh +627 -17
  81. package/package.json +13 -2
  82. package/profiles/bst-internal-no-codex.yaml +40 -0
  83. package/profiles/bst-internal.yaml +23 -0
  84. package/profiles/client-engagement.yaml +23 -0
  85. package/profiles/lit-wc.yaml +17 -0
  86. package/profiles/minimal.yaml +11 -0
  87. package/profiles/open-source-no-codex.yaml +33 -0
  88. package/profiles/open-source.yaml +18 -0
  89. package/scripts/lint-safe-regex.mjs +78 -0
  90. package/scripts/postinstall.mjs +131 -0
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Write or update a managed fragment inside the consumer's `CLAUDE.md`.
3
+ *
4
+ * The fragment is delimited by HTML-style comment markers so it's invisible in
5
+ * rendered Markdown but machine-parseable:
6
+ *
7
+ * <!-- rea:managed:start v=1 -->
8
+ * ...
9
+ * <!-- rea:managed:end -->
10
+ *
11
+ * On first install the block is appended at the end of `CLAUDE.md` (or the file
12
+ * is created). On subsequent runs, everything between the markers is replaced.
13
+ * Content outside the markers is NEVER touched — the consumer owns the rest
14
+ * of `CLAUDE.md`.
15
+ *
16
+ * Fragment content:
17
+ * - Policy path
18
+ * - Active profile
19
+ * - Autonomy level and ceiling
20
+ * - Blocked-paths count (not the paths themselves — those are in the YAML)
21
+ * - A reminder that `/codex-review` is required on protected-path changes
22
+ */
23
+ export interface ClaudeMdFragmentInput {
24
+ policyPath: string;
25
+ profile: string;
26
+ autonomyLevel: string;
27
+ maxAutonomyLevel: string;
28
+ blockedPathsCount: number;
29
+ blockAiAttribution: boolean;
30
+ }
31
+ export declare const START_MARKER = "<!-- rea:managed:start v=1 -->";
32
+ export declare const END_MARKER = "<!-- rea:managed:end -->";
33
+ /**
34
+ * Return the current managed-fragment substring (from START_MARKER to
35
+ * END_MARKER inclusive) if present in `content`, else `null`. Shared with
36
+ * `rea upgrade` so both sides use identical boundary semantics.
37
+ */
38
+ export declare function extractFragment(content: string): string | null;
39
+ export declare function buildFragment(input: ClaudeMdFragmentInput): string;
40
+ /**
41
+ * Write or replace the managed fragment inside `${targetDir}/CLAUDE.md`.
42
+ * Returns the absolute path written and whether the file existed before.
43
+ */
44
+ export declare function writeClaudeMdFragment(targetDir: string, input: ClaudeMdFragmentInput): Promise<{
45
+ path: string;
46
+ existed: boolean;
47
+ replaced: boolean;
48
+ }>;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Write or update a managed fragment inside the consumer's `CLAUDE.md`.
3
+ *
4
+ * The fragment is delimited by HTML-style comment markers so it's invisible in
5
+ * rendered Markdown but machine-parseable:
6
+ *
7
+ * <!-- rea:managed:start v=1 -->
8
+ * ...
9
+ * <!-- rea:managed:end -->
10
+ *
11
+ * On first install the block is appended at the end of `CLAUDE.md` (or the file
12
+ * is created). On subsequent runs, everything between the markers is replaced.
13
+ * Content outside the markers is NEVER touched — the consumer owns the rest
14
+ * of `CLAUDE.md`.
15
+ *
16
+ * Fragment content:
17
+ * - Policy path
18
+ * - Active profile
19
+ * - Autonomy level and ceiling
20
+ * - Blocked-paths count (not the paths themselves — those are in the YAML)
21
+ * - A reminder that `/codex-review` is required on protected-path changes
22
+ */
23
+ import fs from 'node:fs';
24
+ import fsPromises from 'node:fs/promises';
25
+ import path from 'node:path';
26
+ export const START_MARKER = '<!-- rea:managed:start v=1 -->';
27
+ export const END_MARKER = '<!-- rea:managed:end -->';
28
+ /**
29
+ * Return the current managed-fragment substring (from START_MARKER to
30
+ * END_MARKER inclusive) if present in `content`, else `null`. Shared with
31
+ * `rea upgrade` so both sides use identical boundary semantics.
32
+ */
33
+ export function extractFragment(content) {
34
+ const s = content.indexOf(START_MARKER);
35
+ const e = content.indexOf(END_MARKER);
36
+ if (s === -1 || e === -1 || e < s)
37
+ return null;
38
+ return content.slice(s, e + END_MARKER.length);
39
+ }
40
+ export function buildFragment(input) {
41
+ const lines = [
42
+ START_MARKER,
43
+ '',
44
+ '## REA Governance (managed — do not edit this block)',
45
+ '',
46
+ `- **Policy**: \`${input.policyPath}\` — profile \`${input.profile}\``,
47
+ `- **Autonomy**: \`${input.autonomyLevel}\` (ceiling \`${input.maxAutonomyLevel}\`)`,
48
+ `- **Blocked paths**: ${input.blockedPathsCount} entries — see the policy file`,
49
+ `- **block_ai_attribution**: \`${input.blockAiAttribution}\` (enforced by commit-msg hook)`,
50
+ '',
51
+ 'Protected-path changes (`src/gateway/middleware/`, `hooks/`, `src/policy/`,',
52
+ '`.github/workflows/`) require a `/codex-review` audit entry before push.',
53
+ '',
54
+ 'Run `rea doctor` to verify the install. Run `rea check` to inspect state.',
55
+ '',
56
+ END_MARKER,
57
+ ];
58
+ return lines.join('\n');
59
+ }
60
+ /**
61
+ * Write or replace the managed fragment inside `${targetDir}/CLAUDE.md`.
62
+ * Returns the absolute path written and whether the file existed before.
63
+ */
64
+ export async function writeClaudeMdFragment(targetDir, input) {
65
+ const claudeMdPath = path.join(targetDir, 'CLAUDE.md');
66
+ const fragment = buildFragment(input);
67
+ if (!fs.existsSync(claudeMdPath)) {
68
+ const seed = [
69
+ `# CLAUDE.md — ${path.basename(targetDir)}`,
70
+ '',
71
+ 'Project-level instructions for AI agents in this repository.',
72
+ '',
73
+ fragment,
74
+ '',
75
+ ].join('\n');
76
+ await fsPromises.writeFile(claudeMdPath, seed, 'utf8');
77
+ return { path: claudeMdPath, existed: false, replaced: false };
78
+ }
79
+ const existing = await fsPromises.readFile(claudeMdPath, 'utf8');
80
+ const startIdx = existing.indexOf(START_MARKER);
81
+ const endIdx = existing.indexOf(END_MARKER);
82
+ if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
83
+ // No markers — append the fragment to the end, preserving existing content.
84
+ const trailer = existing.endsWith('\n') ? '' : '\n';
85
+ const next = `${existing}${trailer}\n${fragment}\n`;
86
+ await fsPromises.writeFile(claudeMdPath, next, 'utf8');
87
+ return { path: claudeMdPath, existed: true, replaced: false };
88
+ }
89
+ const endLineIdx = endIdx + END_MARKER.length;
90
+ const next = existing.slice(0, startIdx) + fragment + existing.slice(endLineIdx);
91
+ await fsPromises.writeFile(claudeMdPath, next, 'utf8');
92
+ return { path: claudeMdPath, existed: true, replaced: true };
93
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Install the commit-msg hook that enforces `block_ai_attribution`.
3
+ *
4
+ * Strategy: belt-and-suspenders.
5
+ *
6
+ * 1. Always write `.git/hooks/commit-msg` (the "belt") — every git commit
7
+ * in this repo will hit it, no matter what frontend the consumer uses.
8
+ * 2. If `.husky/` exists (husky is installed), also write `.husky/commit-msg`
9
+ * (the "suspenders") — this is what husky-based projects see in their
10
+ * source tree and will share with collaborators.
11
+ *
12
+ * The hook itself is sourced from the packaged `.husky/commit-msg` so there is
13
+ * exactly one version of truth. `package.json#files[]` includes `.husky/` so
14
+ * the file ships to npm.
15
+ *
16
+ * The hook is a no-op when `block_ai_attribution` is not set to `true` in
17
+ * `.rea/policy.yaml`, so it is safe to install unconditionally — see the
18
+ * header of `.husky/commit-msg` for the opt-in check.
19
+ */
20
+ export interface CommitMsgInstallResult {
21
+ gitHook?: string;
22
+ huskyHook?: string;
23
+ warnings: string[];
24
+ }
25
+ /**
26
+ * Install the commit-msg hook into the consumer project at `targetDir`.
27
+ * Requires `targetDir/.git` to exist (not a bare clone). The husky copy is
28
+ * best-effort and only runs if `.husky/` is already a directory.
29
+ */
30
+ export declare function installCommitMsgHook(targetDir: string): Promise<CommitMsgInstallResult>;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Install the commit-msg hook that enforces `block_ai_attribution`.
3
+ *
4
+ * Strategy: belt-and-suspenders.
5
+ *
6
+ * 1. Always write `.git/hooks/commit-msg` (the "belt") — every git commit
7
+ * in this repo will hit it, no matter what frontend the consumer uses.
8
+ * 2. If `.husky/` exists (husky is installed), also write `.husky/commit-msg`
9
+ * (the "suspenders") — this is what husky-based projects see in their
10
+ * source tree and will share with collaborators.
11
+ *
12
+ * The hook itself is sourced from the packaged `.husky/commit-msg` so there is
13
+ * exactly one version of truth. `package.json#files[]` includes `.husky/` so
14
+ * the file ships to npm.
15
+ *
16
+ * The hook is a no-op when `block_ai_attribution` is not set to `true` in
17
+ * `.rea/policy.yaml`, so it is safe to install unconditionally — see the
18
+ * header of `.husky/commit-msg` for the opt-in check.
19
+ */
20
+ import { execFile } from 'node:child_process';
21
+ import fs from 'node:fs';
22
+ import fsPromises from 'node:fs/promises';
23
+ import path from 'node:path';
24
+ import { promisify } from 'node:util';
25
+ import { PKG_ROOT, warn } from '../utils.js';
26
+ const execFileAsync = promisify(execFile);
27
+ /**
28
+ * Read `core.hooksPath` via `git config --get`. This is the only correct way
29
+ * to consult git config: regex-matching `.git/config` (finding #9) is
30
+ * section-blind and matches `hooksPath = …` inside `[worktree]`, `[alias]`,
31
+ * `[includeIf]`, or conditional include files — any of which would aim the
32
+ * installer at the wrong directory.
33
+ *
34
+ * We use `execFile` (not `exec`) so there is no shell interpolation of the
35
+ * target directory. Returns `null` if the key is unset (git exits non-zero),
36
+ * or if git itself isn't on PATH.
37
+ */
38
+ async function readHooksPathFromGit(targetDir) {
39
+ try {
40
+ const { stdout } = await execFileAsync('git', ['-C', targetDir, 'config', '--get', 'core.hooksPath'], { encoding: 'utf8' });
41
+ const trimmed = stdout.trim();
42
+ return trimmed.length > 0 ? trimmed : null;
43
+ }
44
+ catch {
45
+ // Non-zero exit (key not set) or git missing from PATH. Either way we fall
46
+ // back to the default `.git/hooks/`.
47
+ return null;
48
+ }
49
+ }
50
+ function sourceHookPath() {
51
+ return path.join(PKG_ROOT, '.husky', 'commit-msg');
52
+ }
53
+ async function writeExecutable(src, dst) {
54
+ await fsPromises.mkdir(path.dirname(dst), { recursive: true });
55
+ await fsPromises.copyFile(src, dst);
56
+ await fsPromises.chmod(dst, 0o755);
57
+ }
58
+ /**
59
+ * Install the commit-msg hook into the consumer project at `targetDir`.
60
+ * Requires `targetDir/.git` to exist (not a bare clone). The husky copy is
61
+ * best-effort and only runs if `.husky/` is already a directory.
62
+ */
63
+ export async function installCommitMsgHook(targetDir) {
64
+ const result = { warnings: [] };
65
+ const src = sourceHookPath();
66
+ if (!fs.existsSync(src)) {
67
+ result.warnings.push(`packaged commit-msg hook missing at ${src}`);
68
+ return result;
69
+ }
70
+ const gitDir = path.join(targetDir, '.git');
71
+ if (!fs.existsSync(gitDir)) {
72
+ result.warnings.push('.git/ not found — skipping commit-msg install (not a git repo?)');
73
+ return result;
74
+ }
75
+ // Determine the true hooks directory; respect core.hooksPath when set.
76
+ // We defer to `git config --get` rather than regex-matching `.git/config`
77
+ // so that section-scoped keys (`[worktree]`, `[alias]`, `[includeIf]`,
78
+ // `[include]` files) are resolved the way git itself resolves them. Any
79
+ // other approach (finding #9) is section-blind.
80
+ let hooksDir = path.join(gitDir, 'hooks');
81
+ const configuredHooksPath = await readHooksPathFromGit(targetDir);
82
+ if (configuredHooksPath !== null) {
83
+ hooksDir = path.isAbsolute(configuredHooksPath)
84
+ ? configuredHooksPath
85
+ : path.join(targetDir, configuredHooksPath);
86
+ result.warnings.push(`git core.hooksPath is set — installing to ${hooksDir}`);
87
+ }
88
+ const gitHookPath = path.join(hooksDir, 'commit-msg');
89
+ await writeExecutable(src, gitHookPath);
90
+ result.gitHook = gitHookPath;
91
+ const huskyDir = path.join(targetDir, '.husky');
92
+ if (fs.existsSync(huskyDir)) {
93
+ const huskyHookPath = path.join(huskyDir, 'commit-msg');
94
+ await writeExecutable(src, huskyHookPath);
95
+ result.huskyHook = huskyHookPath;
96
+ }
97
+ else {
98
+ // Not a warning — husky is optional. Just note the state for logging.
99
+ warn('no .husky/ directory — skipped husky commit-msg copy (git-hooks copy is sufficient)');
100
+ }
101
+ return result;
102
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Copy hooks, commands, and agents from the installed package into a consumer's
3
+ * `.claude/` directory. This is the core of what makes `rea init` a real
4
+ * installer rather than a policy-file writer.
5
+ *
6
+ * Conflict policy:
7
+ *
8
+ * - `--force` (boolean): overwrite unconditionally. Reserved for power users
9
+ * who have intentionally modified their local `.claude/` and know they want
10
+ * fresh-from-package versions back.
11
+ * - `--yes` (boolean): non-interactive. Skips existing files — NEVER silently
12
+ * replaces a consumer-modified hook. This is the safe default for CI.
13
+ * - Default (interactive): prompts per conflict via `@clack/prompts`.
14
+ *
15
+ * Hook scripts are chmod'd to 0o755 so the shell hooks the harness fires can
16
+ * actually execute on a fresh clone.
17
+ *
18
+ * ## Symlink safety (finding #5)
19
+ *
20
+ * A prior malicious PR could leave a symlink at a destination path (e.g.
21
+ * `.claude/hooks/secret-scanner.sh` → `/etc/shadow`). Node's `copyFile` and
22
+ * `chmod` follow symlinks, so a subsequent `rea init --force` would overwrite
23
+ * the link target and chmod it 0o755. We defend in multiple layers:
24
+ *
25
+ * 1. Resolve the install root with `realpath` once per run.
26
+ * 2. Before any write, `lstat` the destination and REFUSE (hard error, named
27
+ * file + link target) if it is a symlink. The presence of a symlink is a
28
+ * signal worth surfacing to the operator — we do not silently rewrite it.
29
+ * 3. For every destination path, resolve it and assert containment within the
30
+ * resolved root. Anything escaping the root is refused.
31
+ *
32
+ * On overwrite we use `openSync(O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW)` and
33
+ * write the bytes ourselves — `O_EXCL` makes the create race-safe, and
34
+ * `O_NOFOLLOW` refuses any symlink that sneaks in between the lstat and the
35
+ * write. On fresh creates we use the same flags for the same reason.
36
+ *
37
+ * ## Parent-directory TOCTOU (finding R2-4)
38
+ *
39
+ * `assertSafeDirectory` validates a path *string*. A concurrent attacker with
40
+ * write access under the install tree could swap `.claude/hooks` for a symlink
41
+ * in the window between validation and the subsequent `copyFile`/`unlink`. To
42
+ * close this window:
43
+ *
44
+ * 1. After validating the install root, snapshot the realpath of every
45
+ * ancestor directory between the destination and the root.
46
+ * 2. Immediately before every mutation (`unlink`, then the file write), we
47
+ * re-realpath the same ancestors and refuse if any changed. This closes
48
+ * the practical exploit window to sub-millisecond.
49
+ * 3. `O_NOFOLLOW` on the write call catches a symlink that slips in at the
50
+ * leaf between the re-check and the `open` syscall.
51
+ *
52
+ * Residual risk: a race that wins between the re-realpath loop and the `open`
53
+ * syscall on the leaf still exists, but the `O_NOFOLLOW | O_EXCL` open will
54
+ * refuse any symlink that lands in that micro-window. Fully closing the
55
+ * ancestor-swap race would require dirfd-relative APIs (`openat`) which Node's
56
+ * core `fs` module does not expose. Documented; not a code-execution primitive.
57
+ *
58
+ * ## Ancestor baseline integrity (finding R3-1)
59
+ *
60
+ * `O_NOFOLLOW` only protects the leaf component of the open syscall — an
61
+ * attacker who swaps an intermediate directory for a symlink pointing outside
62
+ * the install root between `assertSafeDirectory` and the per-file `copyOne`
63
+ * call would otherwise get the snapshot to record the attacker's state as
64
+ * baseline. `verifyAncestorsUnchanged` would then pass (the symlink is
65
+ * stable) and the write would land wherever the symlink points.
66
+ *
67
+ * `snapshotAncestors` closes that primitive by (a) refusing any ancestor that
68
+ * is itself a symlink (`lstat`), (b) re-asserting containment via `realpath`
69
+ * at every level, and (c) requiring the walk to terminate at `resolvedRoot`.
70
+ * Those checks run before any write-side syscall; an escape attempt surfaces
71
+ * as `UnsafeInstallPathError` with `kind: 'symlink'` or `'escape'`.
72
+ */
73
+ export interface CopyOptions {
74
+ force: boolean;
75
+ yes: boolean;
76
+ }
77
+ export interface CopyResult {
78
+ copied: string[];
79
+ skipped: string[];
80
+ overwritten: string[];
81
+ }
82
+ /**
83
+ * Thrown when a destination path is a symlink, escapes the install root, or a
84
+ * previously-validated ancestor directory changed shape between validation and
85
+ * the write (finding R2-4). Kept as a named class so callers (and tests) can
86
+ * match the shape without scraping the message.
87
+ */
88
+ export declare class UnsafeInstallPathError extends Error {
89
+ readonly kind: 'symlink' | 'escape' | 'ancestor-changed';
90
+ readonly targetPath: string;
91
+ readonly linkTarget?: string;
92
+ constructor(kind: 'symlink' | 'escape' | 'ancestor-changed', targetPath: string, linkTarget: string | undefined, message: string);
93
+ }
94
+ /**
95
+ * Snapshot the realpath of every ancestor directory between `dstPath` and
96
+ * `resolvedRoot` (inclusive of the root, exclusive of the leaf). The resulting
97
+ * map — `absolute ancestor path → realpath at snapshot time` — is later
98
+ * re-validated by {@link verifyAncestorsUnchanged} immediately before each
99
+ * mutation. If any entry has changed, the tree was swapped and we refuse.
100
+ *
101
+ * We deliberately skip the leaf: the leaf's safety is handled by the
102
+ * `lstat` check in `assertSafeDestination` and by `O_NOFOLLOW` on the open.
103
+ *
104
+ * ## Defense against ancestor escape (finding R3-1)
105
+ *
106
+ * The snapshot itself must not be allowed to record an attacker-controlled
107
+ * baseline. Without the checks below, an attacker who swaps an intermediate
108
+ * directory for a symlink to `/tmp/decoy` between `assertSafeDirectory` and
109
+ * `copyOne` could get the snapshot to record `.claude/hooks → /tmp/decoy` as
110
+ * the trusted state — {@link verifyAncestorsUnchanged} would then pass, and
111
+ * `writeFileExclusiveNoFollow` would land the payload outside the install
112
+ * root (O_NOFOLLOW only guards the leaf, not ancestor components).
113
+ *
114
+ * To close that primitive, for every ancestor we:
115
+ *
116
+ * 1. `lstat` the component. If it is a symbolic link, refuse — ancestor
117
+ * symlinks inside the install tree are never legitimate for us to walk
118
+ * through, regardless of where they point.
119
+ * 2. `realpath` the component and assert containment within `resolvedRoot`.
120
+ * Anything pointing outside is an attempted escape.
121
+ * 3. Require the walk to terminate at `resolvedRoot`. If the cursor reaches
122
+ * the filesystem root without passing through `resolvedRoot`, the
123
+ * destination was never under the install root to begin with — that
124
+ * means `assertSafeDestination` was bypassed and we refuse loudly.
125
+ */
126
+ declare function snapshotAncestors(resolvedRoot: string, dstPath: string): Promise<Map<string, string>>;
127
+ /**
128
+ * Re-realpath every ancestor captured in `snapshot` and throw
129
+ * {@link UnsafeInstallPathError} if any resolution has changed (an intermediate
130
+ * directory was replaced with a symlink, renamed, etc.) or disappeared.
131
+ */
132
+ declare function verifyAncestorsUnchanged(snapshot: Map<string, string>): Promise<void>;
133
+ /**
134
+ * Write `srcPath`'s bytes to `dstPath` using `openSync(O_WRONLY | O_CREAT |
135
+ * O_EXCL | O_NOFOLLOW)`. This is the race-safe replacement for `copyFile` —
136
+ *
137
+ * - `O_EXCL`: the open fails with EEXIST if anything appears at the leaf
138
+ * between our pre-check and this call, including a symlink or regular file.
139
+ * - `O_NOFOLLOW`: the open fails with ELOOP if the leaf itself is a symlink
140
+ * that somehow bypassed EXCL (belt-and-suspenders; on most platforms EXCL
141
+ * alone is sufficient).
142
+ *
143
+ * Reads the source via the fs/promises API so we inherit standard error shapes.
144
+ * Source-side read follows symlinks, which is fine: `srcPath` is inside PKG_ROOT
145
+ * and the source tree is trusted.
146
+ */
147
+ declare function writeFileExclusiveNoFollow(srcPath: string, dstPath: string): Promise<void>;
148
+ /**
149
+ * Copy hooks/commands/agents from the package root into `${targetDir}/.claude/`.
150
+ *
151
+ * Caller is responsible for ensuring `targetDir` is a real directory — this
152
+ * function creates `.claude/` and the three subdirectories if missing.
153
+ *
154
+ * Throws {@link UnsafeInstallPathError} if any destination is a symlink or
155
+ * would escape the resolved install root. The caller should surface this as a
156
+ * named failure and exit non-zero; do not wrap-and-swallow.
157
+ */
158
+ export declare function copyArtifacts(targetDir: string, options: CopyOptions): Promise<CopyResult>;
159
+ /**
160
+ * Internal helpers exposed for unit tests only. Not part of the public API —
161
+ * do not import from outside `./copy.test.ts`. Grouped under `__internal` so
162
+ * consumers can grep and stay away.
163
+ */
164
+ export declare const __internal: {
165
+ readonly snapshotAncestors: typeof snapshotAncestors;
166
+ readonly verifyAncestorsUnchanged: typeof verifyAncestorsUnchanged;
167
+ readonly writeFileExclusiveNoFollow: typeof writeFileExclusiveNoFollow;
168
+ };
169
+ export {};