@ravefox/space 0.0.0-stage → 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 (152) hide show
  1. package/LICENSE +205 -0
  2. package/NOTICE +55 -0
  3. package/README.md +63 -2
  4. package/assets/webhook-sdk/python/bot.py +63 -0
  5. package/assets/webhook-sdk/python/commonly.py +232 -0
  6. package/node_modules/@commonlyai/seats/README.md +25 -0
  7. package/node_modules/@commonlyai/seats/package.json +22 -0
  8. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +264 -0
  9. package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
  10. package/node_modules/@commonlyai/seats/src/contract.js +48 -0
  11. package/node_modules/@commonlyai/seats/src/failures.js +44 -0
  12. package/node_modules/@commonlyai/seats/src/harness-plan.js +329 -0
  13. package/node_modules/@commonlyai/seats/src/index.js +22 -0
  14. package/node_modules/@commonlyai/seats/src/process-group.js +269 -0
  15. package/node_modules/@commonlyai/seats/src/record/format.js +737 -0
  16. package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
  17. package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
  18. package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
  19. package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
  20. package/node_modules/@commonlyai/seats/src/trace/events.js +46 -0
  21. package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
  22. package/node_modules/@commonlyai/seats/src/turn-processes.js +226 -0
  23. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +418 -0
  24. package/node_modules/@commonlyai/seats/src/valet/env.js +201 -0
  25. package/node_modules/@commonlyai/seats/src/valet/grant.js +123 -0
  26. package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
  27. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +529 -0
  28. package/package.json +63 -4
  29. package/skills/commonly/SKILL.md +233 -0
  30. package/src/commands/agent.js +6334 -0
  31. package/src/commands/daemon.js +933 -0
  32. package/src/commands/dev.js +153 -0
  33. package/src/commands/doctor.js +141 -0
  34. package/src/commands/inbox.js +330 -0
  35. package/src/commands/login.js +194 -0
  36. package/src/commands/mcp.js +56 -0
  37. package/src/commands/pod.js +120 -0
  38. package/src/commands/task.js +279 -0
  39. package/src/commands/verify.js +48 -0
  40. package/src/index.js +99 -0
  41. package/src/lib/adapter-login.js +76 -0
  42. package/src/lib/adapters/broker-bridge-entry.js +76 -0
  43. package/src/lib/adapters/claude.js +2207 -0
  44. package/src/lib/adapters/codex-broker-bridge.mjs +298 -0
  45. package/src/lib/adapters/codex.js +1898 -0
  46. package/src/lib/adapters/dsh.js +513 -0
  47. package/src/lib/adapters/goose.js +510 -0
  48. package/src/lib/adapters/hermes.js +18 -0
  49. package/src/lib/adapters/index.js +68 -0
  50. package/src/lib/adapters/opencode.js +1681 -0
  51. package/src/lib/adapters/pi-commonly-mcp.mjs +71 -0
  52. package/src/lib/adapters/pi-mcp-client.mjs +617 -0
  53. package/src/lib/adapters/pi-shell-env.mjs +41 -0
  54. package/src/lib/adapters/pi.js +1025 -0
  55. package/src/lib/adapters/qwen.js +523 -0
  56. package/src/lib/adapters/seat-model.js +8 -0
  57. package/src/lib/adapters/stub.js +1 -0
  58. package/src/lib/adapters/tested-version.js +106 -0
  59. package/src/lib/api.js +205 -0
  60. package/src/lib/claim-outcome.js +128 -0
  61. package/src/lib/claimed-work-gate.js +73 -0
  62. package/src/lib/cli-hint.js +33 -0
  63. package/src/lib/commit-attribution.js +391 -0
  64. package/src/lib/config.js +155 -0
  65. package/src/lib/content-refusal.js +94 -0
  66. package/src/lib/credential-file.js +208 -0
  67. package/src/lib/daemon-agent-states.js +95 -0
  68. package/src/lib/daemon-hint.js +18 -0
  69. package/src/lib/daemon-logs.js +43 -0
  70. package/src/lib/daemon-service-windows.js +263 -0
  71. package/src/lib/daemon-service.js +486 -0
  72. package/src/lib/daemon-state.js +84 -0
  73. package/src/lib/daemon-store.js +119 -0
  74. package/src/lib/daemon-supervisor.js +629 -0
  75. package/src/lib/daemon-watch.js +115 -0
  76. package/src/lib/declared-mcp-guard.js +284 -0
  77. package/src/lib/default-environment.js +207 -0
  78. package/src/lib/device-login.js +126 -0
  79. package/src/lib/digest-frame.js +151 -0
  80. package/src/lib/doctor.js +291 -0
  81. package/src/lib/egress-proxy.js +160 -0
  82. package/src/lib/enforcement.js +946 -0
  83. package/src/lib/environment.js +681 -0
  84. package/src/lib/first-output-watchdog.js +191 -0
  85. package/src/lib/git-readonly-server.js +138 -0
  86. package/src/lib/git-readonly.js +463 -0
  87. package/src/lib/git-remote-commonly.mjs +100 -0
  88. package/src/lib/grant-broker-guard.js +345 -0
  89. package/src/lib/hooks-config.js +238 -0
  90. package/src/lib/host-gh-broker.js +152 -0
  91. package/src/lib/host-gh-shim.mjs +98 -0
  92. package/src/lib/inbox.js +483 -0
  93. package/src/lib/instance-proxy.js +76 -0
  94. package/src/lib/machine-host.js +29 -0
  95. package/src/lib/mcp-credential-delivery.js +121 -0
  96. package/src/lib/mcp-first-run.js +50 -0
  97. package/src/lib/mcp-home.js +644 -0
  98. package/src/lib/mcp-ready.js +68 -0
  99. package/src/lib/mcp-server-version.js +123 -0
  100. package/src/lib/mcp-token.js +36 -0
  101. package/src/lib/mcp-warm-child.mjs +412 -0
  102. package/src/lib/memory-bridge.js +191 -0
  103. package/src/lib/memory-import.js +156 -0
  104. package/src/lib/missing-harness.js +31 -0
  105. package/src/lib/model-unavailable.js +154 -0
  106. package/src/lib/native-runtime-inventory.js +40 -0
  107. package/src/lib/outsider-grant-mcp.js +82 -0
  108. package/src/lib/own-key-launch.js +42 -0
  109. package/src/lib/pod-catch-up.js +55 -0
  110. package/src/lib/pod-focus.js +224 -0
  111. package/src/lib/pod-tail.js +68 -0
  112. package/src/lib/poll-liveness.js +103 -0
  113. package/src/lib/poll-retry.js +81 -0
  114. package/src/lib/poller.js +200 -0
  115. package/src/lib/provider-proxy.js +42 -0
  116. package/src/lib/public-seat-mcp-flag.js +36 -0
  117. package/src/lib/public-tool-error.js +60 -0
  118. package/src/lib/public-withheld-tools.js +48 -0
  119. package/src/lib/reply-receipt.js +55 -0
  120. package/src/lib/resolve-on-path.js +22 -0
  121. package/src/lib/runtime-inventory.js +56 -0
  122. package/src/lib/runtime-observation.js +37 -0
  123. package/src/lib/sandbox/bwrap.js +275 -0
  124. package/src/lib/sandbox/codex-perimeter.js +16 -0
  125. package/src/lib/sandbox/host-check.js +268 -0
  126. package/src/lib/sandbox/mode.js +49 -0
  127. package/src/lib/sandbox/opencode-loopback.mjs +68 -0
  128. package/src/lib/sandbox/seatbelt.js +684 -0
  129. package/src/lib/sandbox/turn-dir.js +56 -0
  130. package/src/lib/sandbox/windows-unsandboxed.js +183 -0
  131. package/src/lib/sandbox/workspace-git-config.js +243 -0
  132. package/src/lib/sandbox/workspace-pin.js +158 -0
  133. package/src/lib/seat-runtime-controls.js +61 -0
  134. package/src/lib/seat-stop.js +97 -0
  135. package/src/lib/server-skills.js +32 -0
  136. package/src/lib/session-recycle.js +219 -0
  137. package/src/lib/session-store.js +215 -0
  138. package/src/lib/skill-prompt.js +74 -0
  139. package/src/lib/skills-import.js +113 -0
  140. package/src/lib/spawn-credential.js +402 -0
  141. package/src/lib/spawn-retry.js +311 -0
  142. package/src/lib/stable-install.js +176 -0
  143. package/src/lib/supervised-turn.js +352 -0
  144. package/src/lib/task.js +235 -0
  145. package/src/lib/turn-failure-notice.js +177 -0
  146. package/src/lib/unconfined-seat-guard.js +94 -0
  147. package/src/lib/update-check.js +192 -0
  148. package/src/lib/upstream-refusal.js +1 -0
  149. package/src/lib/webhook-server.js +109 -0
  150. package/src/lib/windows-command.js +393 -0
  151. package/src/lib/workspace-git.js +87 -0
  152. package/src/lib/workspace-trust.js +162 -0
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Read-only git for a `workspace`-trust seat (TASK-294).
3
+ *
4
+ * A workspace seat may look at the repository it was started in (status, log,
5
+ * diff, show, blame) and nothing more: no git call it can make may write a
6
+ * file, run a program, or read outside its workspace. A Bash permission prefix
7
+ * is not a boundary this relies on, so Bash stays denied to workspace seats
8
+ * and git never goes through it. Git is one MCP tool (git-readonly-server.js)
9
+ * whose argv this module builds and runs with execFile, with no shell in
10
+ * between.
11
+ *
12
+ * What holds the line:
13
+ * 1. The subcommand is one of five builtins. A builtin name never resolves to
14
+ * an alias, and the caller's arguments go AFTER it, where git's top-level
15
+ * options (`-c`, `-C`, `--git-dir`, `--exec-path`, …) are not parsed.
16
+ * 2. The arguments are checked against the spellings that write, execute, or
17
+ * read a file the caller names (DENIED_LONG_OPTIONS, DENIED_SHORT_OPTIONS,
18
+ * DENIED_SHORT_OPTIONS_BY_SUBCOMMAND), including git's accepted
19
+ * abbreviations of them (`--outp=`), and against
20
+ * paths that leave the workspace.
21
+ * 3. diff, log and show run with `--no-ext-diff --no-textconv`, and blame with
22
+ * `--no-textconv`, so no external diff or conversion program configured for
23
+ * the repository runs, and the spellings that would turn them back on are
24
+ * refused. log and show also run with `--no-show-signature`, and
25
+ * `format.pretty` is forced to `medium`, so nothing runs gpg:
26
+ * `--show-signature`, a `%G` placeholder and a configured format alias
27
+ * (which can hold one) are refused. `core.fsmonitor` is forced off for the
28
+ * same reason: `git status` runs it. What still runs is a clean filter the
29
+ * repository's own config defines, when status, diff or blame read a
30
+ * working-tree file: without it they report unchanged files as changed. It
31
+ * runs inside the sandbox below, and the seat cannot define one. A
32
+ * submodule's git runs as a child process that gets none of these flags, so
33
+ * `--submodule=diff` is refused, and the settings that would start one are
34
+ * forced through `-c`, which does reach it (TOP_LEVEL).
35
+ * 4. The environment is rebuilt from an allowlist. No inherited GIT_* variable
36
+ * survives, system and global config are off, optional locks are off (so
37
+ * status never rewrites the index), and GIT_CEILING_DIRECTORIES stops
38
+ * repository discovery at the workspace. The only repository git can open
39
+ * is one rooted there, so `show HEAD:<path>` cannot reach a file above it.
40
+ * No git it runs contacts a remote: lazy fetching is off, so a partial
41
+ * clone reports an object it lacks as missing instead of fetching it, and
42
+ * no transport is allowed, which covers git older than 2.44, where
43
+ * GIT_NO_LAZY_FETCH does nothing.
44
+ * 5. Outside this module, the seat's sandbox (Seatbelt or bwrap) mounts the
45
+ * workspace read-only. A public Codex seat starts its MCP servers outside
46
+ * its own profile, so the codex adapter runs this server under a Seatbelt
47
+ * of its own (workspace-git.js): workspace read-only, no network.
48
+ * 6. A committed `.env` or `.env.*` stays as far out of reach as the file
49
+ * itself, which the sandbox denies at any depth. So do `.npmrc`,
50
+ * `.yarnrc*`, `.commonly` and `.codex`, which a sandbox also denies. An argument naming one
51
+ * (`.env`, `HEAD:.env`, `:0:sub/.env.local`, `-L1,5:.env`) is refused, and
52
+ * status, log, diff and show run with pathspecs excluding them, so no patch,
53
+ * pickaxe match or blob id comes from one whatever path or glob the caller
54
+ * named. `--follow` takes exactly one path, so it is refused, and so is
55
+ * `--full-diff`, which lets pathspecs pick commits and then shows every
56
+ * file each one changed. A line range (log and show `-L<range>:<file>`)
57
+ * takes no pathspec, so it is taken only as the first argument, in one
58
+ * piece, where git cannot read it as another option's value, and its file
59
+ * is name-checked. A blob has no name of its own, so `show` refuses any
60
+ * revision that names a blob (or a tag that peels to one) other than as
61
+ * `<rev>:<path>`, whose path is name-checked: git is asked each one's type
62
+ * before the call runs (blobNamedById). `show` also refuses the options
63
+ * that make git pick objects itself (SHOW_DENIED_OPTIONS), since it prints
64
+ * every blob they reach whatever the pathspec says.
65
+ */
66
+ import { execFile } from 'child_process';
67
+ import { realpathSync } from 'fs';
68
+ import {
69
+ dirname, isAbsolute, relative, resolve,
70
+ } from 'path';
71
+
72
+ export const READONLY_GIT_SUBCOMMANDS = Object.freeze(['status', 'log', 'diff', 'show', 'blame']);
73
+
74
+ // Long options refused in any position, with what each one does.
75
+ export const DENIED_LONG_OPTIONS = Object.freeze({
76
+ '--output': 'writes its result to a file',
77
+ '--ext-diff': 'runs an external diff program',
78
+ '--textconv': 'runs a configured conversion program',
79
+ '--no-index': 'compares files outside the repository',
80
+ '--contents': 'reads a file the caller names',
81
+ '--ignore-revs-file': 'reads a file the caller names',
82
+ '--git-dir': 'opens a different repository',
83
+ '--work-tree': 'opens a different working tree',
84
+ '--exec-path': 'runs git programs from another directory',
85
+ '--config-env': 'sets configuration',
86
+ '--paginate': 'runs a pager',
87
+ '--show-signature': 'runs gpg to check a signature',
88
+ '--follow': 'needs exactly one path, and every log here also carries the .env excludes',
89
+ '--full-diff': 'shows every file a picked commit changed, .env files included',
90
+ '--end-of-options': 'makes git read every later argument as a revision, so an option-shaped one skips the checks a revision gets',
91
+ });
92
+
93
+ // Options refused for `show` only. Each makes git pick objects itself (every
94
+ // ref, every tag, the index), and `show` prints every blob it is handed in
95
+ // full: a pathspec doesn't filter it, so `show --indexed-objects` would print
96
+ // every file in the index, and `show --tags` a tag that points at a blob.
97
+ // log reads the same options, but prints only the commits they reach.
98
+ export const SHOW_DENIED_OPTIONS = Object.freeze({
99
+ '--all': 'shows every object the refs point at, blobs included',
100
+ '--branches': 'shows every object the branches point at',
101
+ '--tags': 'shows every object the tags point at, blobs included',
102
+ '--remotes': 'shows every object the remote refs point at',
103
+ '--glob': 'shows every object the matching refs point at, blobs included',
104
+ '--exclude': 'only qualifies a ref-selecting option, which show refuses',
105
+ '--exclude-hidden': 'only qualifies a ref-selecting option, which show refuses',
106
+ '--reflog': 'shows every object a reflog names',
107
+ '--indexed-objects': 'shows every file in the index, whatever its name',
108
+ '--alternate-refs': 'shows objects another repository\'s refs point at',
109
+ '--bisect': 'shows the objects the bisect refs point at',
110
+ '--stdin': 'reads revisions the name check never sees',
111
+ });
112
+ const checkShowOption = (arg) => {
113
+ const [name] = arg.split('=', 1);
114
+ if (Object.prototype.hasOwnProperty.call(SHOW_DENIED_OPTIONS, name)) {
115
+ return `${name} is refused for show: it ${SHOW_DENIED_OPTIONS[name]}; name the commit, tree or <rev>:<path> to show`;
116
+ }
117
+ if (name.length >= 4) {
118
+ const denied = Object.keys(SHOW_DENIED_OPTIONS).find((option) => option.startsWith(name));
119
+ if (denied) return `${name} is refused for show: git may read it as ${denied}, which ${SHOW_DENIED_OPTIONS[denied]}`;
120
+ }
121
+ return null;
122
+ };
123
+
124
+ // Real options that are also prefixes of a denied one. Git takes an exact name
125
+ // over an abbreviation, so these never mean the denied option.
126
+ const EXACT_NAMES_THAT_PREFIX_A_DENIED_ONE = new Set(['--text', '--ignore-rev']);
127
+
128
+ // Single-letter options refused, with what each one does.
129
+ export const DENIED_SHORT_OPTIONS = Object.freeze({
130
+ c: 'sets configuration (as a top-level option)',
131
+ C: 'changes directory (as a top-level option); use --find-copies to detect copies',
132
+ O: 'reads a file the caller names',
133
+ });
134
+
135
+ // Letters refused for one subcommand only. log, diff and show read -S as the
136
+ // pickaxe string, but blame reads it as a file of revisions, and prints back a
137
+ // line it cannot parse ("bad graft data: <line>").
138
+ export const DENIED_SHORT_OPTIONS_BY_SUBCOMMAND = Object.freeze({
139
+ blame: Object.freeze({ S: 'reads a revisions file the caller names' }),
140
+ });
141
+
142
+ // Short options that carry their value in the same argument (`-n5`, `-U3`,
143
+ // `-Sneedle`, `-Gregex`, `-L1,5:file`, `-M50%`, `-B`). Git hands the rest of
144
+ // the argument to the option as its value, so nothing after the letter can be
145
+ // read as another option.
146
+ const VALUE_SHORT_OPTIONS = new Set(['n', 'U', 'S', 'G', 'L', 'M', 'B']);
147
+ const STATUS_UNTRACKED_MODES = new Set(['-uno', '-unormal', '-uall']);
148
+
149
+ // Ahead of the caller's arguments, so a configured program never runs.
150
+ const FORCED_OPTIONS = Object.freeze({
151
+ diff: ['--no-ext-diff', '--no-textconv'],
152
+ log: ['--no-ext-diff', '--no-textconv', '--no-show-signature'],
153
+ show: ['--no-ext-diff', '--no-textconv', '--no-show-signature'],
154
+ status: [],
155
+ blame: ['--no-textconv'],
156
+ });
157
+ // `format.pretty` is the repository's default format for log and show, and a
158
+ // format can hold a %G placeholder; `medium` is git's own default. The last
159
+ // three reach the child git a submodule runs in, which FORCED_OPTIONS do not:
160
+ // `diff.submodule=diff` would run git diff inside each submodule under that
161
+ // submodule's own config, `status.submoduleSummary` runs git log there, and
162
+ // `log.showSignature` would make that log run gpg.
163
+ const TOP_LEVEL = Object.freeze([
164
+ '--no-pager', '-c', 'core.fsmonitor=false', '-c', 'format.pretty=medium',
165
+ '-c', 'diff.submodule=short', '-c', 'status.submoduleSummary=false', '-c', 'log.showSignature=false',
166
+ ]);
167
+
168
+ // A %G placeholder (%G?, %GG, %GS, %GK, %GF, %GP, %GT) runs gpg on each signed
169
+ // commit, and git reads one behind a `+`, `-` or space modifier (`%+G?`,
170
+ // `%-GG`, `% GS`) as the same placeholder. A value with no %, no format: and
171
+ // no tformat: is looked up by prefix among the built-in formats and the
172
+ // repository's `pretty.<name>` aliases, and an alias can hold one, so only a
173
+ // built-in name is taken.
174
+ const SIGNATURE_PLACEHOLDER = /%[-+ ]?G/;
175
+ const BUILTIN_FORMATS = Object.freeze(['oneline', 'short', 'medium', 'full', 'fuller', 'reference', 'email', 'mboxrd', 'raw']);
176
+ // Git's revision parser reads --pretty and --format only by their exact names,
177
+ // and their format only after `=`: a bare --pretty means medium, and a bare
178
+ // --format is an unrecognized argument. The check does not depend on that. An
179
+ // abbreviation (`--form=`, `--pret=`) is checked like the full name, and a
180
+ // --format that would take its format from the next argument is refused.
181
+ const FORMAT_OPTIONS = Object.freeze(['--pretty', '--format']);
182
+ const checkFormat = (arg) => {
183
+ const [name] = arg.split('=', 1);
184
+ const option = name.length >= 3 && FORMAT_OPTIONS.find((full) => full.startsWith(name));
185
+ if (!option) return null;
186
+ if (name === arg) {
187
+ return option === '--format' ? `${arg} is refused: give the format in the same argument, as --format=<format>` : null;
188
+ }
189
+ const value = arg.slice(name.length + 1);
190
+ if (SIGNATURE_PLACEHOLDER.test(value)) return `${arg} is refused: a %G placeholder runs gpg to check a signature`;
191
+ const userFormat = value === '' || value.startsWith('format:') || value.startsWith('tformat:') || value.includes('%');
192
+ if (userFormat || BUILTIN_FORMATS.includes(value.toLowerCase())) return null;
193
+ return `${arg} is refused: name a built-in format (${BUILTIN_FORMATS.join(', ')}) or give format:<placeholders>`;
194
+ };
195
+
196
+ // The names a seat's sandbox denies at any depth: sandbox/seatbelt.js denies
197
+ // .env files, .commonly and .codex, and a public Codex seat's profile
198
+ // (adapters/codex.js) also denies .npmrc and .yarnrc*. They are kept out of
199
+ // git's results too, since the objects git reads hold them whatever the
200
+ // sandbox says about the file.
201
+ const ENV_FILE_SEGMENT = /^\.env(\..*)?$/i;
202
+ const OTHER_PROTECTED_SEGMENT = /^(?:\.npmrc|\.yarnrc.*|\.commonly|\.codex)$/i;
203
+ const segmentsOf = (arg) => arg.split(':').flatMap((part) => part.split('/'));
204
+ const namesEnvFile = (arg) => segmentsOf(arg).some((segment) => ENV_FILE_SEGMENT.test(segment));
205
+ const namesOtherProtected = (arg) => segmentsOf(arg).some((segment) => OTHER_PROTECTED_SEGMENT.test(segment));
206
+ const NAMES_ENV_FILE = 'it names a .env file, which the workspace keeps out of reach';
207
+ const NAMES_OTHER_PROTECTED = 'it names a file the workspace keeps out of reach (.npmrc, .yarnrc, .commonly or .codex)';
208
+ const protectedNameReason = (arg) => {
209
+ if (namesEnvFile(arg)) return NAMES_ENV_FILE;
210
+ if (namesOtherProtected(arg)) return NAMES_OTHER_PROTECTED;
211
+ return null;
212
+ };
213
+ const ENV_EXCLUDES = Object.freeze([
214
+ ':(exclude,glob,icase)**/.env', ':(exclude,glob,icase)**/.env.*', ':(exclude,glob,icase)**/.env/**', ':(exclude,glob,icase)**/.env.*/**',
215
+ ':(exclude,glob,icase)**/.npmrc', ':(exclude,glob,icase)**/.npmrc/**', ':(exclude,glob,icase)**/.yarnrc*', ':(exclude,glob,icase)**/.yarnrc*/**',
216
+ ':(exclude,glob,icase)**/.commonly', ':(exclude,glob,icase)**/.commonly/**', ':(exclude,glob,icase)**/.codex', ':(exclude,glob,icase)**/.codex/**',
217
+ ]);
218
+ // blame takes one literal file, so the name check above covers it. A
219
+ // blob-to-blob diff (`diff A:f B:f`) takes no pathspec and is refused by git
220
+ // here; `diff A B -- f` is the same diff. A line range takes none either (see
221
+ // checkLineRange), so its file is name-checked instead.
222
+ const PATHSPEC_SUBCOMMANDS = new Set(['status', 'log', 'diff', 'show']);
223
+
224
+ // log and show trace a line range (-L<range>:<file>), and git refuses a
225
+ // pathspec with one, so that call runs without the .env excludes. Only a token
226
+ // git is sure to read as -L may turn them off: the first argument, with its
227
+ // range in it. After another argument, git can read it as that option's value;
228
+ // a bare -L takes the next argument as its range, which the name check may
229
+ // read as an option instead. status and diff take no -L at all.
230
+ const LINE_RANGE_SUBCOMMANDS = new Set(['log', 'show']);
231
+ const checkLineRange = (arg, index, subcommand) => {
232
+ if (!LINE_RANGE_SUBCOMMANDS.has(subcommand)) return `-L is refused for ${subcommand}: only log and show trace a line range`;
233
+ if (index > 0 || arg.length === 2) {
234
+ return `${arg} is refused: give one line range, as the first argument and in one piece (-L<start>,<end>:<file>), so git cannot read it as another option's value`;
235
+ }
236
+ return null;
237
+ };
238
+
239
+ const MAX_ARGS = 64;
240
+ const MAX_ARG_LENGTH = 4096;
241
+ export const MAX_OUTPUT_CHARS = 50 * 1024;
242
+ const MAX_BUFFER_BYTES = 4 * 1024 * 1024;
243
+ const DEFAULT_TIMEOUT_MS = 30 * 1000;
244
+
245
+ const refuse = (reason) => ({ ok: false, reason });
246
+
247
+ const realOrResolved = (p) => {
248
+ try {
249
+ return realpathSync(p);
250
+ } catch {
251
+ return resolve(p);
252
+ }
253
+ };
254
+
255
+ const escapesRoot = (root, arg) => {
256
+ const rel = relative(root, resolve(root, arg));
257
+ return rel === '..' || rel.startsWith('../') || isAbsolute(rel);
258
+ };
259
+
260
+ const checkLongOption = (arg) => {
261
+ const name = arg.split('=')[0];
262
+ if (arg.endsWith('=diff') && name.length >= 3 && '--submodule'.startsWith(name)) {
263
+ return `${arg} is refused: it runs git diff inside each submodule, where the forced options don't reach; --submodule=log lists the same commits`;
264
+ }
265
+ if (Object.prototype.hasOwnProperty.call(DENIED_LONG_OPTIONS, name)) {
266
+ return `${name} is refused: it ${DENIED_LONG_OPTIONS[name]}`;
267
+ }
268
+ if (name.length >= 3 && !EXACT_NAMES_THAT_PREFIX_A_DENIED_ONE.has(name)) {
269
+ const denied = Object.keys(DENIED_LONG_OPTIONS).find((option) => option.startsWith(name));
270
+ if (denied) return `${name} is refused: git reads it as ${denied}, which ${DENIED_LONG_OPTIONS[denied]}`;
271
+ }
272
+ return null;
273
+ };
274
+
275
+ const checkShortOption = (arg, subcommand) => {
276
+ if (/^-\d+$/.test(arg)) return null;
277
+ const letter = arg[1];
278
+ if (Object.prototype.hasOwnProperty.call(DENIED_SHORT_OPTIONS, letter)) {
279
+ return `-${letter} is refused: it ${DENIED_SHORT_OPTIONS[letter]}`;
280
+ }
281
+ const deniedHere = DENIED_SHORT_OPTIONS_BY_SUBCOMMAND[subcommand] || {};
282
+ if (Object.prototype.hasOwnProperty.call(deniedHere, letter)) {
283
+ return `-${letter} is refused for ${subcommand}: it ${deniedHere[letter]}`;
284
+ }
285
+ if (arg.length === 2) return null;
286
+ if (VALUE_SHORT_OPTIONS.has(letter)) return null;
287
+ if (subcommand === 'status' && STATUS_UNTRACKED_MODES.has(arg)) return null;
288
+ return `${arg} is refused: pass each short option as its own argument`;
289
+ };
290
+
291
+ /**
292
+ * The argv for one read-only git call, or the reason it is refused.
293
+ * `root` is the workspace; the caller's paths are checked against it.
294
+ */
295
+ export const readonlyGitArgv = ({ subcommand, args = [], root }) => {
296
+ if (!READONLY_GIT_SUBCOMMANDS.includes(subcommand)) {
297
+ return refuse(`git ${subcommand} is refused: only ${READONLY_GIT_SUBCOMMANDS.join(', ')} are available`);
298
+ }
299
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) {
300
+ return refuse('args must be an array of strings');
301
+ }
302
+ if (args.length > MAX_ARGS) return refuse(`at most ${MAX_ARGS} arguments`);
303
+ if (typeof root !== 'string' || !isAbsolute(root)) return refuse('the workspace is not an absolute path');
304
+ let pathsOnly = false;
305
+ let lineRange = false;
306
+ for (const [index, arg] of args.entries()) {
307
+ if (arg.length > MAX_ARG_LENGTH || arg.includes('\0')) return refuse('an argument is too long or contains NUL');
308
+ if (pathsOnly || arg === '-' || !arg.startsWith('-')) {
309
+ if (escapesRoot(root, arg)) return refuse(`${arg} is refused: it is outside the workspace`);
310
+ const protectedReason = protectedNameReason(arg);
311
+ if (protectedReason) return refuse(`${arg} is refused: ${protectedReason}`);
312
+ } else if (arg === '--') {
313
+ pathsOnly = true;
314
+ } else {
315
+ const reason = arg.startsWith('--')
316
+ ? checkLongOption(arg) || checkFormat(arg) || (subcommand === 'show' ? checkShowOption(arg) : null)
317
+ : checkShortOption(arg, subcommand);
318
+ if (reason) return refuse(reason);
319
+ if (arg.startsWith('-L')) {
320
+ const rangeReason = protectedNameReason(arg.slice(2));
321
+ if (rangeReason) return refuse(`${arg} is refused: ${rangeReason}`);
322
+ if (PATHSPEC_SUBCOMMANDS.has(subcommand)) {
323
+ const lineRangeReason = checkLineRange(arg, index, subcommand);
324
+ if (lineRangeReason) return refuse(lineRangeReason);
325
+ lineRange = true;
326
+ }
327
+ }
328
+ }
329
+ }
330
+ const excludes = PATHSPEC_SUBCOMMANDS.has(subcommand) && !lineRange
331
+ ? [...(pathsOnly ? [] : ['--']), ...ENV_EXCLUDES]
332
+ : [];
333
+ return { ok: true, argv: [...TOP_LEVEL, subcommand, ...FORCED_OPTIONS[subcommand], ...args, ...excludes] };
334
+ };
335
+
336
+ // What git keeps from the seat's environment. Everything else is dropped,
337
+ // every GIT_* variable included.
338
+ const KEPT_ENV = new Set(['PATH', 'HOME', 'USER', 'LOGNAME', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TMPDIR', 'TZ']);
339
+
340
+ export const readonlyGitEnv = ({ root, baseEnv = process.env }) => {
341
+ const env = {};
342
+ for (const [key, value] of Object.entries(baseEnv || {})) {
343
+ if (KEPT_ENV.has(key) && typeof value === 'string') env[key] = value;
344
+ }
345
+ return {
346
+ ...env,
347
+ GIT_CONFIG_NOSYSTEM: '1',
348
+ GIT_CONFIG_GLOBAL: '/dev/null',
349
+ GIT_ATTR_NOSYSTEM: '1',
350
+ GIT_OPTIONAL_LOCKS: '0',
351
+ GIT_TERMINAL_PROMPT: '0',
352
+ GIT_NO_LAZY_FETCH: '1',
353
+ // A name no transport uses, so every protocol is refused.
354
+ GIT_ALLOW_PROTOCOL: 'none',
355
+ GIT_PAGER: 'cat',
356
+ PAGER: 'cat',
357
+ GIT_CEILING_DIRECTORIES: dirname(realOrResolved(root)),
358
+ };
359
+ };
360
+
361
+ const execGit = (git, argv, { root, baseEnv, timeoutMs }) => new Promise((done) => {
362
+ execFile(git, argv, {
363
+ cwd: root,
364
+ env: readonlyGitEnv({ root, baseEnv }),
365
+ timeout: timeoutMs,
366
+ maxBuffer: MAX_BUFFER_BYTES,
367
+ encoding: 'utf8',
368
+ windowsHide: true,
369
+ }, (error, stdout, stderr) => done({ error, stdout: String(stdout || ''), stderr: String(stderr || '') }));
370
+ });
371
+
372
+ // Whether git reads `arg` as `<rev>:<path>`. git splits at the first `:`
373
+ // outside any `{...}` (get_oid_with_context_1 in object-name.c counts brace
374
+ // depth), so a `:` inside `@{...}` or `^{...}` belongs to the revision: a
375
+ // reflog date such as `ref@{2026-11-09 11:41:50}` names no path at all.
376
+ const namesAPath = (arg) => {
377
+ let depth = 0;
378
+ for (const ch of arg) {
379
+ if (ch === '{') depth += 1;
380
+ else if (ch === '}' && depth > 0) depth -= 1;
381
+ else if (ch === ':' && depth === 0) return true;
382
+ }
383
+ return false;
384
+ };
385
+
386
+ // The arguments `show` would read as revisions: before `--`, not an option
387
+ // (`--end-of-options` is refused, so a `-` argument is never a revision). One
388
+ // that names a path does so through `<rev>:<path>`, and readonlyGitArgv has
389
+ // name-checked that path; every other one is probed.
390
+ const unnamedRevisions = (args) => {
391
+ const revisions = [];
392
+ for (const arg of args) {
393
+ if (arg === '--') break;
394
+ if (!arg.startsWith('-') && !namesAPath(arg)) revisions.push(arg);
395
+ }
396
+ return revisions;
397
+ };
398
+
399
+ /**
400
+ * The first of `show`'s revisions that is a blob, or a tag that peels to one,
401
+ * or null. git prints a blob given by id whatever path it was committed at, so
402
+ * a blob is only shown through `<rev>:<path>`. An argument git can't resolve
403
+ * to one object (a range, a typo) is left to `show` itself, which shows no
404
+ * blob for it.
405
+ */
406
+ export const blobNamedById = async ({
407
+ args, root, git = 'git', baseEnv = process.env, timeoutMs = DEFAULT_TIMEOUT_MS,
408
+ }) => {
409
+ // One budget for every check, so many arguments can't stretch the call.
410
+ const deadline = Date.now() + timeoutMs;
411
+ for (const revision of unnamedRevisions(args)) {
412
+ const left = deadline - Date.now();
413
+ if (left <= 0) return { revision, unchecked: true };
414
+ // A revision here never starts with `-`, so cat-file can't read it as an option.
415
+ const { error, stdout } = await execGit(
416
+ git,
417
+ [...TOP_LEVEL, 'cat-file', '-t', `${revision}^{}`],
418
+ { root, baseEnv, timeoutMs: left },
419
+ );
420
+ // A check that was stopped proves nothing, so the call is refused.
421
+ if (error?.killed || error?.signal) return { revision, unchecked: true };
422
+ if (!error && stdout.trim() === 'blob') return { revision };
423
+ }
424
+ return null;
425
+ };
426
+
427
+ const truncate = (text) => (text.length <= MAX_OUTPUT_CHARS
428
+ ? text
429
+ : `${text.slice(0, MAX_OUTPUT_CHARS)}\n[output truncated at ${MAX_OUTPUT_CHARS} characters; narrow it with -n, a path, or a revision range]`);
430
+
431
+ /**
432
+ * Run one read-only git call in `root`. Resolves `{ ok, text }`; never rejects.
433
+ * `git` is the binary to run (the adapter resolves it outside the sandbox).
434
+ */
435
+ export const runReadonlyGit = async ({
436
+ subcommand, args, root, git = 'git', baseEnv = process.env, timeoutMs = DEFAULT_TIMEOUT_MS,
437
+ }) => {
438
+ const checked = readonlyGitArgv({ subcommand, args, root });
439
+ if (!checked.ok) return { ok: false, text: checked.reason };
440
+ if (subcommand === 'show') {
441
+ const blob = await blobNamedById({
442
+ args, root, git, baseEnv, timeoutMs,
443
+ });
444
+ if (blob?.unchecked) {
445
+ return { ok: false, text: `${blob.revision} is refused: git did not say in time whether it is a blob` };
446
+ }
447
+ if (blob) {
448
+ return {
449
+ ok: false,
450
+ text: `${blob.revision} is refused: it is a blob, and a blob is shown only as <rev>:<path>, so the file it came from can be checked`,
451
+ };
452
+ }
453
+ }
454
+ const { error, stdout: out, stderr } = await execGit(git, checked.argv, { root, baseEnv, timeoutMs });
455
+ if (!error) return { ok: true, text: truncate(out || '(no output)') };
456
+ const detail = stderr.trim() || error.message;
457
+ return { ok: false, text: truncate([out.trim(), detail].filter(Boolean).join('\n')) };
458
+ };
459
+
460
+ export default {
461
+ READONLY_GIT_SUBCOMMANDS, DENIED_LONG_OPTIONS, DENIED_SHORT_OPTIONS, blobNamedById, readonlyGitArgv, readonlyGitEnv,
462
+ runReadonlyGit,
463
+ };
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ /** Push-only Git remote helper for a confined host seat. No provider credential enters this process. */
3
+ import { spawn } from 'child_process';
4
+ import { connect } from 'net';
5
+ import { mkdtemp, readFile, rm } from 'fs/promises';
6
+ import { createInterface } from 'readline';
7
+ import { join } from 'path';
8
+
9
+ const MAX_BUNDLE_BYTES = 6 * 1024 * 1024;
10
+ const OID = /^[0-9a-f]{40}$/;
11
+ const REF = /^refs\/heads\/seat\/[A-Za-z0-9._/-]+$/;
12
+ const grantId = String(process.argv[3] || '').replace(/^commonly::/, '');
13
+
14
+ const git = (args) => new Promise((resolve, reject) => {
15
+ const child = spawn('git', args, { stdio: ['ignore', 'pipe', 'pipe'] });
16
+ let stdout = '';
17
+ let stderr = '';
18
+ child.stdout.on('data', (chunk) => { stdout += chunk; if (stdout.length > 4096) child.kill(); });
19
+ child.stderr.on('data', (chunk) => { stderr += chunk; if (stderr.length > 4096) child.kill(); });
20
+ child.on('error', reject);
21
+ child.on('close', (code) => (code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || 'git command failed'))));
22
+ });
23
+
24
+ const broker = (args) => new Promise((resolve, reject) => {
25
+ const path = process.env.COMMONLY_GH_SOCKET;
26
+ if (!path?.startsWith('/')) return reject(new Error('seat GitHub broker is unavailable'));
27
+ const socket = connect(path);
28
+ const chunks = [];
29
+ let bytes = 0;
30
+ socket.setTimeout(185_000, () => socket.destroy(new Error('push broker timed out')));
31
+ socket.on('connect', () => socket.end(JSON.stringify({ grantId, operation: 'push.branch', args })));
32
+ socket.on('data', (chunk) => {
33
+ bytes += chunk.length;
34
+ if (bytes > 128 * 1024) socket.destroy(new Error('push broker response is too large'));
35
+ else chunks.push(chunk);
36
+ });
37
+ socket.on('error', reject);
38
+ socket.on('end', () => {
39
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch {
40
+ reject(new Error('push broker response is invalid'));
41
+ }
42
+ });
43
+ });
44
+
45
+ const push = async (commands) => {
46
+ if (commands.length !== 1 || !commands[0].startsWith('push ')) {
47
+ throw new Error('exactly one branch push is allowed');
48
+ }
49
+ const spec = commands[0].slice(5);
50
+ if (spec.startsWith('+') || spec.split(':').length !== 2) throw new Error('force and deletion are not allowed');
51
+ const [source, ref] = spec.split(':');
52
+ if (!/^(HEAD|refs\/heads\/[A-Za-z0-9._/-]+)$/.test(source)
53
+ || !REF.test(ref) || ref.includes('..') || ref.endsWith('.lock') || ref.endsWith('.')) {
54
+ throw new Error('branch ref is not allowed');
55
+ }
56
+ const newOid = await git(['rev-parse', '--verify', `${source}^{commit}`]);
57
+ const baseOid = await git(['merge-base', newOid, 'refs/remotes/origin/main']);
58
+ if (!OID.test(newOid) || !OID.test(baseOid)) throw new Error('invalid local commit');
59
+ const gitPath = await git(['rev-parse', '--git-path', 'commonly-push']);
60
+ const directory = await mkdtemp(`${gitPath}-`);
61
+ try {
62
+ const file = join(directory, 'push.bundle');
63
+ await git(['bundle', 'create', file, source, `^${baseOid}`]);
64
+ const bundle = await readFile(file);
65
+ if (bundle.length > MAX_BUNDLE_BYTES) throw new Error('push bundle exceeds 6 MiB');
66
+ const answer = await broker({ ref, baseOid, newOid, bundle: bundle.toString('base64') });
67
+ if (answer.status !== 200) throw new Error(answer.body?.error || 'broker refused push');
68
+ return ref;
69
+ } finally {
70
+ await rm(directory, { recursive: true, force: true });
71
+ }
72
+ };
73
+
74
+ const main = async () => {
75
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(grantId)) throw new Error('invalid broker grant id');
76
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
77
+ let batch = [];
78
+ for await (const line of lines) {
79
+ if (line === 'capabilities') {
80
+ process.stdout.write('push\n\n');
81
+ } else if (line === 'list for-push') {
82
+ process.stdout.write('\n');
83
+ } else if (line.startsWith('push ')) {
84
+ batch.push(line);
85
+ } else if (line === '' && batch.length) {
86
+ const ref = batch[0].split(':').at(-1);
87
+ try {
88
+ const accepted = await push(batch);
89
+ process.stdout.write(`ok ${accepted}\n\n`);
90
+ } catch (error) {
91
+ process.stdout.write(`error ${ref} ${String(error.message).replace(/[\r\n]/g, ' ')}\n\n`);
92
+ }
93
+ batch = [];
94
+ } else if (line !== '') {
95
+ throw new Error('unsupported Git remote command');
96
+ }
97
+ }
98
+ };
99
+
100
+ main().catch((error) => { process.stderr.write(`${error.message}\n`); process.exitCode = 2; });