@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.
- package/.husky/commit-msg +130 -0
- package/.husky/pre-push +128 -0
- package/README.md +5 -5
- package/agents/codex-adversarial.md +23 -8
- package/commands/codex-review.md +2 -2
- package/dist/audit/append.d.ts +62 -0
- package/dist/audit/append.js +189 -0
- package/dist/audit/codex-event.d.ts +28 -0
- package/dist/audit/codex-event.js +15 -0
- package/dist/cli/doctor.d.ts +60 -1
- package/dist/cli/doctor.js +459 -20
- package/dist/cli/index.js +35 -5
- package/dist/cli/init.d.ts +13 -0
- package/dist/cli/init.js +278 -67
- package/dist/cli/install/canonical.d.ts +43 -0
- package/dist/cli/install/canonical.js +101 -0
- package/dist/cli/install/claude-md.d.ts +48 -0
- package/dist/cli/install/claude-md.js +93 -0
- package/dist/cli/install/commit-msg.d.ts +30 -0
- package/dist/cli/install/commit-msg.js +102 -0
- package/dist/cli/install/copy.d.ts +169 -0
- package/dist/cli/install/copy.js +455 -0
- package/dist/cli/install/fs-safe.d.ts +91 -0
- package/dist/cli/install/fs-safe.js +347 -0
- package/dist/cli/install/manifest-io.d.ts +12 -0
- package/dist/cli/install/manifest-io.js +44 -0
- package/dist/cli/install/manifest-schema.d.ts +83 -0
- package/dist/cli/install/manifest-schema.js +80 -0
- package/dist/cli/install/reagent.d.ts +59 -0
- package/dist/cli/install/reagent.js +160 -0
- package/dist/cli/install/settings-merge.d.ts +91 -0
- package/dist/cli/install/settings-merge.js +239 -0
- package/dist/cli/install/sha.d.ts +9 -0
- package/dist/cli/install/sha.js +21 -0
- package/dist/cli/serve.d.ts +11 -0
- package/dist/cli/serve.js +72 -6
- package/dist/cli/upgrade.d.ts +67 -0
- package/dist/cli/upgrade.js +509 -0
- package/dist/gateway/downstream-pool.d.ts +39 -0
- package/dist/gateway/downstream-pool.js +93 -0
- package/dist/gateway/downstream.d.ts +80 -0
- package/dist/gateway/downstream.js +196 -0
- package/dist/gateway/middleware/audit-types.d.ts +10 -0
- package/dist/gateway/middleware/audit.js +14 -0
- package/dist/gateway/middleware/injection.d.ts +59 -2
- package/dist/gateway/middleware/injection.js +91 -14
- package/dist/gateway/middleware/kill-switch.d.ts +20 -5
- package/dist/gateway/middleware/kill-switch.js +57 -35
- package/dist/gateway/middleware/redact.d.ts +83 -6
- package/dist/gateway/middleware/redact.js +133 -46
- package/dist/gateway/observability/codex-probe.d.ts +110 -0
- package/dist/gateway/observability/codex-probe.js +234 -0
- package/dist/gateway/observability/codex-telemetry.d.ts +93 -0
- package/dist/gateway/observability/codex-telemetry.js +221 -0
- package/dist/gateway/redact-safe/match-timeout.d.ts +83 -0
- package/dist/gateway/redact-safe/match-timeout.js +179 -0
- package/dist/gateway/reviewers/claude-self.d.ts +99 -0
- package/dist/gateway/reviewers/claude-self.js +316 -0
- package/dist/gateway/reviewers/codex.d.ts +64 -0
- package/dist/gateway/reviewers/codex.js +80 -0
- package/dist/gateway/reviewers/select.d.ts +64 -0
- package/dist/gateway/reviewers/select.js +102 -0
- package/dist/gateway/reviewers/types.d.ts +85 -0
- package/dist/gateway/reviewers/types.js +14 -0
- package/dist/gateway/server.d.ts +51 -0
- package/dist/gateway/server.js +258 -0
- package/dist/gateway/session.d.ts +9 -0
- package/dist/gateway/session.js +17 -0
- package/dist/policy/loader.d.ts +59 -0
- package/dist/policy/loader.js +65 -0
- package/dist/policy/profiles.d.ts +80 -0
- package/dist/policy/profiles.js +94 -0
- package/dist/policy/types.d.ts +38 -0
- package/dist/registry/loader.d.ts +98 -0
- package/dist/registry/loader.js +153 -0
- package/dist/registry/types.d.ts +44 -0
- package/dist/registry/types.js +6 -0
- package/dist/scripts/read-policy-field.d.ts +36 -0
- package/dist/scripts/read-policy-field.js +96 -0
- package/hooks/push-review-gate.sh +627 -17
- package/package.json +13 -2
- package/profiles/bst-internal-no-codex.yaml +40 -0
- package/profiles/bst-internal.yaml +23 -0
- package/profiles/client-engagement.yaml +23 -0
- package/profiles/lit-wc.yaml +17 -0
- package/profiles/minimal.yaml +11 -0
- package/profiles/open-source-no-codex.yaml +33 -0
- package/profiles/open-source.yaml +18 -0
- package/scripts/lint-safe-regex.mjs +78 -0
- 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 {};
|