@stratusagent/permissions 0.11.8 → 0.11.10

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,753 @@
1
+ import { readFile, realpath } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const READERS = {
4
+ cat: { flags: ['-n', '-b', '-s', '-v', '-e', '-t', '-u', '-A', '-E', '-T', '--number', '--number-nonblank', '--squeeze-blank', '--show-all', '--show-ends', '--show-tabs'] },
5
+ ls: {
6
+ flags: [
7
+ '-l', '-a', '-A', '-h', '-R', '-t', '-r', '-S', '-1', '-d', '-F', '-G', '-i', '-s', '-p', '-C', '-c', '-u', '-U', '-m', '-n', '-o', '-x', '-g', '-k', '-T',
8
+ '--all', '--almost-all', '--human-readable', '--recursive', '--reverse', '--directory', '--classify', '--color', '--no-color', '--inode', '--size',
9
+ ],
10
+ readsCwd: true,
11
+ },
12
+ head: { valueFlags: ['-n', '-c', '--lines', '--bytes'], flags: ['-q', '-v', '--quiet', '--silent', '--verbose'], numeric: true },
13
+ tail: { valueFlags: ['-n', '-c', '--lines', '--bytes'], flags: ['-q', '-v', '-r', '--quiet', '--silent', '--verbose'], numeric: true },
14
+ wc: { flags: ['-l', '-w', '-c', '-m', '-L', '--lines', '--words', '--bytes', '--chars', '--max-line-length'] },
15
+ grep: {
16
+ // `--color` is not a value flag: its argument is optional and only
17
+ // ever attached (`--color=always`), so `grep --color root /etc/passwd`
18
+ // has `root` as its pattern.
19
+ valueFlags: ['-e', '-m', '-A', '-B', '-C', '--regexp', '--max-count', '--after-context', '--before-context', '--context', '--include', '--exclude', '--exclude-dir'],
20
+ flags: [
21
+ '--color', '--colour',
22
+ '-i', '-v', '-n', '-l', '-L', '-c', '-o', '-q', '-s', '-r', '-w', '-x', '-E', '-F', '-G', '-h', '-H', '-I', '-a', '-b', '-Z', '-z',
23
+ '--ignore-case', '--invert-match', '--line-number', '--files-with-matches', '--files-without-match', '--count', '--only-matching',
24
+ '--quiet', '--silent', '--no-messages', '--recursive', '--word-regexp', '--line-regexp', '--extended-regexp', '--fixed-strings',
25
+ '--basic-regexp', '--no-filename', '--with-filename', '--binary-files', '--text', '--byte-offset', '--null', '--null-data',
26
+ ],
27
+ pattern: true,
28
+ patternFlags: ['-e', '--regexp'],
29
+ // Without -r, grep with no file reads stdin, which is the pipeline rule's
30
+ // business, not this one's.
31
+ readsCwd: (flags) => flags.has('-r') || flags.has('--recursive'),
32
+ },
33
+ rg: {
34
+ valueFlags: [
35
+ '-e', '-g', '-t', '-T', '-m', '-A', '-B', '-C', '-M', '--regexp', '--glob', '--iglob', '--type', '--type-not', '--max-count',
36
+ '--after-context', '--before-context', '--context', '--max-columns', '--max-depth', '--max-filesize', '--sort', '--sortr', '--color', '--colors',
37
+ ],
38
+ flags: [
39
+ '-i', '-s', '-S', '-v', '-n', '-N', '-l', '-c', '-o', '-w', '-x', '-F', '-u', '-U', '-P', '-H', '-I', '-0', '-a',
40
+ '--ignore-case', '--case-sensitive', '--smart-case', '--invert-match', '--line-number', '--no-line-number', '--files',
41
+ '--files-with-matches', '--files-without-match', '--count', '--count-matches', '--only-matching', '--word-regexp', '--line-regexp',
42
+ '--fixed-strings', '--hidden', '--no-ignore', '--unrestricted', '--multiline', '--pcre2', '--json', '--no-heading', '--heading',
43
+ '--vimgrep', '--with-filename', '--no-filename', '--null', '--text', '--stats', '--trim', '--no-messages', '--no-config',
44
+ '--no-ignore-parent', '--no-ignore-global', '--no-ignore-dot', '--no-ignore-vcs', '--no-ignore-files', '--no-ignore-exclude',
45
+ ],
46
+ pattern: true,
47
+ patternFlags: ['-e', '--regexp'],
48
+ noPatternFlags: ['--files'],
49
+ readsCwd: true,
50
+ },
51
+ };
52
+ /**
53
+ * `find`'s expression primaries that only test or print. Everything that
54
+ * runs a program (`-exec`, `-ok`), deletes, writes a file (`-fprint`), or
55
+ * follows links out (`-L`, `-follow`) is missing, so it asks.
56
+ */
57
+ const FIND_TESTS_WITH_VALUE = new Set([
58
+ '-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex', '-type', '-maxdepth', '-mindepth',
59
+ '-size', '-mtime', '-mmin', '-atime', '-amin', '-ctime', '-cmin', '-perm', '-user', '-group', '-links',
60
+ ]);
61
+ const FIND_TESTS = new Set(['-print', '-print0', '-empty', '-prune', '-not', '!', '-o', '-or', '-a', '-and', '-true', '-false', '-depth', '-xdev', '-readable', '-writable', '-executable']);
62
+ /** Primaries whose value is a path, which must be inside too. */
63
+ const FIND_PATH_TESTS = new Set(['-newer', '-anewer', '-cnewer', '-samefile']);
64
+ const isNumericFlag = (token) => /^-\d+$/.test(token);
65
+ /** Short flags may be bundled (`-rn`); each letter must be allowed on its own. */
66
+ const flagAllowed = (reader, token) => {
67
+ if (reader.flags.includes(token)) {
68
+ return true;
69
+ }
70
+ if (token.startsWith('--')) {
71
+ const [name] = token.split('=');
72
+ return name !== undefined && (reader.flags.includes(name) || (reader.valueFlags ?? []).includes(name));
73
+ }
74
+ if (token.length > 2 && !token.includes('=')) {
75
+ return [...token.slice(1)].every((letter) => reader.flags.includes(`-${letter}`));
76
+ }
77
+ return false;
78
+ };
79
+ /**
80
+ * The paths a read would touch, or undefined when the command isn't one
81
+ * this rule understands. `.` stands for the working directory.
82
+ */
83
+ const readPaths = (base, args) => {
84
+ if (base === 'find') {
85
+ const paths = [];
86
+ let index = 0;
87
+ while (index < args.length && !args[index].startsWith('-') && args[index] !== '!') {
88
+ paths.push(args[index]);
89
+ index += 1;
90
+ }
91
+ for (; index < args.length; index += 1) {
92
+ const token = args[index];
93
+ if (FIND_TESTS.has(token)) {
94
+ continue;
95
+ }
96
+ if (FIND_TESTS_WITH_VALUE.has(token) || FIND_PATH_TESTS.has(token)) {
97
+ const value = args[index + 1];
98
+ if (value === undefined) {
99
+ return undefined;
100
+ }
101
+ if (FIND_PATH_TESTS.has(token)) {
102
+ paths.push(value);
103
+ }
104
+ index += 1;
105
+ continue;
106
+ }
107
+ return undefined;
108
+ }
109
+ return paths.length === 0 ? ['.'] : paths;
110
+ }
111
+ const reader = READERS[base];
112
+ if (!reader) {
113
+ return undefined;
114
+ }
115
+ const positionals = [];
116
+ // Flags after the first operand mean two things: GNU tools read them as
117
+ // flags, BSD ones (and GNU under POSIXLY_CORRECT) as more files. So they
118
+ // must pass as flags and, as files, land inside: `head inside -n
119
+ // /etc/passwd` reads /etc/passwd on a Mac.
120
+ const operandsToo = [];
121
+ const seen = new Set();
122
+ let patternGiven = false;
123
+ let endOfFlags = false;
124
+ for (let index = 0; index < args.length; index += 1) {
125
+ const token = args[index];
126
+ if (!endOfFlags && token === '--') {
127
+ // After an operand, `--` may itself be a file to a tool that stopped
128
+ // reading options there (BSD, or POSIXLY_CORRECT).
129
+ if (positionals.length > 0) {
130
+ operandsToo.push(token);
131
+ }
132
+ endOfFlags = true;
133
+ continue;
134
+ }
135
+ if (!endOfFlags && token.startsWith('-') && token !== '-') {
136
+ if (positionals.length > 0) {
137
+ operandsToo.push(token);
138
+ }
139
+ if (reader.numeric && isNumericFlag(token)) {
140
+ continue;
141
+ }
142
+ if ((reader.valueFlags ?? []).includes(token)) {
143
+ if (args[index + 1] === undefined) {
144
+ return undefined;
145
+ }
146
+ if (positionals.length > 0) {
147
+ operandsToo.push(args[index + 1]);
148
+ }
149
+ if ((reader.patternFlags ?? []).includes(token)) {
150
+ patternGiven = true;
151
+ }
152
+ seen.add(token);
153
+ index += 1;
154
+ continue;
155
+ }
156
+ if (!flagAllowed(reader, token)) {
157
+ return undefined;
158
+ }
159
+ const [name] = token.split('=');
160
+ if ((reader.patternFlags ?? []).includes(name ?? '')) {
161
+ patternGiven = true;
162
+ }
163
+ seen.add(name ?? token);
164
+ if (!token.startsWith('--')) {
165
+ for (const letter of token.slice(1)) {
166
+ seen.add(`-${letter}`);
167
+ }
168
+ }
169
+ continue;
170
+ }
171
+ // A lone `-` is a positional like any other: stdin as a file, or, in
172
+ // the pattern position, the pattern. Dropping it would shift a real
173
+ // path into the pattern slot (`grep - /etc/passwd`).
174
+ positionals.push(token);
175
+ }
176
+ // ripgrep reads ignore files it was never pointed at: above the directory
177
+ // it searches, the user's global ignore, and a linked worktree's
178
+ // `.git/info/exclude` in a git directory that can be anywhere. Only
179
+ // `--no-ignore` (or `-u`) turns all of them off at once.
180
+ if (base === 'rg' && !seen.has('--no-ignore') && !seen.has('-u') && !seen.has('--unrestricted')) {
181
+ return undefined;
182
+ }
183
+ const noPattern = (reader.noPatternFlags ?? []).some((flag) => seen.has(flag));
184
+ const paths = [...(reader.pattern && !patternGiven && !noPattern ? positionals.slice(1) : positionals), ...operandsToo];
185
+ if (reader.pattern && !patternGiven && !noPattern && positionals.length === 0) {
186
+ return undefined;
187
+ }
188
+ if (paths.length === 0) {
189
+ const readsCwd = typeof reader.readsCwd === 'function' ? reader.readsCwd(seen) : reader.readsCwd === true;
190
+ return readsCwd ? ['.'] : [];
191
+ }
192
+ return paths;
193
+ };
194
+ /** Whether `child` is `parent` or inside it, both already resolved. */
195
+ const within = (parent, child) => {
196
+ const relative = path.relative(parent, child);
197
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
198
+ };
199
+ /**
200
+ * Where a path really is: the nearest ancestor that exists, resolved through
201
+ * its symlinks, with whatever doesn't exist yet appended. A path that
202
+ * doesn't exist reads nothing, but `ls missing/../../..` still has to be
203
+ * judged by where it points.
204
+ */
205
+ const resolveReal = async (target) => {
206
+ let existing = target;
207
+ const missing = [];
208
+ for (;;) {
209
+ try {
210
+ return path.join(await realpath(existing), ...missing.reverse());
211
+ }
212
+ catch {
213
+ const parent = path.dirname(existing);
214
+ if (parent === existing) {
215
+ return undefined;
216
+ }
217
+ missing.push(path.basename(existing));
218
+ existing = parent;
219
+ }
220
+ }
221
+ };
222
+ /**
223
+ * Where `target` lands when the kernel walks it from `base`: each component
224
+ * in turn, symlinks followed as they are reached. Normalizing first, the
225
+ * way `path.resolve` does, would cancel `link/..` lexically, while the
226
+ * kernel follows `link` and then takes the parent of wherever it pointed.
227
+ */
228
+ const walk = async (base, target) => {
229
+ let current = path.isAbsolute(target) ? await resolveReal(path.parse(target).root) : await resolveReal(base);
230
+ if (current === undefined) {
231
+ return undefined;
232
+ }
233
+ for (const segment of target.split(path.sep)) {
234
+ if (segment === '' || segment === '.') {
235
+ continue;
236
+ }
237
+ if (segment === '..') {
238
+ current = path.dirname(current);
239
+ continue;
240
+ }
241
+ const next = path.join(current, segment);
242
+ // Missing from here on: nothing below it exists to follow, so the rest
243
+ // is lexical, and a read of it reads nothing.
244
+ current = await realpath(next).catch(() => next);
245
+ }
246
+ return current;
247
+ };
248
+ /**
249
+ * Whether one command (not a pipeline) only reads, and only inside the
250
+ * workspace. `cwd` is where the shell will run it.
251
+ */
252
+ export const readsInsideWorkspace = async (analysis, cwd, workspace) => {
253
+ if (analysis.disqualifiedBy || analysis.base === undefined || analysis.pipeline) {
254
+ return false;
255
+ }
256
+ // A glob, `~`, `$`, or brace becomes paths this parser never saw. A
257
+ // workspace can hold a symlink a glob would expand to, and `cat *` would
258
+ // follow it out.
259
+ if (analysis.expands?.some((expands) => expands)) {
260
+ return false;
261
+ }
262
+ // Inside double quotes `$` and backticks still expand, and the tokenizer
263
+ // marks only what expands unquoted: `cat "$HOME/.ssh/id_rsa"` would read
264
+ // as a literal path under the workspace. Any `$` or backtick, however
265
+ // quoted, means the shell may read something this parser didn't. A
266
+ // backslash no longer does: unquoted it marks the token as expanding
267
+ // (refused above), and quoted it is read as the shell reads it, so one
268
+ // left in a token is text — the `\|` of a grep pattern.
269
+ if (analysis.tokens.some((token) => /[$`]/.test(token))) {
270
+ return false;
271
+ }
272
+ const paths = readPaths(analysis.base, analysis.tokens.slice(1));
273
+ if (paths === undefined) {
274
+ return false;
275
+ }
276
+ const root = await resolveReal(workspace);
277
+ const here = await resolveReal(cwd);
278
+ if (root === undefined || here === undefined || !within(root, here)) {
279
+ return false;
280
+ }
281
+ for (const target of paths) {
282
+ const resolved = await walk(here, target);
283
+ if (resolved === undefined || !within(root, resolved)) {
284
+ return false;
285
+ }
286
+ }
287
+ return true;
288
+ };
289
+ const LOG_FLAGS = [
290
+ '--oneline', '--graph', '--decorate', '--no-decorate', '--all', '--stat', '--shortstat', '--numstat', '--name-only', '--name-status',
291
+ '-p', '--patch', '--no-patch', '-s', '--reverse', '--first-parent', '--no-merges', '--merges', '--abbrev-commit', '--follow', '--color',
292
+ '--no-color', '-w', '--ignore-all-space', '--date-order', '--topo-order', '--left-right', '--cherry-pick', '--boundary',
293
+ ];
294
+ const LOG_VALUES = ['-n', '--max-count', '--format', '--pretty', '--since', '--after', '--until', '--before', '--author', '--committer', '--grep', '--date', '-S', '-G', '-U', '--unified', '--skip', '--abbrev'];
295
+ const DIFF_FLAGS = [
296
+ '--stat', '--shortstat', '--numstat', '--name-only', '--name-status', '--cached', '--staged', '-p', '--patch', '--word-diff', '--color',
297
+ '--no-color', '--check', '-w', '--ignore-all-space', '-b', '--ignore-space-change', '--exit-code', '--quiet', '-R', '--merge-base', '--summary',
298
+ ];
299
+ const GIT_SUBCOMMANDS = {
300
+ status: { flags: ['-s', '--short', '-b', '--branch', '--porcelain', '--long', '-u', '--untracked-files', '--ignored', '-z', '-v', '--verbose'] },
301
+ log: { flags: LOG_FLAGS, values: LOG_VALUES, numeric: true },
302
+ shortlog: { flags: ['-s', '--summary', '-n', '--numbered', '-e', '--email'], numeric: true },
303
+ diff: { flags: DIFF_FLAGS, values: ['-U', '--unified', '--diff-filter', '--stat-width'] },
304
+ show: { flags: [...LOG_FLAGS, ...DIFF_FLAGS], values: [...LOG_VALUES, '--diff-filter'], numeric: true },
305
+ blame: { flags: ['-w', '-s', '-e', '--porcelain', '-l', '--line-porcelain', '-M', '-C'], values: ['-L'] },
306
+ 'rev-parse': { flags: ['--abbrev-ref', '--short', '--show-toplevel', '--git-dir', '--git-common-dir', '--verify', '--is-inside-work-tree', '--symbolic-full-name', '-q', '--quiet'] },
307
+ 'ls-files': { flags: ['-m', '-o', '-d', '-s', '-c', '-u', '--others', '--modified', '--deleted', '--cached', '--stage', '--unmerged', '--exclude-standard', '-z', '--full-name'] },
308
+ describe: { flags: ['--tags', '--always', '--dirty', '--long', '--all'], values: ['--abbrev', '--match', '--exclude'] },
309
+ add: { flags: ['-A', '--all', '-u', '--update', '-N', '--intent-to-add', '-v', '--verbose', '-n', '--dry-run', '--renormalize'] },
310
+ commit: { flags: ['-a', '--all', '--amend', '--no-edit', '-s', '--signoff', '-q', '--quiet', '--allow-empty', '-v', '--verbose', '--no-verify-signatures'], values: ['-m', '--message', '--fixup', '--squash', '--author', '--date'] },
311
+ switch: { flags: ['--detach', '-d', '--track', '-t', '--no-track', '--guess', '--no-guess', '-q', '--quiet'], values: ['-c', '--create'] },
312
+ checkout: { flags: ['--detach', '--track', '-t', '--no-track', '-q', '--quiet'], values: ['-b'] },
313
+ restore: { flags: ['--staged', '-S', '--worktree', '-W', '-q', '--quiet'], values: ['--source', '-s'] },
314
+ branch: {
315
+ flags: [
316
+ '-a', '--all', '-r', '--remotes', '-l', '--list', '-v', '-vv', '--verbose', '--show-current', '--merged', '--no-merged',
317
+ '--unset-upstream', '--track', '-t', '--no-track', '-m', '--move', '--color', '--no-color', '--no-column',
318
+ ],
319
+ values: ['--set-upstream-to', '-u', '--sort', '--format', '--contains', '--no-contains', '--points-at'],
320
+ },
321
+ // Not `remove`: git matches its argument against registered worktrees by
322
+ // unique suffix, so `remove wt` can delete one outside the workspace.
323
+ worktree: { flags: ['--detach', '--track', '--no-track', '-q', '--quiet', '--porcelain', '-v', '--verbose', '--checkout', '--no-checkout'], values: ['-b'], actions: ['add', 'list'] },
324
+ stash: { flags: ['-u', '--include-untracked', '-k', '--keep-index', '--no-keep-index', '-q', '--quiet', '--index', '--staged'], values: ['-m', '--message'], actions: ['push', 'pop', 'apply', 'list', 'show', 'save'] },
325
+ merge: { flags: ['--no-ff', '--ff-only', '--ff', '--squash', '--no-squash', '--no-edit', '--abort', '--continue', '--quit', '-q', '--quiet', '--no-commit', '--commit', '--stat', '--no-stat', '--autostash'], values: ['-m', '--message'] },
326
+ rebase: { flags: ['--abort', '--continue', '--skip', '--quit', '--autosquash', '--no-autosquash', '--autostash', '--no-autostash', '-q', '--quiet', '--root', '--keep-empty', '--update-refs'], values: ['--onto'] },
327
+ 'cherry-pick': { flags: ['--abort', '--continue', '--skip', '--quit', '-n', '--no-commit', '-x', '--ff', '--allow-empty'], values: ['-m', '--mainline'] },
328
+ reset: { flags: ['--soft', '--mixed', '--keep', '-q', '--quiet', '-N', '--intent-to-add'] },
329
+ fetch: { flags: ['--all', '--tags', '--no-tags', '-q', '--quiet', '-v', '--verbose', '--unshallow', '--dry-run', '--atomic', '--no-recurse-submodules'], values: ['--depth', '--deepen', '--shallow-since'], refspecs: true },
330
+ pull: { flags: ['--rebase', '--no-rebase', '--ff-only', '--ff', '--no-ff', '-q', '--quiet', '-v', '--verbose', '--autostash', '--no-autostash', '--no-edit'], refspecs: true },
331
+ tag: { flags: ['-l', '--list', '-a', '--annotate', '-n'], values: ['-m', '--message', '--sort', '--contains', '--points-at'] },
332
+ mv: { flags: ['-k', '-n', '--dry-run', '-v', '--verbose'] },
333
+ rm: { flags: ['--cached', '-r', '-q', '--quiet', '-n', '--dry-run'] },
334
+ };
335
+ /** The flags of one subcommand, or undefined when one isn't on its list. */
336
+ const gitPositionals = (spec, rest) => {
337
+ const flags = spec.flags ?? [];
338
+ const values = spec.values ?? [];
339
+ const positionals = [];
340
+ for (let index = 0; index < rest.length; index += 1) {
341
+ const token = rest[index];
342
+ if (token === '--') {
343
+ positionals.push(...rest.slice(index + 1));
344
+ break;
345
+ }
346
+ if (!token.startsWith('-') || token === '-') {
347
+ positionals.push(token);
348
+ continue;
349
+ }
350
+ if (spec.numeric && /^-\d+$/.test(token)) {
351
+ continue;
352
+ }
353
+ if (token.startsWith('--')) {
354
+ const [name] = token.split('=');
355
+ if (token.includes('=')) {
356
+ if (!values.includes(name ?? '') && !flags.includes(name ?? '')) {
357
+ return undefined;
358
+ }
359
+ continue;
360
+ }
361
+ if (values.includes(token)) {
362
+ if (rest[index + 1] === undefined) {
363
+ return undefined;
364
+ }
365
+ index += 1;
366
+ continue;
367
+ }
368
+ if (!flags.includes(token)) {
369
+ return undefined;
370
+ }
371
+ continue;
372
+ }
373
+ if (flags.includes(token)) {
374
+ continue;
375
+ }
376
+ if (values.includes(token)) {
377
+ if (rest[index + 1] === undefined) {
378
+ return undefined;
379
+ }
380
+ index += 1;
381
+ continue;
382
+ }
383
+ // An attached value (`-mmsg`, `-U3`), or a bundle of flags (`-sb`)
384
+ // whose last letter may take the next token as its value (`-am msg`).
385
+ if (values.includes(token.slice(0, 2))) {
386
+ continue;
387
+ }
388
+ const letters = [...token.slice(1)];
389
+ const last = `-${letters.at(-1)}`;
390
+ if (!letters.slice(0, -1).every((letter) => flags.includes(`-${letter}`))) {
391
+ return undefined;
392
+ }
393
+ if (values.includes(last)) {
394
+ if (rest[index + 1] === undefined) {
395
+ return undefined;
396
+ }
397
+ index += 1;
398
+ continue;
399
+ }
400
+ if (!flags.includes(last)) {
401
+ return undefined;
402
+ }
403
+ }
404
+ return positionals;
405
+ };
406
+ /**
407
+ * The repository's git directories, found the way git finds them: the
408
+ * nearest `.git` at or above `start` (but not above the workspace), which in
409
+ * a linked worktree is a file naming its own git directory, whose
410
+ * `commondir` names the one holding config and shared refs. Both resolved
411
+ * through symlinks, so a `.git` file or link pointing out of the workspace
412
+ * is seen for what it is.
413
+ */
414
+ const gitDirsOf = async (start, root) => {
415
+ for (let dir = start; within(root, dir); dir = path.dirname(dir)) {
416
+ const dotGit = path.join(dir, '.git');
417
+ const pointer = await readFile(dotGit, 'utf8').catch(() => undefined);
418
+ let gitDir;
419
+ if (pointer !== undefined) {
420
+ const match = /^gitdir: (.+)$/m.exec(pointer);
421
+ gitDir = match ? await resolveReal(path.resolve(dir, match[1].trim())) : undefined;
422
+ if (gitDir === undefined) {
423
+ return undefined;
424
+ }
425
+ }
426
+ else if (await readFile(path.join(dotGit, 'HEAD'), 'utf8').then(() => true, () => false)) {
427
+ gitDir = await resolveReal(dotGit);
428
+ }
429
+ if (gitDir !== undefined) {
430
+ const common = await readFile(path.join(gitDir, 'commondir'), 'utf8').catch(() => undefined);
431
+ const commonDir = common === undefined ? gitDir : await resolveReal(path.resolve(gitDir, common.trim()));
432
+ return commonDir === undefined ? undefined : { gitDir, commonDir };
433
+ }
434
+ if (dir === root) {
435
+ return undefined;
436
+ }
437
+ }
438
+ return undefined;
439
+ };
440
+ /**
441
+ * The remotes a repository configures, or undefined when its config can't
442
+ * be trusted to say. `for: 'fetch'` needs a `url`; git ignores `pushurl`
443
+ * when fetching and reads the name as a path instead.
444
+ */
445
+ const configuredRemotes = async (commonDir, use = 'push') => {
446
+ const config = await readFile(path.join(commonDir, 'config'), 'utf8').catch(() => '');
447
+ // An include pulls config from a file this check never reads, and a
448
+ // backslash-continued line folds what looks like a header into a value:
449
+ // either way this line-based reading isn't git's, so nothing counts.
450
+ if (/^\s*\[include(?:If)?\b/im.test(config) || /\\\s*$/m.test(config)) {
451
+ return undefined;
452
+ }
453
+ // A remote only counts with a URL: without one git reads the name as a
454
+ // path (`[remote ".."]` would push to the parent directory). `.` and `..`
455
+ // are paths whatever the config says.
456
+ const sections = config.split(/^(?=\s*\[)/m);
457
+ return sections
458
+ // Section names are case-insensitive to git; subsection names aren't.
459
+ .map((section) => ({ name: /^\s*\[remote "([^"]+)"\]/i.exec(section)?.[1], section }))
460
+ .filter((entry) => entry.name !== undefined && entry.name !== '.' && entry.name !== '..'
461
+ && (use === 'fetch' ? /^\s*url\s*=\s*\S/im : /^\s*(?:push)?url\s*=\s*\S/im).test(entry.section))
462
+ .map((entry) => entry.name);
463
+ };
464
+ /**
465
+ * The remote a bare `git fetch`/`git pull` uses: the checked-out branch's
466
+ * `branch.<name>.remote`, or `origin`. Undefined when HEAD isn't a branch or
467
+ * the config includes a file this can't read.
468
+ */
469
+ const upstreamRemote = async (dirs) => {
470
+ const head = await readFile(path.join(dirs.gitDir, 'HEAD'), 'utf8').catch(() => '');
471
+ const branch = /^ref: refs\/heads\/(.+)$/m.exec(head)?.[1]?.trim();
472
+ if (branch === undefined) {
473
+ return undefined;
474
+ }
475
+ const config = await readFile(path.join(dirs.commonDir, 'config'), 'utf8').catch(() => '');
476
+ if (/^\s*\[include(?:If)?\b/im.test(config)) {
477
+ return undefined;
478
+ }
479
+ for (const section of config.split(/^(?=\s*\[)/m)) {
480
+ const header = /^\s*\[branch "([^"]+)"\]/i.exec(section);
481
+ if (header?.[1] === branch) {
482
+ const remote = /^\s*remote\s*=\s*(.+?)\s*$/im.exec(section.slice(header[0].length))?.[1];
483
+ if (remote !== undefined) {
484
+ return remote;
485
+ }
486
+ }
487
+ }
488
+ return 'origin';
489
+ };
490
+ export const gitInsideWorkspace = async (analysis, cwd, workspace) => {
491
+ if (analysis.disqualifiedBy || analysis.base !== 'git' || analysis.pipeline) {
492
+ return false;
493
+ }
494
+ // What the shell would expand, quoted or not, with one allowance: `~` only
495
+ // expands at the start of a word, so `HEAD~1` is the revision it says.
496
+ if (analysis.tokens.some((token) => token.startsWith('~') || /[*?[\]{}$`\\]/.test(token))) {
497
+ return false;
498
+ }
499
+ const args = analysis.tokens.slice(1);
500
+ // Global options: only where it runs, and whether it pages. Everything
501
+ // else before the subcommand (`-c`, `--git-dir`, `--exec-path`) asks.
502
+ // Each -C is relative to the one before it, as git applies them.
503
+ const directories = [];
504
+ let index = 0;
505
+ for (; index < args.length; index += 1) {
506
+ const token = args[index];
507
+ if (token === '-C' && args[index + 1] !== undefined) {
508
+ directories.push(args[index + 1]);
509
+ index += 1;
510
+ continue;
511
+ }
512
+ if (token === '--no-pager') {
513
+ continue;
514
+ }
515
+ break;
516
+ }
517
+ const subcommand = args[index];
518
+ const spec = subcommand === undefined ? undefined : GIT_SUBCOMMANDS[subcommand];
519
+ if (subcommand === undefined || spec === undefined) {
520
+ return false;
521
+ }
522
+ const positionals = gitPositionals(spec, args.slice(index + 1));
523
+ if (positionals === undefined) {
524
+ return false;
525
+ }
526
+ if (spec.actions && positionals.length > 0 && !spec.actions.includes(positionals[0])) {
527
+ return false;
528
+ }
529
+ if (spec.refspecs && positionals.some((token) => token.startsWith('+') || token.includes(':'))) {
530
+ // `+src:dst` is a forced update without a flag, and `:dst` a delete.
531
+ return false;
532
+ }
533
+ // An editor run from config is a program nobody approved, and with no
534
+ // terminal it can't be anything else: a commit or an annotated tag says
535
+ // its message on the command line.
536
+ const said = (names) => args.slice(index + 1).some((token) => names.some((name) => token === name
537
+ || token.startsWith(`${name}=`)
538
+ // `-mmsg`, or a short bundle ending in it (`-am msg`).
539
+ || (name.length === 2 && !token.startsWith('--') && token.startsWith('-') && token.slice(1).includes(name.slice(1)))));
540
+ if (subcommand === 'commit' && !said(['-m', '--message', '--no-edit', '--fixup'])) {
541
+ return false;
542
+ }
543
+ if (subcommand === 'tag' && said(['-a', '--annotate']) && !said(['-m', '--message'])) {
544
+ return false;
545
+ }
546
+ const root = await resolveReal(workspace);
547
+ const here = await resolveReal(cwd);
548
+ if (root === undefined || here === undefined || !within(root, here)) {
549
+ return false;
550
+ }
551
+ let repoDir = here;
552
+ for (const directory of directories) {
553
+ repoDir = repoDir === undefined ? undefined : await walk(repoDir, directory);
554
+ }
555
+ if (repoDir === undefined || !within(root, repoDir)) {
556
+ return false;
557
+ }
558
+ // The repository git will actually use, not just the directory it runs
559
+ // in: a `.git` file or link can name one anywhere on the host.
560
+ const dirs = await gitDirsOf(repoDir, root);
561
+ if (dirs === undefined || !within(root, dirs.gitDir) || !within(root, dirs.commonDir)) {
562
+ return false;
563
+ }
564
+ // Fetch and pull name a repository first, and a path there reads one from
565
+ // anywhere on the host: only a remote the repository configures.
566
+ // With no repository named, the checked-out branch's `branch.<x>.remote`
567
+ // decides (`origin` when unset), and that must be a configured remote too.
568
+ if (subcommand === 'fetch' || subcommand === 'pull') {
569
+ const remotes = await configuredRemotes(dirs.commonDir, 'fetch');
570
+ const named = positionals.length > 0 ? positionals[0] : await upstreamRemote(dirs);
571
+ if (remotes === undefined || named === undefined || !remotes.includes(named)) {
572
+ return false;
573
+ }
574
+ }
575
+ // `git mv` writes its destination, and a symlinked directory on the way
576
+ // can put it outside: every path it names must land inside.
577
+ if (subcommand === 'mv') {
578
+ for (const target of positionals) {
579
+ const landed = await walk(repoDir, target);
580
+ if (landed === undefined || !within(root, landed)) {
581
+ return false;
582
+ }
583
+ }
584
+ }
585
+ // A worktree is a directory git creates or removes: it must land inside
586
+ // too. `git worktree add [-b <branch>] <path> [<commit>]`.
587
+ if (subcommand === 'worktree') {
588
+ const [action, target] = positionals;
589
+ if (action === 'list') {
590
+ return true;
591
+ }
592
+ if (target === undefined) {
593
+ return false;
594
+ }
595
+ const landed = await walk(repoDir, target);
596
+ return landed !== undefined && within(root, landed) && landed !== root;
597
+ }
598
+ return true;
599
+ };
600
+ /**
601
+ * Whether any config git reads for this repository sets a push mapping, in
602
+ * any of its spellings (`[remote "origin"]` or the older `[remote.origin]`),
603
+ * including a worktree's own `config.worktree`. Any `push =` key at all
604
+ * counts: the only `push` key git has is `remote.<name>.push`, and reading
605
+ * every form exactly is a parser this check doesn't need to be.
606
+ */
607
+ const anyPushMapping = async (gitDir, commonDir) => {
608
+ for (const file of [path.join(commonDir, 'config'), path.join(gitDir, 'config.worktree'), path.join(commonDir, 'config.worktree')]) {
609
+ const config = await readFile(file, 'utf8').catch(() => '');
610
+ // `push.followTags` publishes reachable annotated tags with the branch.
611
+ // Any `followTags` key (a bare one is true) publishes tags with the
612
+ // branch, and submodule recursion pushes repositories nobody checked.
613
+ if (/^\s*push\s*=/im.test(config) || /^\s*followtags\b/im.test(config) || /^\s*recursesubmodules\b/im.test(config)
614
+ || /^\s*recurse\b/im.test(config) || /^\s*\[include(?:If)?\b/im.test(config)) {
615
+ return true;
616
+ }
617
+ }
618
+ return false;
619
+ };
620
+ /** Whether a ref exists, loose or packed. */
621
+ const refExists = async (commonDir, ref) => {
622
+ if (await readFile(path.join(commonDir, ref), 'utf8').then(() => true, () => false)) {
623
+ return true;
624
+ }
625
+ const packed = await readFile(path.join(commonDir, 'packed-refs'), 'utf8').catch(() => '');
626
+ return packed.split('\n').some((line) => line.endsWith(` ${ref}`));
627
+ };
628
+ /**
629
+ * Publishing the agent's own work: `git push <remote> <refspec>` of a
630
+ * branch whose name starts with one of the agent's prefixes (`nova/` by
631
+ * default), to a remote configured in the repository. Nothing lands from
632
+ * here without review, which is what makes this safe to run unattended.
633
+ *
634
+ * Judged by what git will actually update, so only forms whose destination
635
+ * is certain from the command and the repository: an explicit remote and
636
+ * one refspec. A bare `git push` or `git push origin` asks, because
637
+ * `remote.<name>.push` and `push.default` decide its destination. `HEAD` is
638
+ * the checked-out branch, pushed to the same name. An unqualified name is
639
+ * a branch only if it is a local branch and not also a tag.
640
+ *
641
+ * Refused: force in any spelling (`--force*`, `-f`, a `+` refspec), deletes
642
+ * (`--delete`, `:branch`), `--all`, `--mirror`, `--tags`, any other flag, a
643
+ * remote that isn't configured (a URL, a path, or a directory that happens
644
+ * to have a name's spelling), and any branch outside the prefixes.
645
+ */
646
+ export const gitPushInsideWorkspace = async (analysis, cwd, workspace, branchPrefixes) => {
647
+ if (analysis.disqualifiedBy || analysis.base !== 'git' || analysis.pipeline || branchPrefixes.length === 0) {
648
+ return false;
649
+ }
650
+ if (analysis.tokens.some((token) => token.startsWith('~') || /[*?[\]{}$`\\]/.test(token))) {
651
+ return false;
652
+ }
653
+ const args = analysis.tokens.slice(1);
654
+ const directories = [];
655
+ let index = 0;
656
+ for (; index < args.length; index += 1) {
657
+ const token = args[index];
658
+ if (token === '-C' && args[index + 1] !== undefined) {
659
+ directories.push(args[index + 1]);
660
+ index += 1;
661
+ continue;
662
+ }
663
+ if (token === '--no-pager') {
664
+ continue;
665
+ }
666
+ break;
667
+ }
668
+ if (args[index] !== 'push') {
669
+ return false;
670
+ }
671
+ const allowedFlags = new Set(['-u', '--set-upstream', '--dry-run', '-n', '-q', '--quiet', '-v', '--verbose', '--porcelain']);
672
+ const positionals = [];
673
+ for (const token of args.slice(index + 1)) {
674
+ if (token.startsWith('-')) {
675
+ if (!allowedFlags.has(token)) {
676
+ return false;
677
+ }
678
+ continue;
679
+ }
680
+ positionals.push(token);
681
+ }
682
+ if (positionals.length !== 2) {
683
+ return false;
684
+ }
685
+ const [remote, refspec] = positionals;
686
+ if (!/^[A-Za-z0-9._-]+$/.test(remote) || refspec.startsWith('+') || refspec.startsWith(':')) {
687
+ return false;
688
+ }
689
+ const root = await resolveReal(workspace);
690
+ const here = await resolveReal(cwd);
691
+ if (root === undefined || here === undefined || !within(root, here)) {
692
+ return false;
693
+ }
694
+ let repoDir = here;
695
+ for (const directory of directories) {
696
+ repoDir = repoDir === undefined ? undefined : await walk(repoDir, directory);
697
+ }
698
+ if (repoDir === undefined || !within(root, repoDir)) {
699
+ return false;
700
+ }
701
+ const dirs = await gitDirsOf(repoDir, root);
702
+ if (dirs === undefined || !within(root, dirs.gitDir) || !within(root, dirs.commonDir)) {
703
+ return false;
704
+ }
705
+ // Configured, not merely name-shaped: git reads an unconfigured name as a
706
+ // path, and a directory by that name would receive the push.
707
+ const remotes = await configuredRemotes(dirs.commonDir);
708
+ if (remotes === undefined || !remotes.includes(remote)) {
709
+ return false;
710
+ }
711
+ // A remote with its own push mapping sends `nova/x` wherever that says,
712
+ // so the refspec on the line no longer names the destination.
713
+ if (await anyPushMapping(dirs.gitDir, dirs.commonDir)) {
714
+ return false;
715
+ }
716
+ const parts = refspec.split(':');
717
+ if (parts.length > 2) {
718
+ return false;
719
+ }
720
+ const [source, destination] = parts;
721
+ let branch;
722
+ if (source === 'HEAD') {
723
+ const head = await readFile(path.join(dirs.gitDir, 'HEAD'), 'utf8').catch(() => '');
724
+ const current = /^ref: refs\/heads\/(.+)$/m.exec(head)?.[1]?.trim();
725
+ if (current === undefined) {
726
+ return false;
727
+ }
728
+ branch = current;
729
+ }
730
+ else {
731
+ const name = source.replace(/^refs\/heads\//, '');
732
+ // A source git would read as something other than a local branch makes
733
+ // the destination something other than a branch, or unknowable.
734
+ if (source.startsWith('refs/') && !source.startsWith('refs/heads/')) {
735
+ return false;
736
+ }
737
+ if (!await refExists(dirs.commonDir, `refs/heads/${name}`) || await refExists(dirs.commonDir, `refs/tags/${name}`)) {
738
+ return false;
739
+ }
740
+ branch = name;
741
+ }
742
+ // An explicit destination must say it's a branch: unqualified, git
743
+ // matches it against the remote's refs first, and `nova/x` could be
744
+ // `refs/nova/x` there.
745
+ if (destination !== undefined) {
746
+ if (!destination.startsWith('refs/heads/')) {
747
+ return false;
748
+ }
749
+ branch = destination.slice('refs/heads/'.length);
750
+ }
751
+ return branch.length > 0 && branchPrefixes.some((prefix) => branch.startsWith(prefix) && branch.length > prefix.length);
752
+ };
753
+ //# sourceMappingURL=workspace.js.map