hivecode-mcp 0.14.1 → 0.14.2

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 (72) hide show
  1. package/HIVE_RULES.md +98 -98
  2. package/LICENSE +21 -21
  3. package/README.md +421 -421
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -310
  6. package/docs/GITHUB_WORKFLOW.md +394 -394
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -157
  9. package/docs/QUICKSTART.md +266 -266
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +474 -474
  12. package/docs/TROUBLESHOOTING.md +105 -105
  13. package/hive-activity.js +336 -336
  14. package/hive-authors.js +46 -44
  15. package/hive-await.js +230 -230
  16. package/hive-branch.js +608 -607
  17. package/hive-busy.js +88 -88
  18. package/hive-coord.js +110 -110
  19. package/hive-gate.js +135 -135
  20. package/hive-github.js +3 -1
  21. package/hive-hook-install.js +179 -179
  22. package/hive-instructions.js +74 -74
  23. package/hive-land.js +695 -691
  24. package/hive-listen.js +81 -81
  25. package/hive-mailbox.js +520 -520
  26. package/hive-mcp-http.js +591 -591
  27. package/hive-mcp.js +33 -6
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +351 -351
  30. package/hive-notify.js +234 -233
  31. package/hive-obligations.js +247 -247
  32. package/hive-poll-proto.js +50 -50
  33. package/hive-pr-staleness.js +189 -189
  34. package/hive-pr.js +600 -512
  35. package/hive-privacy.js +327 -327
  36. package/hive-receipts.js +233 -233
  37. package/hive-relay-health.js +234 -234
  38. package/hive-repos.js +430 -430
  39. package/hive-room-key.js +210 -210
  40. package/hive-room-mode.js +1668 -1668
  41. package/hive-room-watcher.js +562 -561
  42. package/hive-secrets.js +89 -89
  43. package/hive-session.js +114 -114
  44. package/hive-setup.js +560 -560
  45. package/hive-slack-agent.js +408 -408
  46. package/hive-slack.js +2384 -2367
  47. package/hive-stdio.js +172 -172
  48. package/hive-stop-hook.js +293 -293
  49. package/hive-throttle.js +118 -118
  50. package/hive-time.js +71 -71
  51. package/hive-transport.js +578 -578
  52. package/hive-update.js +58 -58
  53. package/hive-version.js +110 -110
  54. package/hive-webhook.js +55 -54
  55. package/icr-merge.js +58 -58
  56. package/icr.js +851 -851
  57. package/lang-brace.js +305 -305
  58. package/lang-js.js +651 -651
  59. package/lang-json.js +211 -211
  60. package/lang-python.js +588 -588
  61. package/lang-ruby.js +230 -230
  62. package/lang-toml.js +136 -136
  63. package/lang-yaml.js +183 -183
  64. package/limits.js +413 -413
  65. package/package.json +1 -1
  66. package/reconcile.cjs +621 -621
  67. package/slack-app-manifest-agent.json +61 -61
  68. package/slack-app-manifest-inbound.json +36 -36
  69. package/slack-app-manifest.json +27 -27
  70. package/substrate.js +252 -252
  71. package/sync.js +169 -11
  72. package/token.js +201 -201
