@ravefox/space 0.0.0-stage → 0.2.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.
Files changed (152) hide show
  1. package/LICENSE +203 -0
  2. package/NOTICE +44 -0
  3. package/README.md +49 -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 +56 -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,345 @@
1
+ /**
2
+ * Withhold the grant broker from a seat this daemon cannot confine (TASK-063).
3
+ *
4
+ * The grant arrives as an injected MCP server, and confinement is declared in a
5
+ * different field — `sandbox` — so the two can disagree: a seat can be handed a
6
+ * granter's authority and no confinement. wren's ruling (69799) is REFUSE, not
7
+ * derive, and it is ENTRY-level (69829): the broker entry is withheld and the
8
+ * seat runs, because the seat was never promised confinement — a seat-level
9
+ * refusal is #1727's case only (a declared sandbox the host cannot enforce).
10
+ *
11
+ * This is the daemon half of one refusal with TWO emitters. The server refuses
12
+ * at the projection (`backend/services/grantBrokerConfinement.ts`) for rows it
13
+ * can judge host-independently; the daemon decides the rest, because it is the
14
+ * layer that knows this host, that resolved the adapter locally, and that holds
15
+ * the record the seat actually runs from — including records the projection
16
+ * never reaches (a row naming no adapter, a backend older than the refusal, a
17
+ * hand-written token file). Both halves emit the same `code`.
18
+ *
19
+ * THE ADAPTER IS THE HOST-INDEPENDENT FACT, but sandbox support and broker
20
+ * admission are separate: only ADAPTERS_WITH_GRANT_BROKER may receive the grant
21
+ * broker (claude, codex, OpenCode since F175a, pi since F175c, Qwen and Goose
22
+ * since F175d). pi, Qwen and Goose are admitted on a narrower rule
23
+ * (ADAPTERS_WITH_PUBLIC_ONLY_GRANT): a public seat in a jail this host has (for
24
+ * pi, macOS Seatbelt only), and every other declaration is
25
+ * `adapter_confines_public_only`, which a named grant (F94) does not lift.
26
+ *
27
+ * ONE REASON THE SERVER DOES NOT HAVE: `sandbox_absent`. The server must ALLOW
28
+ * an absent block (a daemon-provisioned seat's baseline is derived here and is
29
+ * not visible from the installation row — `quill` carries no sandbox key), so
30
+ * absence is this layer's to judge. Reaching it here means the baseline did not
31
+ * supply one, which happens for a local record with no environment of its own
32
+ * (`sandbox: !existing.environment` at the derive sites): nobody authored a
33
+ * sandbox and nothing derived one, so the seat would run unconfined.
34
+ *
35
+ * THE URL IS THE FACT, AND THE URL THIS LAYER HOLDS USUALLY CANNOT BE PARSED.
36
+ * Measured on the live fleet: the broker url in a token record is the
37
+ * UNRESOLVED placeholder `"${COMMONLY_API_URL}/api/mcp/grants/<id>"` (c4-smoke,
38
+ * the only record declaring one). `new URL()` throws on that string, so
39
+ * `isGrantBrokerUrl` — which the pi adapter calls AFTER substituting — returns
40
+ * false here. A daemon-side predicate that reused it unchanged would match
41
+ * nothing and refuse nothing while reading as if it enforced something. So the
42
+ * known placeholders are resolved to this instance first, and an entry counts
43
+ * only when it is OUR broker: our origin, or a relative/placeholder-prefixed
44
+ * path. A foreign server that happens to live under `/api/mcp/grants/` is not
45
+ * our grant and is left alone.
46
+ *
47
+ * AND A MISS HERE IS FAIL-OPEN, so the path is NORMALIZED before it is judged.
48
+ * Measured (vera, 70369): a bound instance spelled `https://api.commonly.me/`
49
+ * turns `"${COMMONLY_API_URL}/api/mcp/grants/g1"` into
50
+ * `https://api.commonly.me//api/mcp/grants/g1`, whose pathname starts `//api/`
51
+ * and matches nothing — the entry goes unrecognised and the broker RIDES into a
52
+ * seat this daemon just decided it cannot confine. Reachable: `agent.js` takes
53
+ * `instanceUrl` from `COMMONLY_API_URL` with a bare `.trim()`, while `config.js`
54
+ * is what strips the slash.
55
+ *
56
+ * The enforcement is ONE mechanism: duplicate slashes are collapsed before the
57
+ * path predicate sees them. It covers the doubled slash whichever side produced
58
+ * it — the join, a hand-written record, and a protocol-relative spelling OF THE
59
+ * PATH (`//api/mcp/grants/g1`, which collapses to the broker's path) — where
60
+ * stripping the instance's trailing slash covers only the join, and a mutation
61
+ * showed the two were redundant here (removing the strip reddened nothing).
62
+ *
63
+ * A URL WITH NO SCHEME HAS TWO READINGS, and both are taken (vera, 70372, who
64
+ * corrected a first draft of this paragraph for claiming the second was
65
+ * covered). Collapsing alone reads `//api.commonly.me/api/mcp/grants/g1` as the
66
+ * PATH `/api.commonly.me/api/...`, which is not the broker's path and measures
67
+ * false — a fail-open, because URL semantics say that string names THIS
68
+ * instance. So a schemeless value is also resolved against the bound instance,
69
+ * and counts when that reading lands on our origin and the broker's path. A
70
+ * foreign host is left alone under either reading; the path reading is what the
71
+ * shipped declarations use, and a record is free to hold the other.
72
+ *
73
+ * Collapsing can only turn a miss into a match, and a match here means WITHHOLD,
74
+ * so it moves in the safe direction; origin equality is still required first, so
75
+ * a foreign server cannot be drawn in by its spelling.
76
+ */
77
+ import { isGrantBrokerUrl } from './adapters/pi-mcp-client.mjs';
78
+ import { ADAPTERS_WITH_GRANT_BROKER, ADAPTERS_WITH_PUBLIC_ONLY_GRANT } from './default-environment.js';
79
+ import { effectiveSandboxTrust } from './environment.js';
80
+ import { PUBLIC_SANDBOX_MODES, resolvePublicSandboxMode } from './sandbox/mode.js';
81
+
82
+ /** One typed code, two emitters (`decidedBy` says which one spoke). */
83
+ export const GRANT_BROKER_REFUSAL_CODE = 'grant_broker_unconfined';
84
+
85
+ /**
86
+ * Every mode an adapter enforces for a public seat: the cli's own public set
87
+ * ({workspace, read-only}) plus `bwrap`, which `resolvePublicSandboxMode`
88
+ * resolves to on Linux and claude implements there. Derived from the cli's
89
+ * constants rather than restated, so a mode added to one is not silently
90
+ * missing from the other.
91
+ */
92
+ export const ENFORCING_MODES = new Set([...PUBLIC_SANDBOX_MODES, 'bwrap']);
93
+
94
+ const URL_PLACEHOLDERS = ['${COMMONLY_API_URL}', '${COMMONLY_INSTANCE_URL}'];
95
+ const PARSE_ANCHOR = 'https://grant-declaration.invalid';
96
+
97
+ /**
98
+ * A bound instance is only trimmed here (`agent.js` does the same). Any
99
+ * trailing slash it carries is deliberately left alone: the doubled slash it
100
+ * would manufacture is answered by `collapseSlashes` below, one mechanism for
101
+ * every spelling rather than two that a mutation cannot tell apart.
102
+ */
103
+ const resolveInstance = (instanceUrl) => (
104
+ typeof instanceUrl === 'string' ? instanceUrl.trim() : ''
105
+ );
106
+
107
+ /**
108
+ * `//api/mcp/grants/g1` and `/api/mcp/grants/g1` are the same path written two
109
+ * ways, and only one of them is ours to withhold — the absolute branch needs it
110
+ * for a doubled slash after the origin, the relative branch for a
111
+ * protocol-relative spelling of the path, i.e. one that names no host (a
112
+ * protocol-relative URL naming a HOST is a different string and is not ours).
113
+ * Collapsing is one-directional (a miss becomes a match) and never widens the
114
+ * ORIGIN check above it.
115
+ */
116
+ const collapseSlashes = (path) => path.replace(/\/{2,}/g, '/');
117
+
118
+ const parseUrl = (value, baseUrl) => {
119
+ try {
120
+ return baseUrl ? new URL(value, baseUrl) : new URL(value);
121
+ } catch {
122
+ return null;
123
+ }
124
+ };
125
+
126
+ /**
127
+ * Does this one declaration name the grant broker WE inject?
128
+ *
129
+ * Resolves the declaration's own placeholders against the instance this daemon
130
+ * is bound to, then asks the same path predicate the adapters use — so the
131
+ * expanded spelling and the placeholder spelling agree, and an entry on another
132
+ * origin does not match just because its path resembles ours.
133
+ */
134
+ export const isOurGrantBroker = (server, { instanceUrl } = {}) => {
135
+ const url = server?.url;
136
+ if (typeof url !== 'string' || url === '') return false;
137
+
138
+ const base = resolveInstance(instanceUrl);
139
+ let resolvedUrl = url;
140
+ for (const placeholder of URL_PLACEHOLDERS) {
141
+ if (!resolvedUrl.includes(placeholder)) continue;
142
+ // A placeholder we cannot resolve is not ours to judge; an entry that is
143
+ // only a placeholder has no origin to compare and no path to match.
144
+ if (base === '') return false;
145
+ resolvedUrl = resolvedUrl.split(placeholder).join(base);
146
+ }
147
+
148
+ const parsed = parseUrl(resolvedUrl);
149
+ const ours = base === '' ? null : parseUrl(base);
150
+ const onOurOrigin = (url) => url.origin + collapseSlashes(url.pathname) + url.search;
151
+
152
+ // Every candidate below is first proven to be on OUR origin, so an entry that
153
+ // merely resembles the broker is left alone; a match means WITHHOLD.
154
+ const candidates = [];
155
+ if (parsed) {
156
+ // An absolute url: our origin, or it is not our grant however its path reads.
157
+ if (ours && parsed.origin === ours.origin) candidates.push(onOurOrigin(parsed));
158
+ } else if (resolvedUrl.startsWith('/')) {
159
+ // A schemeless url, read BOTH ways — as a path (anchored so its path can be
160
+ // judged), and as a protocol-relative reference to the bound instance.
161
+ candidates.push(PARSE_ANCHOR + collapseSlashes(resolvedUrl));
162
+ const against = ours ? parseUrl(resolvedUrl, base) : null;
163
+ if (against && against.origin === ours.origin) candidates.push(onOurOrigin(against));
164
+ }
165
+ // An unparseable value that is neither — an unknown `${...}` expansion, a
166
+ // malformed string — is not an entry this daemon can identify as the broker
167
+ // it injects, and the injected spelling is one of the two handled above.
168
+ return candidates.some((candidate) => isGrantBrokerUrl(candidate));
169
+ };
170
+
171
+ /** True when the environment declares our grant broker at all. */
172
+ export const declaresGrantBroker = (environment, opts) => (
173
+ Array.isArray(environment?.mcp) && environment.mcp.some((server) => isOurGrantBroker(server, opts))
174
+ );
175
+
176
+ /**
177
+ * F175c: the one reason naming (F94) does not lift for an adapter on the grant
178
+ * list. pi, Qwen and Goose hold a grant only on a public seat in a jail, so
179
+ * every declaration of theirs short of that is this reason, not a sandbox
180
+ * reason a consent could waive. Same literal as the server's.
181
+ */
182
+ export const PUBLIC_ONLY_REASON = 'adapter_confines_public_only';
183
+
184
+ const publicOnlyReason = (environment, rule, platform) => {
185
+ const sandbox = environment?.sandbox;
186
+ if (sandbox === null || typeof sandbox !== 'object' || Array.isArray(sandbox)) return PUBLIC_ONLY_REASON;
187
+ if (sandbox.mode === 'none' || effectiveSandboxTrust(sandbox.trust) !== 'public') return PUBLIC_ONLY_REASON;
188
+ // D245: a public declaration on native Windows reads the same for every adapter.
189
+ if (platform === 'win32') return 'sandbox_mode_unenforceable';
190
+ const modes = Object.hasOwn(rule, platform) ? rule[platform] : null;
191
+ if (!modes) return PUBLIC_ONLY_REASON;
192
+ const mode = resolvePublicSandboxMode(sandbox, platform);
193
+ return typeof mode === 'string' && modes.has(mode) ? null : PUBLIC_ONLY_REASON;
194
+ };
195
+
196
+ /**
197
+ * Why this daemon cannot confine a seat running `adapter` with this
198
+ * environment — or `null` when confinement is enforced and the broker may ride.
199
+ * The reason vocabulary mirrors the server's, plus `sandbox_absent`.
200
+ */
201
+ export const confinementReason = (environment, adapter, platform = process.platform) => {
202
+ if (!ADAPTERS_WITH_GRANT_BROKER.has(adapter)) return 'adapter_cannot_confine';
203
+ const publicOnly = ADAPTERS_WITH_PUBLIC_ONLY_GRANT.get(adapter);
204
+ if (publicOnly) return publicOnlyReason(environment, publicOnly, platform);
205
+
206
+ const sandbox = environment?.sandbox;
207
+ if (sandbox === null || typeof sandbox !== 'object' || Array.isArray(sandbox)) return 'sandbox_absent';
208
+ const declared = sandbox;
209
+ if (declared.mode === 'none') return 'sandbox_mode_none';
210
+ if (effectiveSandboxTrust(declared.trust) !== 'public') return 'sandbox_trust_not_public';
211
+ // D245: native Windows has no OS jail for any adapter. Claude there runs
212
+ // only by the user's --unsandboxed (policy without a jail), and a public
213
+ // Codex seat is refused there outright (host-check.js), so neither carries a
214
+ // grant.
215
+ if (platform === 'win32') return 'sandbox_mode_unenforceable';
216
+ const mode = resolvePublicSandboxMode(declared, platform);
217
+ if (typeof mode !== 'string' || !ENFORCING_MODES.has(mode)) return 'sandbox_mode_unenforceable';
218
+ return null;
219
+ };
220
+
221
+ /** "claude, codex or opencode": the broker set, as a remedy names it. */
222
+ const brokerAdapterList = () => {
223
+ const names = [...ADAPTERS_WITH_GRANT_BROKER];
224
+ return names.length > 1 ? `${names.slice(0, -1).join(', ')} or ${names[names.length - 1]}` : names.join('');
225
+ };
226
+
227
+ const detailFor = (reason, adapter, environment, platform = process.platform) => {
228
+ const drop = 'or drop the grant broker from this seat';
229
+ if (reason === 'sandbox_mode_unenforceable' && platform === 'win32') {
230
+ return 'native Windows has no sandbox Space can enforce, so no agent on this computer gets connector grants;'
231
+ + ` run the agent on macOS or Linux to use grants, ${drop}`;
232
+ }
233
+ if (reason === PUBLIC_ONLY_REASON) {
234
+ const jails = Object.keys(ADAPTERS_WITH_PUBLIC_ONLY_GRANT.get(adapter) || {});
235
+ const macOnly = jails.length === 1 && jails[0] === 'darwin';
236
+ return `the seat runs the '${adapter}' adapter, which carries a grant only on a public seat confined by the`
237
+ + `${macOnly ? ' macOS' : ''} sandbox; declare sandbox.trust 'public'${macOnly ? ' and run it on a Mac' : ''},`
238
+ + ` or move it to an adapter that confines this host, ${drop} (naming the agent on the grant does not lift this)`;
239
+ }
240
+ if (reason === 'adapter_cannot_confine') {
241
+ return `the seat runs the '${adapter || 'unresolved'}' adapter, which is not approved to receive the grant broker;`
242
+ + ` move this seat to the ${brokerAdapterList()} adapter, ${drop}`;
243
+ }
244
+ if (reason === 'sandbox_absent') {
245
+ return 'this seat declares no sandbox block and none was derived for it,'
246
+ + ` so it would run with a granter's authority and no confinement; declare sandbox.trust 'public' ${drop}`;
247
+ }
248
+ if (reason === 'sandbox_mode_none') {
249
+ return "the declared sandbox.mode is 'none', which no host confines; declare a confining mode"
250
+ + ` (e.g. 'workspace') ${drop}`;
251
+ }
252
+ if (reason === 'sandbox_mode_unenforceable') {
253
+ const shown = typeof environment?.sandbox?.mode === 'string'
254
+ ? `'${environment.sandbox.mode}'`
255
+ : (JSON.stringify(environment?.sandbox?.mode) ?? String(environment?.sandbox?.mode));
256
+ return `the declared sandbox.mode is ${shown}, which no adapter enforces on any host;`
257
+ + " declare one of 'workspace' / 'read-only' (or 'bwrap' on Linux)" + ` ${drop}`;
258
+ }
259
+ const trust = environment?.sandbox?.trust;
260
+ const shown = trust === undefined ? 'absent' : `'${String(trust)}'`;
261
+ return `the declared sandbox.trust is ${shown}${shown === 'absent' ? '' : ` (effective '${String(effectiveSandboxTrust(trust))}')`},`
262
+ + " and no host confines a seat whose trust is not 'public'; declare sandbox.trust 'public'" + ` ${drop}`;
263
+ };
264
+
265
+ /**
266
+ * The env a seat may actually run with: the same environment when it declares
267
+ * no broker WE injected, or when this daemon can confine it; otherwise the same
268
+ * environment minus the broker, with the refusal handed to `onRefuse`.
269
+ *
270
+ * Identity is preserved when nothing is withheld — the derive sites use
271
+ * `isDeepStrictEqual` against the stored record as their dirty check, so
272
+ * returning a fresh object every tick would rewrite the record and restart the
273
+ * seat forever.
274
+ */
275
+ export const withholdGrantBroker = (environment, adapter, {
276
+ instanceUrl,
277
+ onRefuse,
278
+ platform = process.platform,
279
+ } = {}) => {
280
+ if (!declaresGrantBroker(environment, { instanceUrl })) return environment;
281
+ const reason = confinementReason(environment, adapter, platform);
282
+ if (!reason) return environment;
283
+
284
+ // F94: the server flags a broker whose grant names this seat (the granter's
285
+ // own agent, with consent) so it rides a seat no sandbox confines. Delivery
286
+ // only; the broker checks the naming on every call. Never on native Windows,
287
+ // and never for an adapter not approved for the broker.
288
+ const rides = (server) => server?.byoNamed === true
289
+ && reason !== 'adapter_cannot_confine' && reason !== PUBLIC_ONLY_REASON && platform !== 'win32';
290
+ const isWithheld = (server) => isOurGrantBroker(server, { instanceUrl }) && !rides(server);
291
+ if (!environment.mcp.some(isWithheld)) return environment;
292
+
293
+ const kept = environment.mcp.filter((server) => !isWithheld(server));
294
+ const withheld = environment.mcp
295
+ .filter(isWithheld)
296
+ .map((server) => (typeof server?.name === 'string' && server.name ? server.name : '(unnamed)'));
297
+ if (typeof onRefuse === 'function') {
298
+ onRefuse({
299
+ code: GRANT_BROKER_REFUSAL_CODE,
300
+ decidedBy: 'daemon',
301
+ reason,
302
+ detail: detailFor(reason, adapter, environment, platform),
303
+ }, withheld);
304
+ }
305
+ return { ...environment, mcp: kept };
306
+ };
307
+
308
+ /**
309
+ * Merge the server's grant-broker entries into a seat environment, the way the
310
+ * daemon's work list does it (`GET /api/agent-binding/assigned` in
311
+ * `backend/routes/agentBinding.ts`): an existing entry with a broker's name is
312
+ * replaced, the brokers are appended, and a seat with no environment gets one
313
+ * holding only them. Used by `agent run` without a daemon (F150), whose
314
+ * `GET /api/agents/runtime/grant-brokers` returns the same per-seat projection.
315
+ *
316
+ * Merge only. The caller runs `withholdGrantBroker` on the result, so the
317
+ * client-side refusal still applies to whatever the server sent.
318
+ * Identity is preserved when there is nothing to merge.
319
+ */
320
+ export const mergeGrantBrokers = (environment, servers) => {
321
+ const brokers = Array.isArray(servers)
322
+ ? servers.filter((server) => server && typeof server === 'object' && !Array.isArray(server))
323
+ : [];
324
+ if (!brokers.length) return environment;
325
+ if (!environment) return { mcp: brokers };
326
+ const existing = Array.isArray(environment.mcp) ? environment.mcp : [];
327
+ return {
328
+ ...environment,
329
+ mcp: [
330
+ ...existing.filter((server) => !brokers.some((broker) => broker.name === server?.name)),
331
+ ...brokers,
332
+ ],
333
+ };
334
+ };
335
+
336
+ export default {
337
+ GRANT_BROKER_REFUSAL_CODE,
338
+ PUBLIC_ONLY_REASON,
339
+ ENFORCING_MODES,
340
+ isOurGrantBroker,
341
+ declaresGrantBroker,
342
+ confinementReason,
343
+ withholdGrantBroker,
344
+ mergeGrantBrokers,
345
+ };
@@ -0,0 +1,238 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'fs';
2
+ import { dirname, join, resolve as pathResolve, isAbsolute, relative } from 'path';
3
+ import { homedir } from 'os';
4
+ import { createHash } from 'crypto';
5
+ import { CREDENTIAL_FILE_VAR } from './credential-file.js';
6
+
7
+ export const HOOK_EVENTS = ['PreToolUse', 'PostToolUse', 'Stop', 'SubagentStop'];
8
+ export const DEFAULT_HOOK_TIMEOUT_MS = 3000;
9
+ export const MAX_HOOK_TIMEOUT_MS = 5000;
10
+
11
+ export const clampHookTimeoutMs = (value) => Math.min(
12
+ MAX_HOOK_TIMEOUT_MS,
13
+ Math.max(250, Math.trunc(Number.isFinite(Number(value)) ? Number(value) : DEFAULT_HOOK_TIMEOUT_MS)),
14
+ );
15
+
16
+ const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
17
+
18
+ /**
19
+ * Keep hook commands free of bearer material. The dispatcher reads
20
+ * COMMONLY_AGENT_TOKEN from the process environment at invocation time.
21
+ */
22
+ export const buildHookCommand = ({ agentName, timeoutMs = DEFAULT_HOOK_TIMEOUT_MS }) => (
23
+ `commonly agent hooks-forward ${shellQuote(agentName)} --timeout ${clampHookTimeoutMs(timeoutMs)}`
24
+ );
25
+
26
+ const hookEntry = ({ agentName, timeoutMs }) => ({
27
+ matcher: '',
28
+ hooks: [{
29
+ type: 'command',
30
+ command: buildHookCommand({ agentName, timeoutMs }),
31
+ // Claude interprets this as seconds. Keep it short so a dead backend
32
+ // cannot stall every tool call for the 600s default.
33
+ timeout: Math.ceil(clampHookTimeoutMs(timeoutMs) / 1000),
34
+ }],
35
+ });
36
+
37
+ const stableJson = (value) => {
38
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
39
+ if (value && typeof value === 'object') {
40
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
41
+ }
42
+ return JSON.stringify(value);
43
+ };
44
+
45
+ const digestArgs = (value) => createHash('sha256').update(stableJson(value ?? {})).digest('hex');
46
+
47
+ /** Resolve a caller-side path and emit the repo-relative POSIX wire form. */
48
+ const localPath = (value, root = process.cwd()) => {
49
+ if (typeof value !== 'string' || !value.trim() || value.includes('\0')) return null;
50
+ if (value.trim().split(/[\\/]/).includes('..')) return null;
51
+ const resolvedRoot = pathResolve(root);
52
+ const rootReal = (() => {
53
+ try { return realpathSync(resolvedRoot); } catch { return resolvedRoot; }
54
+ })();
55
+ const absolute = isAbsolute(value) ? pathResolve(value) : pathResolve(resolvedRoot, value);
56
+ if (absolute !== resolvedRoot && !absolute.startsWith(`${resolvedRoot}/`)) return null;
57
+ let current = absolute;
58
+ const suffix = [];
59
+ while (!existsSync(current)) {
60
+ const parent = dirname(current);
61
+ if (parent === current) return null;
62
+ suffix.unshift(current.slice(parent.length + 1));
63
+ current = parent;
64
+ }
65
+ let resolved;
66
+ try { resolved = realpathSync(current); } catch { resolved = current; }
67
+ const candidate = pathResolve(resolved, ...suffix);
68
+ if (candidate !== rootReal && !candidate.startsWith(`${rootReal}/`)) return null;
69
+ const repoPath = relative(rootReal, candidate).replaceAll('\\', '/');
70
+ return repoPath || '.';
71
+ };
72
+
73
+ const inputPaths = (input = {}) => {
74
+ const values = [input.path, input.file_path, input.filePath, input.target_file, input.paths]
75
+ .flatMap((value) => Array.isArray(value) ? value : [value]);
76
+ return values.filter((value) => typeof value === 'string' && value.trim());
77
+ };
78
+
79
+ /** Strip raw tool arguments before they leave the local harness. */
80
+ export const sanitizeHookPayload = (payload = {}, { cwd = process.cwd() } = {}) => {
81
+ const event = payload.event || payload.hook_event_name || payload.event_name || '';
82
+ const input = payload.tool_input && typeof payload.tool_input === 'object' ? payload.tool_input : {};
83
+ const tool = payload.tool || payload.tool_name || payload.toolName;
84
+ const root = payload.cwd || cwd;
85
+ const declaredPaths = [payload.paths, payload.resolvedPaths]
86
+ .flatMap((value) => Array.isArray(value) ? value : [value])
87
+ .filter((value) => typeof value === 'string');
88
+ const paths = [...inputPaths(input), ...declaredPaths]
89
+ .map((value) => localPath(value, root)).filter(Boolean);
90
+ return {
91
+ ...(event ? { event } : {}),
92
+ ...(payload.eventId || payload.event_id ? { eventId: payload.eventId || payload.event_id } : {}),
93
+ ...(typeof tool === 'string' && tool ? { tool } : {}),
94
+ argsDigest: payload.argsDigest || payload.args_digest || digestArgs(input),
95
+ ...(paths.length > 0 ? { paths: Array.from(new Set(paths)) } : {}),
96
+ };
97
+ };
98
+
99
+ /** Merge Commonly hooks into settings while preserving every user setting. */
100
+ export const mergeHooksConfig = (
101
+ existing = {},
102
+ { agentName, events = HOOK_EVENTS, timeoutMs = DEFAULT_HOOK_TIMEOUT_MS } = {},
103
+ ) => {
104
+ if (!agentName) throw new Error('agentName is required');
105
+ const source = existing && typeof existing === 'object' ? existing : {};
106
+ const hooks = source.hooks && typeof source.hooks === 'object' ? { ...source.hooks } : {};
107
+ for (const event of events) {
108
+ const prior = Array.isArray(hooks[event]) ? hooks[event] : [];
109
+ // Replace only the entry generated for this agent; unrelated matchers and
110
+ // command hooks remain byte-for-byte represented in the resulting JSON.
111
+ const retained = prior.filter((entry) => !entry?.hooks?.some((h) => (
112
+ h?.command?.includes(`commonly agent hooks-forward '${String(agentName).replaceAll("'", "'\\''")}'`)
113
+ )));
114
+ hooks[event] = [...retained, hookEntry({ agentName, timeoutMs })];
115
+ }
116
+ return { ...source, hooks };
117
+ };
118
+
119
+ export const settingsPathForScope = ({ scope = 'project', cwd = process.cwd(), home = homedir() } = {}) => {
120
+ if (scope === 'user') return join(home, '.claude', 'settings.json');
121
+ if (scope !== 'project') throw new Error("scope must be 'project' or 'user'");
122
+ return join(cwd, '.claude', 'settings.local.json');
123
+ };
124
+
125
+ export const readJsonSettings = (filePath, fsApi = { readFileSync, existsSync }) => {
126
+ if (!fsApi.existsSync(filePath)) return {};
127
+ try {
128
+ const parsed = JSON.parse(fsApi.readFileSync(filePath, 'utf8'));
129
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
130
+ } catch (error) {
131
+ throw new Error(`Could not parse ${filePath}: ${error.message}`);
132
+ }
133
+ };
134
+
135
+ export const writeHooksConfig = ({
136
+ filePath,
137
+ agentName,
138
+ scope = 'project',
139
+ cwd = process.cwd(),
140
+ home = homedir(),
141
+ timeoutMs = DEFAULT_HOOK_TIMEOUT_MS,
142
+ fsApi = { readFileSync, writeFileSync, mkdirSync, existsSync },
143
+ } = {}) => {
144
+ const target = filePath || settingsPathForScope({ scope, cwd, home });
145
+ const existing = readJsonSettings(target, fsApi);
146
+ const next = mergeHooksConfig(existing, { agentName, timeoutMs });
147
+ fsApi.mkdirSync(dirname(target), { recursive: true });
148
+ fsApi.writeFileSync(target, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
149
+ return { filePath: target, config: next };
150
+ };
151
+
152
+ /**
153
+ * Execute one hook event. It is intentionally dependency-light so the
154
+ * generated Claude command works on a fresh CLI install (Node 20's fetch).
155
+ * On any transport failure, fail open with no output. A deny is only the
156
+ * positive decision returned by the Commonly endpoint; D7 does not let a
157
+ * missing/slow ledger become an accidental write blocker.
158
+ */
159
+ /**
160
+ * The hook's credential, resolved the way the runtime it runs inside resolves it.
161
+ *
162
+ * A hook is a child process of the seat's runtime, so it sees that runtime's
163
+ * environment — which is exactly the environment TASK-083 emptied of the token.
164
+ * Reading only COMMONLY_AGENT_TOKEN would therefore leave every hook on a
165
+ * migrated seat with no credential, and because this path fails open by design
166
+ * (the `hook_unavailable` return below), the symptom would be a tool-policy hook
167
+ * that silently stopped deciding anything. So the launcher file comes first, and
168
+ * the value variable stays as the fallback for a seat whose declaration has not
169
+ * migrated.
170
+ *
171
+ * Unlike the MCP reader, a declared-but-unreadable file does NOT throw here: a
172
+ * hook that dies is a hook the runtime reports as broken, and this function's
173
+ * documented posture is to fail open rather than become an accidental blocker.
174
+ */
175
+ export const resolveHookToken = ({
176
+ env = process.env,
177
+ readTokenFile = (path) => readFileSync(path, 'utf8'),
178
+ } = {}) => {
179
+ const path = env[CREDENTIAL_FILE_VAR];
180
+ if (typeof path === 'string' && path.trim() !== '') {
181
+ try {
182
+ const fromFile = readTokenFile(path).trim();
183
+ if (fromFile) return fromFile;
184
+ } catch { /* fall through to the value channel */ }
185
+ }
186
+ return env.COMMONLY_AGENT_TOKEN;
187
+ };
188
+
189
+ export const forwardHookEvent = async ({
190
+ endpoint,
191
+ token = resolveHookToken(),
192
+ input = '',
193
+ timeoutMs = DEFAULT_HOOK_TIMEOUT_MS,
194
+ fetchImpl = globalThis.fetch,
195
+ stdout = (value) => process.stdout.write(value),
196
+ } = {}) => {
197
+ let payload;
198
+ try { payload = typeof input === 'string' ? JSON.parse(input || '{}') : input; } catch {
199
+ payload = {};
200
+ }
201
+ const event = payload?.event || payload?.hook_event_name || payload?.event_name || '';
202
+ const preTool = event === 'PreToolUse';
203
+ if (!endpoint || !token || !fetchImpl) {
204
+ return { acknowledged: false, reason: 'hook_unavailable' };
205
+ }
206
+ const controller = new AbortController();
207
+ const timer = setTimeout(() => controller.abort(), clampHookTimeoutMs(timeoutMs));
208
+ try {
209
+ const response = await fetchImpl(endpoint, {
210
+ method: 'POST',
211
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
212
+ body: JSON.stringify(sanitizeHookPayload(payload)),
213
+ signal: controller.signal,
214
+ });
215
+ const body = await response.json().catch(() => ({}));
216
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
217
+ if (preTool && body.permissionDecision === 'deny') {
218
+ // Claude Code reads a PreToolUse decision only from `hookSpecificOutput`. The top-level
219
+ // `{permissionDecision}` this printed before was ignored and the tool ran (measured on
220
+ // Claude Code 2.1.296, 2026-10-14, issue #2145).
221
+ stdout(JSON.stringify({
222
+ hookSpecificOutput: {
223
+ hookEventName: 'PreToolUse',
224
+ permissionDecision: 'deny',
225
+ ...(body.reason ? { permissionDecisionReason: body.reason } : {}),
226
+ },
227
+ }));
228
+ }
229
+ return body;
230
+ } catch (error) {
231
+ // Deliberately silent and exit-0 for every failure. Claude's default is
232
+ // fail-open, and an unavailable advisory hook must not block a tool.
233
+ void error;
234
+ return { acknowledged: false, reason: 'hook_unavailable' };
235
+ } finally {
236
+ clearTimeout(timer);
237
+ }
238
+ };