@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.
- package/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- 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
|
+
}
|