package/token.js CHANGED
@@ -1,201 +1,201 @@
1
- // Hivecode access tokens — a tiny, dependency-free JWT (JWS) implementation used
2
- // by the relay (verify) and the hive-token CLI (mint). No external libs: just
3
- // Node's crypto, so there's no supply-chain surface for the thing that guards
4
- // access. Supports HS256 (shared secret — self-host) and RS256 (public/private
5
- // key — a hosted control plane signs, relays verify with the public key).
6
- //
7
- // Token shape (claims):
8
- // {
9
- // iss, sub, // issuer, principal id
10
- // name, kind, // display name + 'human' | 'ai'
11
- // owner, // (agents) the human who may approve its tasks
12
- // scopes: [ // what this principal may reach
13
- // { room: "room-id" | "acme/*" | "*", role: "admin|maintainer|writer|reader|agent",
14
- // paths: ["src/**", "!**/*.env"] } // path globs are enforced in a later phase
15
- // ],
16
- // iat, exp, // issued-at, expiry (unix seconds)
17
- // jti // unique id, for revocation
18
- // }
19
-
20
- import crypto from 'crypto'
21
- import ignore from 'ignore'
22
-
23
- const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
24
- const b64urlToBuf = (s) => Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
25
- const enc = (obj) => b64url(JSON.stringify(obj))
26
-
27
- // --- mint ---
28
- // signWith: { secret } -> HS256, or { privateKey } -> RS256.
29
- export function sign(payload, signWith) {
30
- const useRsa = !!signWith.privateKey
31
- const header = { alg: useRsa ? 'RS256' : 'HS256', typ: 'JWT' }
32
- const data = enc(header) + '.' + enc(payload)
33
- let sig
34
- if (useRsa) sig = crypto.sign('RSA-SHA256', Buffer.from(data), signWith.privateKey)
35
- else sig = crypto.createHmac('sha256', signWith.secret).update(data).digest()
36
- return data + '.' + b64url(sig)
37
- }
38
-
39
- // --- verify ---
40
- // verifyWith: { secret } and/or { publicKey }, plus optional { alg } to PIN the
41
- // accepted algorithm (defense-in-depth against alg-confusion: a deployment that
42
- // only issues RS256 can refuse every HS256 token outright). The token's
43
- // header.alg selects which key is used. Returns { ok, payload } or { ok:false, error }.
44
- export function verify(token, verifyWith = {}, now = Math.floor(Date.now() / 1000)) {
45
- if (!token || typeof token !== 'string') return { ok: false, error: 'no token' }
46
- const parts = token.split('.')
47
- if (parts.length !== 3) return { ok: false, error: 'malformed token' }
48
- const [h, p, s] = parts
49
- if (!s) return { ok: false, error: 'unsigned token' } // "h.p." — empty signature segment
50
- let header, payload
51
- try { header = JSON.parse(b64urlToBuf(h).toString('utf8')); payload = JSON.parse(b64urlToBuf(p).toString('utf8')) }
52
- catch { return { ok: false, error: 'undecodable token' } }
53
- if (!payload || typeof payload !== 'object') return { ok: false, error: 'bad payload' }
54
- // Optional algorithm pinning: reject anything the relay didn't opt into.
55
- if (verifyWith.alg && header.alg !== verifyWith.alg) return { ok: false, error: `alg ${header.alg} not allowed (pinned to ${verifyWith.alg})` }
56
- const data = h + '.' + p
57
- const sig = b64urlToBuf(s)
58
-
59
- if (header.alg === 'HS256') {
60
- if (!verifyWith.secret) return { ok: false, error: 'HS256 token but relay has no secret configured' }
61
- const expected = crypto.createHmac('sha256', verifyWith.secret).update(data).digest()
62
- if (sig.length !== expected.length || !crypto.timingSafeEqual(sig, expected)) return { ok: false, error: 'bad signature' }
63
- } else if (header.alg === 'RS256') {
64
- if (!verifyWith.publicKey) return { ok: false, error: 'RS256 token but relay has no public key configured' }
65
- let valid = false
66
- try { valid = crypto.verify('RSA-SHA256', Buffer.from(data), verifyWith.publicKey, sig) } catch { valid = false }
67
- if (!valid) return { ok: false, error: 'bad signature' }
68
- } else {
69
- return { ok: false, error: `unsupported alg: ${header.alg}` }
70
- }
71
-
72
- // exp/nbf must be NUMBERS. A non-numeric exp would make `now >= exp` false and
73
- // the token would never expire — fail closed and reject it instead.
74
- if (payload.exp != null && (typeof payload.exp !== 'number' || now >= payload.exp)) return { ok: false, error: 'token expired' }
75
- if (payload.nbf != null && (typeof payload.nbf !== 'number' || now < payload.nbf)) return { ok: false, error: 'token not yet valid' }
76
- return { ok: true, payload }
77
- }
78
-
79
- // --- room naming for the per-file subdoc model (Phase 2) ---
80
- // A project is a PARENT room (manifest + coordination) plus one room per file,
81
- // named `<baseRoom>␁<path>`. Encoding the path in the room name lets the relay
82
- // authorize per-path (Phase 3) and lets a client connect only to the files it
83
- // may load. FILE_SEP is a control char that never appears in room ids or paths.
84
- export const FILE_SEP = ''
85
- export const fileRoom = (baseRoom, relPath) => baseRoom + FILE_SEP + relPath
86
- export const baseRoomOf = (room) => { const i = room.indexOf(FILE_SEP); return i < 0 ? room : room.slice(0, i) }
87
- export const pathOf = (room) => { const i = room.indexOf(FILE_SEP); return i < 0 ? null : room.slice(i + 1) }
88
-
89
- // Does a scope's room pattern authorize this room? Supports exact, "*" (all),
90
- // and a trailing "*" prefix wildcard (e.g. "acme/*" matches "acme/api").
91
- export function roomMatches(pattern, room) {
92
- if (!pattern) return false
93
- if (pattern === '*' || pattern === room) return true
94
- if (pattern.endsWith('*')) return room.startsWith(pattern.slice(0, -1))
95
- return false
96
- }
97
-
98
- // Find the scope (and role) that authorizes `room`, or null. When several scopes
99
- // match, the MOST SPECIFIC wins (exact room > longer prefix > "*"). This stops a
100
- // broad "*" scope listed first from shadowing a tighter, intentionally-narrower
101
- // scope for the same room (which would silently over-grant). Ties keep the first.
102
- export function scopeForRoom(payload, room) {
103
- const scopes = Array.isArray(payload && payload.scopes) ? payload.scopes : []
104
- let best = null, bestScore = -1
105
- for (const sc of scopes) {
106
- if (!sc || !roomMatches(sc.room, room)) continue
107
- const score = sc.room === room ? Infinity : sc.room === '*' ? 0 : sc.room.length // exact > longer prefix > "*"
108
- if (score > bestScore) { bestScore = score; best = sc }
109
- }
110
- return best
111
- }
112
-
113
- // Is a remote-supplied path safe to materialize on this client's disk? A room
114
- // participant controls the manifest (and thus file paths); without this, a
115
- // malicious entry like "../../etc/cron.d/x", "/etc/passwd", or "C:\\Windows\\..."
116
- // would be written OUTSIDE the project root by every client that syncs it. We
117
- // reject absolute paths, drive letters, UNC, any ".." segment, control chars
118
- // (incl. the FILE_SEP separator and NULs), and absurd lengths. Enforced on the
119
- // client (before writing) AND the relay (rejects the file-room) — defense in depth.
120
- export function isSafeRelPath(relPath) {
121
- if (typeof relPath !== 'string' || relPath.length === 0 || relPath.length > 1024) return false
122
- if (/[\u0000-\u001f]/.test(relPath)) return false // control chars: NUL, FILE_SEP (U+0001), etc.
123
- const p = relPath.replace(/\\/g, '/') // normalize Windows separators
124
- if (p.startsWith('/')) return false // POSIX absolute
125
- if (p.startsWith('//')) return false // UNC-ish
126
- if (/^[a-zA-Z]:/.test(p)) return false // Windows drive letter (C:...)
127
- if (p.split('/').some((seg) => seg === '..')) return false // traversal
128
- return true
129
- }
130
-
131
- // Phase 3: is a file path allowed by a scope's path globs? Gitignore-style globs
132
- // (via the `ignore` lib): positive patterns grant subtrees, "!"-prefixed patterns
133
- // deny. No globs (or none given) = the whole room is allowed (Phase-2 behavior).
134
- // pathAllowed(["src/**", "!**/*.env"], "src/app.js") -> true
135
- // pathAllowed(["src/**"], "secrets/k.txt") -> false
136
- export function pathAllowed(globs, relPath) {
137
- if (!Array.isArray(globs) || globs.length === 0) return true
138
- // The `ignore` lib throws on a "./"-prefixed path; normalize it away. And guard
139
- // every match call so a malformed input can NEVER throw into the relay's auth
140
- // gate (a throw there must fail closed, not crash the process).
141
- const p = String(relPath).replace(/^(\.\/)+/, '')
142
- const pos = [], neg = []
143
- for (const g of globs) { if (typeof g !== 'string' || !g) continue; if (g[0] === '!') neg.push(g.slice(1)); else pos.push(g) }
144
- const matches = (pats) => { try { return pats.length > 0 && ignore().add(pats).ignores(p) } catch { return false } }
145
- const inAllow = pos.length === 0 ? true : matches(pos)
146
- return inAllow && !matches(neg)
147
- }
148
-
149
- // Phase 5: within a scope, which VISIBLE paths are also WRITABLE. `writePaths` is
150
- // an optional subset of `paths`: a file the scope can see is read-only unless it
151
- // ALSO matches writePaths. This is what lets one agent read frontend/ for context
152
- // but only edit backend/. Semantics, chosen to fail safe:
153
- // - role 'reader' -> never writable (whole grant is view-only)
154
- // - no writePaths key -> every visible path is writable (back-compat: a plain
155
- // writer/agent edits everything it can see)
156
- // - writePaths: [] -> nothing writable (explicit view-only everywhere)
157
- // - writePaths: [globs] -> writable IFF the path matches the globs
158
- // The relay enforces this by connecting an out-of-write-scope file-room as a
159
- // reader (so makeReadOnly drops its writes); clients use it for read-only UX.
160
- export function writeAllowed(scope, relPath) {
161
- if (!scope) return false
162
- if ((scope.role || 'writer') === 'reader') return false
163
- const wp = scope.writePaths
164
- if (wp == null) return true
165
- if (!Array.isArray(wp) || wp.length === 0) return false
166
- return pathAllowed(wp, relPath)
167
- }
168
-
169
- // --- self-certifying secured rooms (no server-side key registry, no secret files) ---
170
- // A secured room's id embeds a fingerprint of the OWNER's public key:
171
- // hs_<fp>_<rand> fp = base64url(sha256(spki-DER)).slice(0, 22)
172
- // Every token for the room carries the owner's public key (claim `pk`) and is
173
- // signed by the matching private key (RS256). The relay trusts a token IFF the
174
- // embedded key's fingerprint equals the one in the room id — so trust is anchored
175
- // in the room id itself and the relay needs to store nothing. Only the owner (who
176
- // holds the private key, kept in the editor's secure storage) can mint valid
177
- // tokens. This is what lets the hosted, stateless, free-tier relay enforce
178
- // per-folder access with no registration step and no secret handed to anyone.
179
- export function keyFingerprint(publicKeyPem) {
180
- try {
181
- const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' })
182
- return crypto.createHash('sha256').update(der).digest('base64url').slice(0, 22)
183
- } catch { return null }
184
- }
185
- export function roomFingerprint(room) {
186
- const m = /^hs_([A-Za-z0-9_-]{22})_/.exec(room || '')
187
- return m ? m[1] : null
188
- }
189
- export const isSecuredRoom = (room) => roomFingerprint(room) != null
190
- export function makeSecuredRoomId(publicKeyPem, rand) {
191
- const fp = keyFingerprint(publicKeyPem)
192
- return fp ? `hs_${fp}_${rand}` : null
193
- }
194
-
195
- // Read a token's payload WITHOUT verifying — for a client to inspect its OWN grant
196
- // (e.g. which paths it may open) for UX. NEVER use for access decisions; the relay
197
- // is the enforcer (it verifies the signature).
198
- export function decodeUnsafe(token) {
199
- try { return JSON.parse(Buffer.from(String(token).split('.')[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')) }
200
- catch { return null }
201
- }
1
+ // Hivecode access tokens — a tiny, dependency-free JWT (JWS) implementation used
2
+ // by the relay (verify) and the hive-token CLI (mint). No external libs: just
3
+ // Node's crypto, so there's no supply-chain surface for the thing that guards
4
+ // access. Supports HS256 (shared secret — self-host) and RS256 (public/private
5
+ // key — a hosted control plane signs, relays verify with the public key).
6
+ //
7
+ // Token shape (claims):
8
+ // {
9
+ // iss, sub, // issuer, principal id
10
+ // name, kind, // display name + 'human' | 'ai'
11
+ // owner, // (agents) the human who may approve its tasks
12
+ // scopes: [ // what this principal may reach
13
+ // { room: "room-id" | "acme/*" | "*", role: "admin|maintainer|writer|reader|agent",
14
+ // paths: ["src/**", "!**/*.env"] } // path globs are enforced in a later phase
15
+ // ],
16
+ // iat, exp, // issued-at, expiry (unix seconds)
17
+ // jti // unique id, for revocation
18
+ // }
19
+
20
+ import crypto from 'crypto'
21
+ import ignore from 'ignore'
22
+
23
+ const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
24
+ const b64urlToBuf = (s) => Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
25
+ const enc = (obj) => b64url(JSON.stringify(obj))
26
+
27
+ // --- mint ---
28
+ // signWith: { secret } -> HS256, or { privateKey } -> RS256.
29
+ export function sign(payload, signWith) {
30
+ const useRsa = !!signWith.privateKey
31
+ const header = { alg: useRsa ? 'RS256' : 'HS256', typ: 'JWT' }
32
+ const data = enc(header) + '.' + enc(payload)
33
+ let sig
34
+ if (useRsa) sig = crypto.sign('RSA-SHA256', Buffer.from(data), signWith.privateKey)
35
+ else sig = crypto.createHmac('sha256', signWith.secret).update(data).digest()
36
+ return data + '.' + b64url(sig)
37
+ }
38
+
39
+ // --- verify ---
40
+ // verifyWith: { secret } and/or { publicKey }, plus optional { alg } to PIN the
41
+ // accepted algorithm (defense-in-depth against alg-confusion: a deployment that
42
+ // only issues RS256 can refuse every HS256 token outright). The token's
43
+ // header.alg selects which key is used. Returns { ok, payload } or { ok:false, error }.
44
+ export function verify(token, verifyWith = {}, now = Math.floor(Date.now() / 1000)) {
45
+ if (!token || typeof token !== 'string') return { ok: false, error: 'no token' }
46
+ const parts = token.split('.')
47
+ if (parts.length !== 3) return { ok: false, error: 'malformed token' }
48
+ const [h, p, s] = parts
49
+ if (!s) return { ok: false, error: 'unsigned token' } // "h.p." — empty signature segment
50
+ let header, payload
51
+ try { header = JSON.parse(b64urlToBuf(h).toString('utf8')); payload = JSON.parse(b64urlToBuf(p).toString('utf8')) }
52
+ catch { return { ok: false, error: 'undecodable token' } }
53
+ if (!payload || typeof payload !== 'object') return { ok: false, error: 'bad payload' }
54
+ // Optional algorithm pinning: reject anything the relay didn't opt into.
55
+ if (verifyWith.alg && header.alg !== verifyWith.alg) return { ok: false, error: `alg ${header.alg} not allowed (pinned to ${verifyWith.alg})` }
56
+ const data = h + '.' + p
57
+ const sig = b64urlToBuf(s)
58
+
59
+ if (header.alg === 'HS256') {
60
+ if (!verifyWith.secret) return { ok: false, error: 'HS256 token but relay has no secret configured' }
61
+ const expected = crypto.createHmac('sha256', verifyWith.secret).update(data).digest()
62
+ if (sig.length !== expected.length || !crypto.timingSafeEqual(sig, expected)) return { ok: false, error: 'bad signature' }
63
+ } else if (header.alg === 'RS256') {
64
+ if (!verifyWith.publicKey) return { ok: false, error: 'RS256 token but relay has no public key configured' }
65
+ let valid = false
66
+ try { valid = crypto.verify('RSA-SHA256', Buffer.from(data), verifyWith.publicKey, sig) } catch { valid = false }
67
+ if (!valid) return { ok: false, error: 'bad signature' }
68
+ } else {
69
+ return { ok: false, error: `unsupported alg: ${header.alg}` }
70
+ }
71
+
72
+ // exp/nbf must be NUMBERS. A non-numeric exp would make `now >= exp` false and
73
+ // the token would never expire — fail closed and reject it instead.
74
+ if (payload.exp != null && (typeof payload.exp !== 'number' || now >= payload.exp)) return { ok: false, error: 'token expired' }
75
+ if (payload.nbf != null && (typeof payload.nbf !== 'number' || now < payload.nbf)) return { ok: false, error: 'token not yet valid' }
76
+ return { ok: true, payload }
77
+ }
78
+
79
+ // --- room naming for the per-file subdoc model (Phase 2) ---
80
+ // A project is a PARENT room (manifest + coordination) plus one room per file,
81
+ // named `<baseRoom>␁<path>`. Encoding the path in the room name lets the relay
82
+ // authorize per-path (Phase 3) and lets a client connect only to the files it
83
+ // may load. FILE_SEP is a control char that never appears in room ids or paths.
84
+ export const FILE_SEP = ''
85
+ export const fileRoom = (baseRoom, relPath) => baseRoom + FILE_SEP + relPath
86
+ export const baseRoomOf = (room) => { const i = room.indexOf(FILE_SEP); return i < 0 ? room : room.slice(0, i) }
87
+ export const pathOf = (room) => { const i = room.indexOf(FILE_SEP); return i < 0 ? null : room.slice(i + 1) }
88
+
89
+ // Does a scope's room pattern authorize this room? Supports exact, "*" (all),
90
+ // and a trailing "*" prefix wildcard (e.g. "acme/*" matches "acme/api").
91
+ export function roomMatches(pattern, room) {
92
+ if (!pattern) return false
93
+ if (pattern === '*' || pattern === room) return true
94
+ if (pattern.endsWith('*')) return room.startsWith(pattern.slice(0, -1))
95
+ return false
96
+ }
97
+
98
+ // Find the scope (and role) that authorizes `room`, or null. When several scopes
99
+ // match, the MOST SPECIFIC wins (exact room > longer prefix > "*"). This stops a
100
+ // broad "*" scope listed first from shadowing a tighter, intentionally-narrower
101
+ // scope for the same room (which would silently over-grant). Ties keep the first.
102
+ export function scopeForRoom(payload, room) {
103
+ const scopes = Array.isArray(payload && payload.scopes) ? payload.scopes : []
104
+ let best = null, bestScore = -1
105
+ for (const sc of scopes) {
106
+ if (!sc || !roomMatches(sc.room, room)) continue
107
+ const score = sc.room === room ? Infinity : sc.room === '*' ? 0 : sc.room.length // exact > longer prefix > "*"
108
+ if (score > bestScore) { bestScore = score; best = sc }
109
+ }
110
+ return best
111
+ }
112
+
113
+ // Is a remote-supplied path safe to materialize on this client's disk? A room
114
+ // participant controls the manifest (and thus file paths); without this, a
115
+ // malicious entry like "../../etc/cron.d/x", "/etc/passwd", or "C:\\Windows\\..."
116
+ // would be written OUTSIDE the project root by every client that syncs it. We
117
+ // reject absolute paths, drive letters, UNC, any ".." segment, control chars
118
+ // (incl. the FILE_SEP separator and NULs), and absurd lengths. Enforced on the
119
+ // client (before writing) AND the relay (rejects the file-room) — defense in depth.
120
+ export function isSafeRelPath(relPath) {
121
+ if (typeof relPath !== 'string' || relPath.length === 0 || relPath.length > 1024) return false
122
+ if (/[\u0000-\u001f]/.test(relPath)) return false // control chars: NUL, FILE_SEP (U+0001), etc.
123
+ const p = relPath.replace(/\\/g, '/') // normalize Windows separators
124
+ if (p.startsWith('/')) return false // POSIX absolute
125
+ if (p.startsWith('//')) return false // UNC-ish
126
+ if (/^[a-zA-Z]:/.test(p)) return false // Windows drive letter (C:...)
127
+ if (p.split('/').some((seg) => seg === '..')) return false // traversal
128
+ return true
129
+ }
130
+
131
+ // Phase 3: is a file path allowed by a scope's path globs? Gitignore-style globs
132
+ // (via the `ignore` lib): positive patterns grant subtrees, "!"-prefixed patterns
133
+ // deny. No globs (or none given) = the whole room is allowed (Phase-2 behavior).
134
+ // pathAllowed(["src/**", "!**/*.env"], "src/app.js") -> true
135
+ // pathAllowed(["src/**"], "secrets/k.txt") -> false
136
+ export function pathAllowed(globs, relPath) {
137
+ if (!Array.isArray(globs) || globs.length === 0) return true
138
+ // The `ignore` lib throws on a "./"-prefixed path; normalize it away. And guard
139
+ // every match call so a malformed input can NEVER throw into the relay's auth
140
+ // gate (a throw there must fail closed, not crash the process).
141
+ const p = String(relPath).replace(/^(\.\/)+/, '')
142
+ const pos = [], neg = []
143
+ for (const g of globs) { if (typeof g !== 'string' || !g) continue; if (g[0] === '!') neg.push(g.slice(1)); else pos.push(g) }
144
+ const matches = (pats) => { try { return pats.length > 0 && ignore().add(pats).ignores(p) } catch { return false } }
145
+ const inAllow = pos.length === 0 ? true : matches(pos)
146
+ return inAllow && !matches(neg)
147
+ }
148
+
149
+ // Phase 5: within a scope, which VISIBLE paths are also WRITABLE. `writePaths` is
150
+ // an optional subset of `paths`: a file the scope can see is read-only unless it
151
+ // ALSO matches writePaths. This is what lets one agent read frontend/ for context
152
+ // but only edit backend/. Semantics, chosen to fail safe:
153
+ // - role 'reader' -> never writable (whole grant is view-only)
154
+ // - no writePaths key -> every visible path is writable (back-compat: a plain
155
+ // writer/agent edits everything it can see)
156
+ // - writePaths: [] -> nothing writable (explicit view-only everywhere)
157
+ // - writePaths: [globs] -> writable IFF the path matches the globs
158
+ // The relay enforces this by connecting an out-of-write-scope file-room as a
159
+ // reader (so makeReadOnly drops its writes); clients use it for read-only UX.
160
+ export function writeAllowed(scope, relPath) {
161
+ if (!scope) return false
162
+ if ((scope.role || 'writer') === 'reader') return false
163
+ const wp = scope.writePaths
164
+ if (wp == null) return true
165
+ if (!Array.isArray(wp) || wp.length === 0) return false
166
+ return pathAllowed(wp, relPath)
167
+ }
168
+
169
+ // --- self-certifying secured rooms (no server-side key registry, no secret files) ---
170
+ // A secured room's id embeds a fingerprint of the OWNER's public key:
171
+ // hs_<fp>_<rand> fp = base64url(sha256(spki-DER)).slice(0, 22)
172
+ // Every token for the room carries the owner's public key (claim `pk`) and is
173
+ // signed by the matching private key (RS256). The relay trusts a token IFF the
174
+ // embedded key's fingerprint equals the one in the room id — so trust is anchored
175
+ // in the room id itself and the relay needs to store nothing. Only the owner (who
176
+ // holds the private key, kept in the editor's secure storage) can mint valid
177
+ // tokens. This is what lets the hosted, stateless, free-tier relay enforce
178
+ // per-folder access with no registration step and no secret handed to anyone.
179
+ export function keyFingerprint(publicKeyPem) {
180
+ try {
181
+ const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' })
182
+ return crypto.createHash('sha256').update(der).digest('base64url').slice(0, 22)
183
+ } catch { return null }
184
+ }
185
+ export function roomFingerprint(room) {
186
+ const m = /^hs_([A-Za-z0-9_-]{22})_/.exec(room || '')
187
+ return m ? m[1] : null
188
+ }
189
+ export const isSecuredRoom = (room) => roomFingerprint(room) != null
190
+ export function makeSecuredRoomId(publicKeyPem, rand) {
191
+ const fp = keyFingerprint(publicKeyPem)
192
+ return fp ? `hs_${fp}_${rand}` : null
193
+ }
194
+
195
+ // Read a token's payload WITHOUT verifying — for a client to inspect its OWN grant
196
+ // (e.g. which paths it may open) for UX. NEVER use for access decisions; the relay
197
+ // is the enforcer (it verifies the signature).
198
+ export function decodeUnsafe(token) {
199
+ try { return JSON.parse(Buffer.from(String(token).split('.')[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')) }
200
+ catch { return null }
201
+ }