@huynle/hub 0.6.0 → 0.7.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 (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,378 @@
1
+ // The pure grant policy (design §5, §7, §11.4; decision wvqeuis0). No state, no I/O: grants.ts stores grants and
2
+ // checks tenancy, acting.ts runs the check order; this module answers "does this rule cover this request?".
3
+ // - Patterns `action[:glob]` (`bash` is OpenCode's `shell`; the action alone means every resource), matched with
4
+ // OpenCode's Wildcard semantics (`*` any run, `?` one character, a trailing ` *` optional).
5
+ // - A request is allowed only if every resource is; never (hub `agents.never`, then the grant's) beats allow, and
6
+ // for an `always` reply also covers the save patterns, which must lie inside the allow rules.
7
+ // - Shell allow rules: `$(`, backticks and `<`/`>` in a resource must be written literally, never matched by a wildcard.
8
+ // - Escalation tiers are the hub's: tap for shell, external_directory, MCP/unknown tools and near-never requests.
9
+ // - Grant schema: verbs (launch included, phase 4), permissions, expires (1 minute–30 days), maxActionsPerHour,
10
+ // allowAlways; children stays deferred. Project scopes are canonical paths matched on path boundaries.
11
+
12
+ import type { Tier } from "./decisions"
13
+ import { AgentError } from "./errors"
14
+ import { isReservedName, isReservedRole, parseAddress } from "./names"
15
+
16
+ /** One permission rule: an OpenCode action (or action glob) and a resource glob (`*` when the action stands alone). */
17
+ export interface Pattern {
18
+ readonly action: string
19
+ readonly resource: string
20
+ }
21
+
22
+ /** Action names: OpenCode tools, `<server>_<tool>` MCP names, or globs of them. */
23
+ const ACTION = /^[A-Za-z0-9_.*?-]{1,80}$/
24
+ export const RESOURCE_MAX = 1000
25
+
26
+ /** What a wildcard may consume: any character, or (for shell allow rules) anything but `$(`, backticks and redirections. */
27
+ const ANY = "."
28
+ const SHELL_SAFE = "(?:[^`<>$(]|\\$(?!\\()|(?<!\\$)\\()"
29
+
30
+ function globRegex(pattern: string, any: string): RegExp {
31
+ const glob = pattern.replaceAll("\\", "/")
32
+ const optionalTail = glob.endsWith(" *")
33
+ const body = optionalTail ? glob.slice(0, -2) : glob
34
+ const source = [...body].map((c) => (c === "*" ? `${any}*` : c === "?" ? any : c.replace(/[.+^${}()|[\]\\/]/g, "\\$&"))).join("")
35
+ return new RegExp(`^${source}${optionalTail ? `( ${any}*)?` : ""}$`, "s")
36
+ }
37
+
38
+ /** OpenCode's Wildcard.match: `*` any run (slashes and newlines too), `?` one character, a trailing ` *` optional. */
39
+ export function wildcard(input: string, pattern: string): boolean {
40
+ return globRegex(pattern, ANY).test(input.replaceAll("\\", "/"))
41
+ }
42
+
43
+ /** OpenCode v2 calls the shell tool `shell`; `bash` is accepted as its old name. */
44
+ const normaliseAction = (action: string): string => (action === "bash" ? "shell" : action)
45
+
46
+ /** `action[:glob]` → a Pattern; undefined when malformed. */
47
+ export function parsePattern(text: string): Pattern | undefined {
48
+ if (typeof text !== "string" || text !== text.trim()) return undefined
49
+ const colon = text.indexOf(":")
50
+ const action = colon < 0 ? text : text.slice(0, colon)
51
+ const resource = colon < 0 ? "*" : text.slice(colon + 1)
52
+ if (!ACTION.test(action) || resource === "" || resource.length > RESOURCE_MAX) return undefined
53
+ return { action: normaliseAction(action), resource }
54
+ }
55
+
56
+ /** True when the shell resource matches the glob with every `$(`, backtick and redirection written literally. */
57
+ const shellWildcard = (input: string, pattern: string): boolean => globRegex(pattern, SHELL_SAFE).test(input.replaceAll("\\", "/"))
58
+
59
+ /** An allow rule covers this request resource (shell metacharacters are never a wildcard's). */
60
+ export function allowsResource(pattern: Pattern, action: string, resource: string): boolean {
61
+ const normalised = normaliseAction(action)
62
+ if (!wildcard(normalised, pattern.action)) return false
63
+ return normalised === "shell" ? shellWildcard(resource, pattern.resource) : wildcard(resource, pattern.resource)
64
+ }
65
+
66
+ /** A never rule hits this request resource (plain wildcards: nothing escapes a never). */
67
+ export function forbidsResource(pattern: Pattern, action: string, resource: string): boolean {
68
+ return wildcard(normaliseAction(action), pattern.action) && wildcard(resource, pattern.resource)
69
+ }
70
+
71
+ /** A glob's forms under the optional trailing ` *`: `git push *` is `git push` or `git push *`. */
72
+ const forms = (glob: string): string[] => {
73
+ const g = glob.replaceAll("\\", "/")
74
+ return g.endsWith(" *") ? [g.slice(0, -2), g] : [g]
75
+ }
76
+
77
+ /** Whether some string matches both globs (`*` any run, `?` one character). */
78
+ export function globsOverlap(a: string, b: string): boolean {
79
+ return forms(a).some((x) => forms(b).some((y) => overlap([...x], [...y])))
80
+ }
81
+
82
+ function overlap(a: string[], b: string[]): boolean {
83
+ const memo = new Map<number, boolean>()
84
+ const go = (i: number, j: number): boolean => {
85
+ const key = i * (b.length + 1) + j
86
+ const known = memo.get(key)
87
+ if (known !== undefined) return known
88
+ let result: boolean
89
+ if (i === a.length && j === b.length) result = true
90
+ else if (a[i] === "*") result = go(i + 1, j) || (j < b.length && go(i, j + 1))
91
+ else if (b[j] === "*") result = go(i, j + 1) || (i < a.length && go(i + 1, j))
92
+ else if (i === a.length || j === b.length) result = false
93
+ else result = (a[i] === "?" || b[j] === "?" || a[i] === b[j]) && go(i + 1, j + 1)
94
+ memo.set(key, result)
95
+ return result
96
+ }
97
+ return go(0, 0)
98
+ }
99
+
100
+ /** Whether every string the save glob matches also matches the allow glob (a sound, not complete, check). */
101
+ export function globCovers(allow: string, save: string): boolean {
102
+ return forms(save).every((s) => forms(allow).some((a) => covers([...a], [...s])))
103
+ }
104
+
105
+ function covers(allow: string[], save: string[]): boolean {
106
+ const memo = new Map<number, boolean>()
107
+ const go = (i: number, j: number): boolean => {
108
+ const key = i * (save.length + 1) + j
109
+ const known = memo.get(key)
110
+ if (known !== undefined) return known
111
+ let result: boolean
112
+ if (i === allow.length && j === save.length) result = true
113
+ else if (allow[i] === "*") result = go(i + 1, j) || (j < save.length && go(i, j + 1))
114
+ else if (i === allow.length || j === save.length) result = false
115
+ else if (allow[i] === "?") result = save[j] !== "*" && go(i + 1, j + 1)
116
+ else result = save[j] !== "*" && save[j] !== "?" && allow[i] === save[j] && go(i + 1, j + 1)
117
+ memo.set(key, result)
118
+ return result
119
+ }
120
+ return go(0, 0)
121
+ }
122
+
123
+ /** A permission request as OpenCode asks it (permission.asked). */
124
+ export interface PermissionRequest {
125
+ readonly action: string
126
+ readonly resources: readonly string[]
127
+ /** What an `always` reply saves as rules. */
128
+ readonly save?: readonly string[]
129
+ }
130
+
131
+ export interface PermissionRules {
132
+ readonly allow: readonly Pattern[]
133
+ readonly never: readonly Pattern[]
134
+ }
135
+
136
+ export interface PermissionContext {
137
+ readonly grant: PermissionRules
138
+ /** The hub-wide `agents.never` (hub.json), checked first. */
139
+ readonly hubNever?: readonly Pattern[]
140
+ /** The agent replies `always` (saving the request's save patterns). */
141
+ readonly always?: boolean
142
+ /** The grant lets its holder reply `always`. */
143
+ readonly allowAlways?: boolean
144
+ }
145
+
146
+ export type PermissionVerdict =
147
+ | { readonly verdict: "allow" }
148
+ /** A never rule hit a resource or save pattern (subject): the agent may not answer; the user still may. */
149
+ | { readonly verdict: "never"; readonly by: "hub" | "grant"; readonly pattern: Pattern; readonly subject: string }
150
+ /** Not covered by the grant: needs_user. */
151
+ | { readonly verdict: "outside"; readonly reason: string }
152
+
153
+ /** The shell metacharacters a shell allow rule must spell out (`$(`, backticks, redirections). */
154
+ const SHELL_META = /\$\(|[`<>]/
155
+
156
+ /**
157
+ * The grant policy for one permission request: hub never, then grant never (on every resource and, for
158
+ * `always`, every save pattern), then allow (every resource, and every save pattern for `always`).
159
+ */
160
+ export function evaluatePermission(request: PermissionRequest, context: PermissionContext): PermissionVerdict {
161
+ const action = normaliseAction(request.action)
162
+ const save = context.always ? (request.save ?? []) : []
163
+ const subjects = request.resources.length > 0 ? request.resources : [""]
164
+ const nevers: Array<["hub" | "grant", readonly Pattern[]]> = [
165
+ ["hub", context.hubNever ?? []],
166
+ ["grant", context.grant.never],
167
+ ]
168
+ for (const [by, list] of nevers) {
169
+ for (const pattern of list) {
170
+ if (!wildcard(action, pattern.action)) continue
171
+ const subject = subjects.find((r) => wildcard(r, pattern.resource)) ?? save.find((s) => globsOverlap(pattern.resource, s))
172
+ if (subject !== undefined) return { verdict: "never", by, pattern, subject }
173
+ }
174
+ }
175
+ if (request.resources.length === 0) return { verdict: "outside", reason: "The request names no resources" }
176
+ const denied = request.resources.find((r) => !context.grant.allow.some((pattern) => allowsResource(pattern, action, r)))
177
+ if (denied !== undefined) return { verdict: "outside", reason: `${action} ${denied} is outside the grant` }
178
+ if (!context.always) return { verdict: "allow" }
179
+ if (!context.allowAlways) return { verdict: "outside", reason: "The grant does not allow always" }
180
+ if (save.length === 0) return { verdict: "outside", reason: "The request has nothing to save for always" }
181
+ const broad = save.find((s) => !context.grant.allow.some((pattern) => savedBy(pattern, action, s)))
182
+ if (broad !== undefined) return { verdict: "outside", reason: `Always would save ${action} ${broad}, broader than the grant` }
183
+ return { verdict: "allow" }
184
+ }
185
+
186
+ /** An allow rule covers a save pattern (shell save patterns with metacharacters only word for word). */
187
+ function savedBy(pattern: Pattern, action: string, save: string): boolean {
188
+ if (!wildcard(action, pattern.action)) return false
189
+ if (action === "shell" && SHELL_META.test(save)) return pattern.resource === save
190
+ return globCovers(pattern.resource, save)
191
+ }
192
+
193
+ /** OpenCode's built-in tools a console confirm may answer; everything else (shell, external_directory, MCP, unknown) is tap. */
194
+ const CONFIRM_ACTIONS: ReadonlySet<string> = new Set(["read", "edit", "write", "glob", "grep", "list", "lsp", "webfetch", "websearch", "codesearch", "task", "skill", "todowrite", "todoread", "question"])
195
+
196
+ /** The literal text of a glob before its first wildcard; at least 3 characters to count. */
197
+ const NEAR_MIN = 3
198
+ const literalPrefix = (glob: string): string => glob.replaceAll("\\", "/").split(/[*?]/, 1)[0]!.trim()
199
+
200
+ /** A never rule hits, or nearly hits, the request: a resource matches or contains its literal text, a save pattern overlaps it. */
201
+ function nearNever(action: string, request: PermissionRequest, never: readonly Pattern[]): boolean {
202
+ return never.some((pattern) => {
203
+ if (!wildcard(action, pattern.action)) return false
204
+ const literal = literalPrefix(pattern.resource)
205
+ const near = (r: string) => wildcard(r, pattern.resource) || (literal.length >= NEAR_MIN && r.replaceAll("\\", "/").includes(literal))
206
+ return request.resources.some(near) || (request.save ?? []).some((s) => globsOverlap(pattern.resource, s))
207
+ })
208
+ }
209
+
210
+ /**
211
+ * The tier of an escalation (decision wvqeuis0 §7): `tap` for shell, external_directory, MCP (and unknown)
212
+ * tools and anything near a never rule (hub and grant); `confirm` otherwise. The hub sets it, never the agent.
213
+ */
214
+ export function riskTier(request: PermissionRequest, never: readonly Pattern[]): Tier {
215
+ const action = normaliseAction(request.action)
216
+ if (!CONFIRM_ACTIONS.has(action)) return "tap"
217
+ return nearNever(action, request, never) ? "tap" : "confirm"
218
+ }
219
+
220
+ export const PROJECT_PATH_MAX = 1024
221
+ const DRIVE = /^[A-Za-z]:\//
222
+
223
+ /**
224
+ * A project directory as a grant scope compares it: absolute (POSIX or a Windows drive), `/` separators,
225
+ * `.`/`..`/repeated slashes resolved, no trailing slash; undefined for relative, `~`, root or malformed paths.
226
+ * Pure text: symlinks are not resolved (the directories live on the spokes' machines).
227
+ */
228
+ export function canonicalProject(path: string): string | undefined {
229
+ if (typeof path !== "string" || path.length === 0 || path.length > PROJECT_PATH_MAX || path.includes("\0")) return undefined
230
+ const slashed = path.replaceAll("\\", "/")
231
+ const drive = DRIVE.test(slashed) ? slashed.slice(0, 2) : ""
232
+ const rest = drive ? slashed.slice(2) : slashed
233
+ if (!rest.startsWith("/")) return undefined
234
+ const segments: string[] = []
235
+ for (const segment of rest.split("/")) {
236
+ if (segment === "" || segment === ".") continue
237
+ if (segment === "..") {
238
+ if (segments.length === 0) return undefined
239
+ segments.pop()
240
+ } else segments.push(segment)
241
+ }
242
+ return segments.length === 0 ? undefined : `${drive}/${segments.join("/")}`
243
+ }
244
+
245
+ /** Whether a session directory is one of the projects or inside one (on a path boundary, after canonicalising). */
246
+ export function inProjects(dir: string, projects: readonly string[]): boolean {
247
+ const canonical = canonicalProject(dir)
248
+ if (canonical === undefined) return false
249
+ return projects.some((project) => {
250
+ const root = canonicalProject(project)
251
+ return root !== undefined && (canonical === root || canonical.startsWith(`${root}/`))
252
+ })
253
+ }
254
+
255
+ /** A Pattern as text: the action alone when it covers every resource, else `action:glob`. */
256
+ export const formatPattern = (pattern: Pattern): string => (pattern.resource === "*" ? pattern.action : `${pattern.action}:${pattern.resource}`)
257
+
258
+ const UNIT_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const
259
+
260
+ /** A duration in ms: a number of seconds (as elsewhere in the agent API) or `<n>s|m|h|d`; undefined when malformed. */
261
+ export function parseDuration(value: unknown): number | undefined {
262
+ if (typeof value === "number") return Number.isFinite(value) && value >= 0 ? Math.round(value * 1000) : undefined
263
+ if (typeof value !== "string") return undefined
264
+ const match = /^(\d{1,9})([smhd])$/.exec(value)
265
+ return match ? Number(match[1]) * UNIT_MS[match[2] as keyof typeof UNIT_MS] : undefined
266
+ }
267
+
268
+ // ---------- grants ----------
269
+
270
+ /** What a grant may let its holder do (`launch`, phase 4, starts sessions in the grant's projects). */
271
+ export const GRANT_VERBS = ["approve", "deny", "answer", "prompt", "stop", "roles", "launch"] as const
272
+ export type GrantVerb = (typeof GRANT_VERBS)[number]
273
+
274
+ export const GRANT_DEFAULT_EXPIRES_MS = 8 * 3_600_000
275
+ export const GRANT_EXPIRES_MIN_MS = 60_000
276
+ export const GRANT_EXPIRES_MAX_MS = 30 * 86_400_000
277
+ export const GRANT_DEFAULT_MAX_ACTIONS_PER_HOUR = 60
278
+ export const GRANT_MAX_ACTIONS_PER_HOUR = 1000
279
+ export const GRANT_PROJECTS_MAX = 20
280
+ export const GRANT_PATTERNS_MAX = 100
281
+
282
+ /** A validated grant as the user wrote it (holder, scope and tenancy are checked against the user's world by grants.ts). */
283
+ export interface GrantSpec {
284
+ /** `@@role` (a reserved role), `@name`, `ses_…` or `app:<token id>`. */
285
+ readonly holder: string
286
+ readonly scope: { readonly projects: readonly string[] }
287
+ readonly allow: readonly GrantVerb[]
288
+ /** Canonical pattern texts (formatPattern). */
289
+ readonly permissions: { readonly allow: readonly string[]; readonly never: readonly string[] }
290
+ /** The holder may reply `always` (still only when every save pattern passes the policy). */
291
+ readonly allowAlways: boolean
292
+ readonly expiresInMs: number
293
+ readonly maxActionsPerHour: number
294
+ }
295
+
296
+ const GRANT_FIELDS = ["holder", "scope", "allow", "permissions", "expires", "maxActionsPerHour", "allowAlways"]
297
+ const APP_HOLDER = /^app:[0-9a-f]{16}$/
298
+ const DEFERRED = ["children"]
299
+
300
+ const invalid = (message: string) => new AgentError("invalid", message)
301
+ const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
302
+
303
+ function onlyFields(value: Record<string, unknown>, allowed: readonly string[], what: string): void {
304
+ for (const key of Object.keys(value)) {
305
+ if (DEFERRED.includes(key)) throw invalid(`${what}.${key} is not available yet (phase 4)`)
306
+ if (!allowed.includes(key)) throw invalid(`Unknown ${what} field ${key}`)
307
+ }
308
+ }
309
+
310
+ function validHolder(value: unknown): string {
311
+ if (typeof value === "string" && APP_HOLDER.test(value)) return value
312
+ const address = typeof value === "string" ? parseAddress(value) : undefined
313
+ if (!address || (address.kind === "name" && isReservedName(address.name))) throw invalid("holder is @@role, @name, ses_… or app:<token id>")
314
+ if (address.kind === "role" && !isReservedRole(address.role)) throw invalid("A role holder must be a reserved role (such as @@orchestrator)")
315
+ return value as string
316
+ }
317
+
318
+ function validProjects(value: unknown): string[] {
319
+ if (!isObject(value)) throw invalid("scope is an object {projects: [...]}")
320
+ onlyFields(value, ["projects"], "scope")
321
+ const raw = value.projects
322
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > GRANT_PROJECTS_MAX) throw invalid(`scope.projects lists 1–${GRANT_PROJECTS_MAX} project directories`)
323
+ const projects: string[] = []
324
+ for (const item of raw) {
325
+ const canonical = canonicalProject(item as string)
326
+ if (canonical === undefined) throw invalid(`scope.projects: ${String(item)} is not an absolute project directory`)
327
+ if (!projects.includes(canonical)) projects.push(canonical)
328
+ }
329
+ return projects
330
+ }
331
+
332
+ function validVerbs(value: unknown): GrantVerb[] {
333
+ if (!Array.isArray(value) || value.length === 0) throw invalid(`allow lists the verbs: ${GRANT_VERBS.join(", ")}`)
334
+ const verbs: GrantVerb[] = []
335
+ for (const verb of value) {
336
+ if (!GRANT_VERBS.includes(verb)) throw invalid(`allow: unknown verb ${String(verb)}`)
337
+ if (!verbs.includes(verb)) verbs.push(verb)
338
+ }
339
+ return verbs
340
+ }
341
+
342
+ function validPatterns(value: unknown, what: string): string[] {
343
+ if (value === undefined) return []
344
+ if (!Array.isArray(value) || value.length > GRANT_PATTERNS_MAX) throw invalid(`${what} lists at most ${GRANT_PATTERNS_MAX} patterns`)
345
+ const out: string[] = []
346
+ for (const item of value) {
347
+ const pattern = parsePattern(item)
348
+ if (!pattern) throw invalid(`${what}: ${String(item)} is not an action[:glob] pattern`)
349
+ const text = formatPattern(pattern)
350
+ if (!out.includes(text)) out.push(text)
351
+ }
352
+ return out
353
+ }
354
+
355
+ /** Validates a grant the user creates or edits; throws AgentError invalid. Holder and scope tenancy are the caller's. */
356
+ export function validateGrant(input: unknown): GrantSpec {
357
+ if (!isObject(input)) throw invalid("A grant is an object")
358
+ onlyFields(input, GRANT_FIELDS, "grant")
359
+ const holder = validHolder(input.holder)
360
+ const projects = validProjects(input.scope)
361
+ const allow = validVerbs(input.allow)
362
+ let permissions: GrantSpec["permissions"] = { allow: [], never: [] }
363
+ if (input.permissions !== undefined) {
364
+ if (!isObject(input.permissions)) throw invalid("permissions is an object {allow?, never?}")
365
+ onlyFields(input.permissions, ["allow", "never"], "permissions")
366
+ permissions = { allow: validPatterns(input.permissions.allow, "permissions.allow"), never: validPatterns(input.permissions.never, "permissions.never") }
367
+ }
368
+ const expiresInMs = input.expires === undefined ? GRANT_DEFAULT_EXPIRES_MS : parseDuration(input.expires)
369
+ if (expiresInMs === undefined || expiresInMs < GRANT_EXPIRES_MIN_MS || expiresInMs > GRANT_EXPIRES_MAX_MS) {
370
+ throw invalid("expires is 1 minute to 30 days (seconds, or 30m, 8h, 2d)")
371
+ }
372
+ const cap = input.maxActionsPerHour ?? GRANT_DEFAULT_MAX_ACTIONS_PER_HOUR
373
+ if (!Number.isInteger(cap) || (cap as number) < 1 || (cap as number) > GRANT_MAX_ACTIONS_PER_HOUR) {
374
+ throw invalid(`maxActionsPerHour is a whole number 1–${GRANT_MAX_ACTIONS_PER_HOUR}`)
375
+ }
376
+ if (input.allowAlways !== undefined && typeof input.allowAlways !== "boolean") throw invalid("allowAlways is true or false")
377
+ return { holder, scope: { projects }, allow, permissions, allowAlways: input.allowAlways === true, expiresInMs, maxActionsPerHour: cap as number }
378
+ }