@withtruing/companion 0.0.0-stage → 0.13.0

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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Defense-in-depth for repository reads, on top of the caller's own read-only tool restrictions.
3
+ * Snapshot reads are performed only in a disposable detached worktree at the exact requested
4
+ * commit. Failure to obtain that commit is reported explicitly; the live checkout is never used
5
+ * as a substitute.
6
+ */
7
+ import { execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ import { chmodSync, lstatSync, mkdtempSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { isAbsolute, join, relative } from 'node:path';
12
+ import { managedFetchEnvironment, reportBackgroundFetch } from './managed-repo.js';
13
+ const execFileAsync = promisify(execFile);
14
+ const FULL_COMMIT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
15
+ function chmodFilesReadOnly(dir) {
16
+ for (const entry of readdirSync(dir)) {
17
+ if (entry === '.git')
18
+ continue;
19
+ const path = join(dir, entry);
20
+ const stat = statSync(path);
21
+ if (stat.isDirectory())
22
+ chmodFilesReadOnly(path);
23
+ else
24
+ chmodSync(path, 0o444);
25
+ }
26
+ }
27
+ function chmodFilesWritable(dir) {
28
+ for (const entry of readdirSync(dir)) {
29
+ if (entry === '.git')
30
+ continue;
31
+ const path = join(dir, entry);
32
+ const stat = statSync(path);
33
+ if (stat.isDirectory())
34
+ chmodFilesWritable(path);
35
+ else
36
+ chmodSync(path, 0o644);
37
+ }
38
+ }
39
+ export function gitErrorMessage(error) {
40
+ const candidate = error;
41
+ const raw = typeof candidate.stderr === 'string' && candidate.stderr.trim()
42
+ ? candidate.stderr.trim()
43
+ : typeof candidate.message === 'string'
44
+ ? candidate.message.trim()
45
+ : String(error);
46
+ return raw
47
+ .replace(/(https?:\/\/)[^\s/@]+@/gi, '$1[redacted]@')
48
+ .replace(/\s+/g, ' ')
49
+ .slice(0, 500);
50
+ }
51
+ function rawGitError(error) {
52
+ const candidate = error;
53
+ return typeof candidate.stderr === 'string' && candidate.stderr.trim()
54
+ ? candidate.stderr
55
+ : typeof candidate.message === 'string' ? candidate.message : String(error);
56
+ }
57
+ async function cleanupWorktree(repoPath, dir) {
58
+ try {
59
+ chmodFilesWritable(dir);
60
+ }
61
+ catch {
62
+ // The forced Git removal or filesystem fallback may still succeed.
63
+ }
64
+ try {
65
+ await execFileAsync('git', ['worktree', 'remove', '--force', dir], { cwd: repoPath, windowsHide: true });
66
+ }
67
+ catch {
68
+ rmSync(dir, { recursive: true, force: true });
69
+ try {
70
+ await execFileAsync('git', ['worktree', 'prune'], { cwd: repoPath, windowsHide: true });
71
+ }
72
+ catch {
73
+ // Nothing more can be cleaned locally; the temp directory removal above is authoritative.
74
+ }
75
+ }
76
+ }
77
+ export async function assertSnapshotReadBoundary(worktreePath) {
78
+ const { stdout } = await execFileAsync('git', ['ls-files', '-z', '--stage'], { cwd: worktreePath, maxBuffer: 64 * 1024 * 1024, windowsHide: true });
79
+ const root = realpathSync(worktreePath);
80
+ for (const record of stdout.split('\0')) {
81
+ if (!record)
82
+ continue;
83
+ const tab = record.indexOf('\t');
84
+ if (tab === -1)
85
+ throw new Error('Malformed tracked-path record.');
86
+ const metadata = record.slice(0, tab);
87
+ const path = record.slice(tab + 1);
88
+ const mode = metadata.slice(0, metadata.indexOf(' '));
89
+ if (mode === '120000' || mode === '160000') {
90
+ throw new Error('Snapshot contains an external link or unbound submodule.');
91
+ }
92
+ const absolutePath = join(worktreePath, path);
93
+ if (lstatSync(absolutePath).isSymbolicLink()) {
94
+ throw new Error('Snapshot contains an external link or unbound submodule.');
95
+ }
96
+ const resolved = realpathSync(absolutePath);
97
+ const fromRoot = relative(root, resolved);
98
+ if (fromRoot === '..' || fromRoot.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(fromRoot)) {
99
+ throw new Error('Snapshot path escapes its captured worktree.');
100
+ }
101
+ }
102
+ }
103
+ function unavailable(fetchOk, reason) {
104
+ const normalizedReason = reason.replace(/\s+/g, ' ').trim().slice(0, 700);
105
+ return {
106
+ target: 'requested_commit',
107
+ obtainedBy: 'unavailable',
108
+ ...(fetchOk === undefined ? {} : { fetchOk }),
109
+ reason: normalizedReason,
110
+ sourceIdentity: `unavailable:${normalizedReason}`,
111
+ cleanup: async () => { },
112
+ };
113
+ }
114
+ /** Read HEAD from inside a worktree and reject any identity mismatch. Exported so the invariant can
115
+ * be exercised directly against real worktrees in the package's integration tests. */
116
+ export async function readAndVerifyWorktreeCommit(worktreePath, requestedCommit) {
117
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--verify', 'HEAD^{commit}'], { cwd: worktreePath, windowsHide: true });
118
+ const observedCommit = stdout.trim();
119
+ if (observedCommit !== requestedCommit) {
120
+ throw new Error(`Sandbox commit mismatch: requested ${requestedCommit}, read back ${observedCommit}`);
121
+ }
122
+ return observedCommit;
123
+ }
124
+ /** Resolve an exact full commit object locally, fetching configured remotes only when it is absent,
125
+ * and create a detached worktree. A missing/unobtainable commit is data, not an exception;
126
+ * malformed targets and identity mismatches are caller/invariant bugs and throw. */
127
+ export async function createSandbox(repoPath, requestedCommit) {
128
+ if (!FULL_COMMIT_ID.test(requestedCommit)) {
129
+ throw new Error(`Sandbox target must be a full lowercase commit id, got ${JSON.stringify(requestedCommit)}`);
130
+ }
131
+ let resolvedCommit;
132
+ try {
133
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--verify', `${requestedCommit}^{commit}`], { cwd: repoPath, windowsHide: true });
134
+ resolvedCommit = stdout.trim();
135
+ }
136
+ catch {
137
+ // The requested object may be available from a configured remote.
138
+ }
139
+ let fetchOk;
140
+ let fetchFailure = '';
141
+ if (resolvedCommit === undefined) {
142
+ try {
143
+ const { stdout: remotes } = await execFileAsync('git', ['remote'], { cwd: repoPath, windowsHide: true });
144
+ if (!remotes.trim()) {
145
+ fetchFailure = 'no configured Git remote';
146
+ }
147
+ else {
148
+ fetchOk = false;
149
+ await execFileAsync('git', ['fetch', '--all'], { cwd: repoPath, env: await managedFetchEnvironment(repoPath), windowsHide: true });
150
+ fetchOk = true;
151
+ reportBackgroundFetch(repoPath, { ok: true });
152
+ }
153
+ }
154
+ catch (error) {
155
+ fetchFailure = gitErrorMessage(error);
156
+ if (fetchOk === false)
157
+ reportBackgroundFetch(repoPath, { ok: false, stderr: rawGitError(error) });
158
+ }
159
+ try {
160
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--verify', `${requestedCommit}^{commit}`], { cwd: repoPath, windowsHide: true });
161
+ resolvedCommit = stdout.trim();
162
+ }
163
+ catch (error) {
164
+ const fetchContext = fetchOk === true
165
+ ? 'fetch succeeded'
166
+ : fetchOk === false
167
+ ? `fetch failed (${fetchFailure})`
168
+ : `fetch was not attempted (${fetchFailure})`;
169
+ return unavailable(fetchOk, `commit ${requestedCommit} could not be resolved after ${fetchContext}: ${gitErrorMessage(error)}`);
170
+ }
171
+ }
172
+ if (resolvedCommit !== requestedCommit) {
173
+ throw new Error(`Resolved commit mismatch: requested ${requestedCommit}, resolved ${resolvedCommit}`);
174
+ }
175
+ const dir = mkdtempSync(join(tmpdir(), 'pc-companion-'));
176
+ try {
177
+ await execFileAsync('git', ['worktree', 'add', '--detach', dir, resolvedCommit], { cwd: repoPath, windowsHide: true });
178
+ }
179
+ catch (error) {
180
+ await cleanupWorktree(repoPath, dir);
181
+ return unavailable(fetchOk, `worktree creation failed for ${requestedCommit}: ${gitErrorMessage(error)}`);
182
+ }
183
+ let observedCommit;
184
+ try {
185
+ observedCommit = await readAndVerifyWorktreeCommit(dir, requestedCommit);
186
+ }
187
+ catch (error) {
188
+ await cleanupWorktree(repoPath, dir);
189
+ throw error;
190
+ }
191
+ try {
192
+ chmodFilesReadOnly(dir);
193
+ }
194
+ catch (error) {
195
+ console.warn(` [sandbox] couldn't mark ${dir} read-only (${gitErrorMessage(error)}) — continuing anyway.`);
196
+ }
197
+ return {
198
+ target: 'requested_commit',
199
+ obtainedBy: 'sandbox_worktree',
200
+ path: dir,
201
+ resolvedCommit: observedCommit,
202
+ sourceIdentity: `commit:${observedCommit}`,
203
+ ...(fetchOk === undefined ? {} : { fetchOk }),
204
+ cleanup: () => cleanupWorktree(repoPath, dir),
205
+ };
206
+ }
207
+ export async function createLegacyHeadSandbox(repoPath) {
208
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--verify', 'HEAD^{commit}'], { cwd: repoPath, windowsHide: true });
209
+ const headCommit = stdout.trim();
210
+ const sandbox = await createSandbox(repoPath, headCommit);
211
+ if (sandbox.obtainedBy === 'unavailable') {
212
+ throw new Error(`Could not create legacy local-HEAD sandbox: ${sandbox.reason}`);
213
+ }
214
+ return { ...sandbox, target: 'legacy_local_head' };
215
+ }