typeclaw 0.1.4 → 0.1.6
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/README.md +15 -13
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +13 -10
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +137 -7
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +809 -300
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +11 -3
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +13 -3
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +491 -19
- package/src/config/index.ts +15 -1
- package/src/config/models-mutation.ts +200 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +6 -1
- package/src/container/port.ts +10 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +81 -63
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +51 -34
- package/src/doctor/plugin-bridge.ts +28 -4
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +36 -10
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +213 -85
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/reload/client.ts +25 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +68 -7
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +83 -0
- package/src/server/index.ts +198 -71
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +104 -112
- package/src/skills/typeclaw-memory/SKILL.md +9 -9
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/tui/client.ts +66 -5
- package/src/tui/index.ts +61 -9
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +134 -98
package/src/init/models-dev.ts
CHANGED
|
@@ -14,6 +14,11 @@ const PROVIDER_TO_MODELS_DEV: Record<KnownProviderId, string> = {
|
|
|
14
14
|
// entries are surfaced regardless of upstream membership.
|
|
15
15
|
'openai-codex': 'openai',
|
|
16
16
|
fireworks: 'fireworks-ai',
|
|
17
|
+
zai: 'zai',
|
|
18
|
+
// zai-coding (GLM Coding Plan) is a billing surface, not a separate model
|
|
19
|
+
// catalog. models.dev tracks the underlying model metadata under `zai`,
|
|
20
|
+
// so we route lookups there. The curated entries still get surfaced.
|
|
21
|
+
'zai-coding': 'zai',
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
export type ModelOption = {
|
|
@@ -25,6 +30,13 @@ export type ModelOption = {
|
|
|
25
30
|
reasoning: boolean
|
|
26
31
|
contextWindow: number | null
|
|
27
32
|
curated: boolean
|
|
33
|
+
// True iff the model accepts image input. Sourced from the curated
|
|
34
|
+
// `Model.input` array (which is the source of truth — pi-ai consumes it
|
|
35
|
+
// directly) with a fallback to models.dev's `modalities.input` when the
|
|
36
|
+
// curated entry omits the field. The init wizard uses this to decide
|
|
37
|
+
// whether to prompt for a separate `vision` profile after the user picks
|
|
38
|
+
// a text-only `default` model.
|
|
39
|
+
supportsVision: boolean
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
type ModelsDevModel = {
|
|
@@ -115,7 +127,10 @@ function buildOption(ref: KnownModelRef, opts: BuildOptionOpts): ModelOption {
|
|
|
115
127
|
const modelId = ref.slice(slash + 1)
|
|
116
128
|
const provider = KNOWN_PROVIDERS[providerId]
|
|
117
129
|
const curatedModel = (
|
|
118
|
-
provider.models as Record<
|
|
130
|
+
provider.models as Record<
|
|
131
|
+
string,
|
|
132
|
+
{ name: string; contextWindow?: number; reasoning?: boolean; input?: ReadonlyArray<string> }
|
|
133
|
+
>
|
|
119
134
|
)[modelId]
|
|
120
135
|
return {
|
|
121
136
|
ref,
|
|
@@ -126,5 +141,15 @@ function buildOption(ref: KnownModelRef, opts: BuildOptionOpts): ModelOption {
|
|
|
126
141
|
reasoning: opts.upstream?.reasoning ?? curatedModel?.reasoning ?? false,
|
|
127
142
|
contextWindow: opts.upstream?.limit?.context ?? curatedModel?.contextWindow ?? null,
|
|
128
143
|
curated: opts.curated,
|
|
144
|
+
supportsVision: resolveSupportsVision(curatedModel?.input, opts.upstream?.modalities?.input),
|
|
129
145
|
}
|
|
130
146
|
}
|
|
147
|
+
|
|
148
|
+
function resolveSupportsVision(
|
|
149
|
+
curatedInput: ReadonlyArray<string> | undefined,
|
|
150
|
+
upstreamInput: ReadonlyArray<string> | undefined,
|
|
151
|
+
): boolean {
|
|
152
|
+
if (curatedInput !== undefined) return curatedInput.includes('image')
|
|
153
|
+
if (upstreamInput !== undefined) return upstreamInput.includes('image')
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { confirm, isCancel, log, note, spinner } from '@clack/prompts'
|
|
2
|
+
|
|
3
|
+
import { c } from '@/cli/ui'
|
|
4
|
+
import { runClaimSession } from '@/role-claim'
|
|
5
|
+
|
|
6
|
+
import type { ChannelKind } from './index'
|
|
7
|
+
|
|
8
|
+
const CHANNEL_LABELS: Record<ChannelKind, string> = {
|
|
9
|
+
'slack-bot': 'Slack',
|
|
10
|
+
'discord-bot': 'Discord',
|
|
11
|
+
'telegram-bot': 'Telegram',
|
|
12
|
+
kakaotalk: 'KakaoTalk',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000
|
|
16
|
+
|
|
17
|
+
export type RunOwnerClaimOptions = {
|
|
18
|
+
url: string
|
|
19
|
+
configuredChannels: readonly ChannelKind[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Drives the post-hatching claim flow: ask the operator whether to pair now,
|
|
23
|
+
// run the claim handshake against the running container, print the result.
|
|
24
|
+
// Aborts (kind: 'cancel' or a clack cancel) drop straight back into the
|
|
25
|
+
// normal hatching path so the TUI still opens — the operator can run
|
|
26
|
+
// `typeclaw role claim` later.
|
|
27
|
+
export async function runOwnerClaim({ url, configuredChannels }: RunOwnerClaimOptions): Promise<void> {
|
|
28
|
+
if (configuredChannels.length === 0) return
|
|
29
|
+
|
|
30
|
+
const channelList = configuredChannels.map((c) => CHANNEL_LABELS[c] ?? c).join(', ')
|
|
31
|
+
|
|
32
|
+
const proceed = await confirm({
|
|
33
|
+
message: `Claim owner role on ${channelList} now?`,
|
|
34
|
+
initialValue: true,
|
|
35
|
+
})
|
|
36
|
+
if (isCancel(proceed) || proceed === false) {
|
|
37
|
+
log.info(`Skipping. Run ${c.bold('typeclaw role claim')} later when you're ready.`)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const s = spinner()
|
|
42
|
+
s.start('Generating your claim code...')
|
|
43
|
+
|
|
44
|
+
const result = await runClaimSession({
|
|
45
|
+
url,
|
|
46
|
+
role: 'owner',
|
|
47
|
+
ttlMs: DEFAULT_TTL_MS,
|
|
48
|
+
onStarted: (payload) => {
|
|
49
|
+
const expiresInMin = Math.max(1, Math.round((payload.expiresAt - Date.now()) / 60_000))
|
|
50
|
+
s.stop('Code ready.')
|
|
51
|
+
note(
|
|
52
|
+
[
|
|
53
|
+
`Open ${channelList} and DM your bot with this code:`,
|
|
54
|
+
'',
|
|
55
|
+
` ${c.bold(payload.code)}`,
|
|
56
|
+
'',
|
|
57
|
+
`(expires in ~${expiresInMin}m)`,
|
|
58
|
+
].join('\n'),
|
|
59
|
+
'Claim your owner role',
|
|
60
|
+
)
|
|
61
|
+
s.start('Waiting for your DM...')
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
if (result.kind === 'completed') {
|
|
66
|
+
s.stop(c.green(`Paired as owner.`))
|
|
67
|
+
log.info(`Match rule added to typeclaw.json#roles.owner.match: ${c.bold(result.payload.matchRule)}`)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (result.kind === 'error') {
|
|
71
|
+
s.stop(c.red(`Claim failed: ${result.payload.reason}`))
|
|
72
|
+
log.info(`You can retry with ${c.bold('typeclaw role claim')} anytime.`)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
s.stop(c.yellow(`Claim timed out — no DM received within the window.`))
|
|
76
|
+
log.info(`Run ${c.bold('typeclaw role claim')} when you're ready.`)
|
|
77
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { MatchRule } from './match-rule'
|
|
2
|
+
|
|
3
|
+
export type BuiltinRoleName = 'owner' | 'trusted' | 'member' | 'guest'
|
|
4
|
+
|
|
5
|
+
export const BUILTIN_ROLE_NAMES: readonly BuiltinRoleName[] = ['owner', 'trusted', 'member', 'guest']
|
|
6
|
+
|
|
7
|
+
// Core-owned permission strings; not contributed by plugins. The security
|
|
8
|
+
// plugin's `security.bypass.*` strings are NOT listed here — they are
|
|
9
|
+
// collected from plugin contributions and merged into `owner`'s permission
|
|
10
|
+
// set at boot via expandOwnerWildcard.
|
|
11
|
+
export const CORE_PERMISSIONS = {
|
|
12
|
+
channelRespond: 'channel.respond',
|
|
13
|
+
cronSchedule: 'cron.schedule',
|
|
14
|
+
cronModify: 'cron.modify',
|
|
15
|
+
} as const
|
|
16
|
+
|
|
17
|
+
// Sentinel that `expandOwnerWildcard` swaps for the concrete union of
|
|
18
|
+
// plugin-registered `security.bypass.*` strings. Users cannot write `*` in
|
|
19
|
+
// their own `permissions[]`; the sentinel exists only inside the built-in
|
|
20
|
+
// `owner` spec.
|
|
21
|
+
export const OWNER_SECURITY_WILDCARD = '__BUILTIN_OWNER_SECURITY_WILDCARD__'
|
|
22
|
+
|
|
23
|
+
export type BuiltinRoleSpec = {
|
|
24
|
+
readonly match: readonly MatchRule[]
|
|
25
|
+
readonly permissions: readonly string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> = {
|
|
29
|
+
owner: {
|
|
30
|
+
match: [{ kind: 'tui' }],
|
|
31
|
+
permissions: [
|
|
32
|
+
CORE_PERMISSIONS.channelRespond,
|
|
33
|
+
CORE_PERMISSIONS.cronSchedule,
|
|
34
|
+
CORE_PERMISSIONS.cronModify,
|
|
35
|
+
OWNER_SECURITY_WILDCARD,
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
trusted: {
|
|
39
|
+
match: [],
|
|
40
|
+
permissions: [CORE_PERMISSIONS.channelRespond, CORE_PERMISSIONS.cronSchedule, 'security.bypass.secretExfilBash'],
|
|
41
|
+
},
|
|
42
|
+
member: {
|
|
43
|
+
match: [],
|
|
44
|
+
permissions: [CORE_PERMISSIONS.channelRespond],
|
|
45
|
+
},
|
|
46
|
+
guest: {
|
|
47
|
+
match: [],
|
|
48
|
+
permissions: [],
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function expandOwnerWildcard(
|
|
53
|
+
ownerPermissions: readonly string[],
|
|
54
|
+
pluginContributed: readonly string[],
|
|
55
|
+
): readonly string[] {
|
|
56
|
+
const bypass = pluginContributed.filter((p) => p.startsWith('security.bypass.'))
|
|
57
|
+
const out: string[] = []
|
|
58
|
+
for (const p of ownerPermissions) {
|
|
59
|
+
if (p === OWNER_SECURITY_WILDCARD) {
|
|
60
|
+
for (const b of bypass) if (!out.includes(b)) out.push(b)
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (!out.includes(p)) out.push(p)
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isBuiltinRoleName(name: string): name is BuiltinRoleName {
|
|
69
|
+
return (BUILTIN_ROLE_NAMES as readonly string[]).includes(name)
|
|
70
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { parseMatchRule } from './match-rule'
|
|
5
|
+
|
|
6
|
+
// Appends `rule` to `typeclaw.json#roles.<name>.match`, creating the role
|
|
7
|
+
// block when missing. Idempotent: re-granting the same rule is a no-op.
|
|
8
|
+
// Atomic: writes via temp+rename so a crashed write never leaves a partial
|
|
9
|
+
// JSON file that would brick the next `typeclaw start`.
|
|
10
|
+
//
|
|
11
|
+
// Used by the role-claim flow (after a successful handshake) and by any
|
|
12
|
+
// future operator-direct grant command. The match rule is validated
|
|
13
|
+
// against the same parser that runs at config load, so a malformed rule
|
|
14
|
+
// fails here instead of bricking the next start.
|
|
15
|
+
|
|
16
|
+
const CONFIG_FILE = 'typeclaw.json'
|
|
17
|
+
|
|
18
|
+
export type GrantResult = { ok: true; added: boolean } | { ok: false; reason: string }
|
|
19
|
+
|
|
20
|
+
export type GrantOptions = {
|
|
21
|
+
cwd: string
|
|
22
|
+
roleName: string
|
|
23
|
+
matchRule: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function grantRole(opts: GrantOptions): GrantResult {
|
|
27
|
+
const validation = parseMatchRule(opts.matchRule)
|
|
28
|
+
if (!validation.ok) {
|
|
29
|
+
return { ok: false, reason: `invalid match rule '${opts.matchRule}': ${validation.error}` }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const path = join(opts.cwd, CONFIG_FILE)
|
|
33
|
+
if (!existsSync(path)) {
|
|
34
|
+
return { ok: false, reason: `${CONFIG_FILE} not found at ${opts.cwd}` }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let raw: string
|
|
38
|
+
try {
|
|
39
|
+
raw = readFileSync(path, 'utf8')
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return { ok: false, reason: `failed to read ${CONFIG_FILE}: ${describeError(error)}` }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let json: unknown
|
|
45
|
+
try {
|
|
46
|
+
json = JSON.parse(raw)
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return { ok: false, reason: `${CONFIG_FILE} is not valid JSON: ${describeError(error)}` }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
|
|
52
|
+
return { ok: false, reason: `${CONFIG_FILE} must be a JSON object` }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const obj = json as Record<string, unknown>
|
|
56
|
+
const roles = isPlainObject(obj.roles) ? { ...obj.roles } : {}
|
|
57
|
+
const role = isPlainObject(roles[opts.roleName]) ? { ...(roles[opts.roleName] as Record<string, unknown>) } : {}
|
|
58
|
+
const existingMatch = Array.isArray(role.match) ? [...(role.match as unknown[])] : []
|
|
59
|
+
|
|
60
|
+
// Dedup by exact string equality — match rules canonicalize during load,
|
|
61
|
+
// and a literal duplicate in the file is a noise source the schema doesn't
|
|
62
|
+
// currently dedupe for us.
|
|
63
|
+
if (existingMatch.includes(opts.matchRule)) {
|
|
64
|
+
return { ok: true, added: false }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
role.match = [...existingMatch, opts.matchRule]
|
|
68
|
+
roles[opts.roleName] = role
|
|
69
|
+
obj.roles = roles
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
writeAtomic(path, `${JSON.stringify(obj, null, 2)}\n`)
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return { ok: false, reason: `failed to write ${CONFIG_FILE}: ${describeError(error)}` }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { ok: true, added: true }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function writeAtomic(path: string, content: string): void {
|
|
81
|
+
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`
|
|
82
|
+
writeFileSync(tmp, content)
|
|
83
|
+
try {
|
|
84
|
+
renameSync(tmp, path)
|
|
85
|
+
} catch (error) {
|
|
86
|
+
try {
|
|
87
|
+
unlinkSync(tmp)
|
|
88
|
+
} catch {}
|
|
89
|
+
throw error
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
94
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function describeError(error: unknown): string {
|
|
98
|
+
return error instanceof Error ? error.message : String(error)
|
|
99
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export {
|
|
2
|
+
BUILTIN_ROLE_NAMES,
|
|
3
|
+
BUILTIN_ROLES,
|
|
4
|
+
CORE_PERMISSIONS,
|
|
5
|
+
OWNER_SECURITY_WILDCARD,
|
|
6
|
+
expandOwnerWildcard,
|
|
7
|
+
isBuiltinRoleName,
|
|
8
|
+
type BuiltinRoleName,
|
|
9
|
+
type BuiltinRoleSpec,
|
|
10
|
+
} from './builtins'
|
|
11
|
+
export {
|
|
12
|
+
MATCH_RULE_REGEX_SOURCE,
|
|
13
|
+
PLATFORMS,
|
|
14
|
+
parseMatchRule,
|
|
15
|
+
type MatchRule,
|
|
16
|
+
type ParseMatchRuleResult,
|
|
17
|
+
type Platform,
|
|
18
|
+
} from './match-rule'
|
|
19
|
+
export {
|
|
20
|
+
createPermissionService,
|
|
21
|
+
findUnknownPermissions,
|
|
22
|
+
noopPermissionService,
|
|
23
|
+
type CreatePermissionServiceOptions,
|
|
24
|
+
type PermissionService,
|
|
25
|
+
type UnknownPermissionWarning,
|
|
26
|
+
} from './permissions'
|
|
27
|
+
export { grantRole, type GrantOptions, type GrantResult } from './grant'
|
|
28
|
+
export { matchesOrigin, type MatchableOrigin } from './resolve'
|
|
29
|
+
export { MATCH_RULE_JSON_SCHEMA_PATTERN, rolesConfigSchema, type RoleConfig, type RolesConfig } from './schema'
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// Compact string DSL for permissions match rules. Examples:
|
|
2
|
+
//
|
|
3
|
+
// tui # any TUI session
|
|
4
|
+
// cron # any cron session (resolved by provenance, not match)
|
|
5
|
+
// subagent # any subagent session
|
|
6
|
+
// subagent:memory-logger # specific subagent
|
|
7
|
+
// * # any channel session, any platform
|
|
8
|
+
// slack:* # any Slack chat, any workspace
|
|
9
|
+
// slack:T0123 # one Slack workspace, any chat
|
|
10
|
+
// slack:T0123/C0ABCDE # one specific Slack chat
|
|
11
|
+
// slack:dm/* # any Slack DM
|
|
12
|
+
// slack:T0123 author:U_ME # specific author across workspace
|
|
13
|
+
//
|
|
14
|
+
// Within one rule: all tokens AND'd. Across multiple rules: OR'd. The parser
|
|
15
|
+
// is hand-rolled (not regex) because the rejection table demands precise
|
|
16
|
+
// error messages with typo suggestions; a single big regex would only ever
|
|
17
|
+
// say "didn't match".
|
|
18
|
+
|
|
19
|
+
export const PLATFORMS = ['slack', 'discord', 'telegram', 'kakao'] as const
|
|
20
|
+
export type Platform = (typeof PLATFORMS)[number]
|
|
21
|
+
|
|
22
|
+
const SUBAGENT_NAME = /^[a-z][a-z0-9-]*$/
|
|
23
|
+
|
|
24
|
+
export type MatchRule =
|
|
25
|
+
| { kind: 'tui' }
|
|
26
|
+
| { kind: 'cron' }
|
|
27
|
+
| { kind: 'subagent'; subagent?: string }
|
|
28
|
+
| { kind: 'wildcard' }
|
|
29
|
+
| {
|
|
30
|
+
kind: 'channel'
|
|
31
|
+
platform: Platform
|
|
32
|
+
// undefined when the rule wildcards across the whole platform (e.g. `slack:*`).
|
|
33
|
+
// '*' is never stored — it is collapsed to undefined at parse time so
|
|
34
|
+
// matchers stay shape-pure (presence == specificity).
|
|
35
|
+
workspace?: string
|
|
36
|
+
chat?: string
|
|
37
|
+
// Buckets for DM-style scopes. `slack:dm/*`, `discord:dm/*`,
|
|
38
|
+
// `kakao:dm/*`, `kakao:group/*`, `kakao:open/*` produce `bucket` only
|
|
39
|
+
// (no workspace, no chat).
|
|
40
|
+
bucket?: 'dm' | 'group' | 'open'
|
|
41
|
+
author?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ParseMatchRuleResult = { ok: true; value: MatchRule } | { ok: false; error: string }
|
|
45
|
+
|
|
46
|
+
// Regex used by the JSON Schema layer for editor-time validation. Kept here
|
|
47
|
+
// next to the parser so divergence is harder. Deliberately permissive: it
|
|
48
|
+
// matches any `<name>:<value>` qualifier shape so the parser still gets to
|
|
49
|
+
// run and emit typo suggestions like `autor:` -> `author:`. If we tightened
|
|
50
|
+
// this to `author:` only, the JSON schema would reject typos with a generic
|
|
51
|
+
// "did not match pattern" error and the user would lose the actionable hint.
|
|
52
|
+
export const MATCH_RULE_REGEX_SOURCE =
|
|
53
|
+
'^(tui|cron|subagent(:[a-z][a-z0-9-]*)?|\\*|(slack|discord|telegram|kakao):[^\\s]+)(\\s+[a-zA-Z][a-zA-Z0-9_]*:[^\\s]+)*$'
|
|
54
|
+
|
|
55
|
+
export function parseMatchRule(input: string): ParseMatchRuleResult {
|
|
56
|
+
if (input !== input.trim() || input.length === 0) {
|
|
57
|
+
return { ok: false, error: 'match rule must not have leading or trailing whitespace' }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The DSL allows ONLY single literal spaces as token separators. Any
|
|
61
|
+
// other whitespace (tabs, newlines, CR, vertical tab, NBSP, NUL, etc.)
|
|
62
|
+
// inside a rule is rejected -- both because the JSON Schema regex uses
|
|
63
|
+
// `\s` boundaries that would diverge from this parser otherwise, and
|
|
64
|
+
// because workspace/chat IDs containing whitespace are not a legitimate
|
|
65
|
+
// shape on any supported platform.
|
|
66
|
+
if (/[^\S ]|\u0000/.test(input)) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
error: 'match rule must use only single ASCII spaces; no tabs, newlines, or control characters',
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const tokens = input.split(' ')
|
|
73
|
+
if (tokens.some((t) => t.length === 0)) {
|
|
74
|
+
return { ok: false, error: 'match rule must use exactly one space between tokens' }
|
|
75
|
+
}
|
|
76
|
+
const [scope, ...qualifiers] = tokens
|
|
77
|
+
if (scope === undefined) return { ok: false, error: 'match rule is empty' }
|
|
78
|
+
|
|
79
|
+
const qualifierResult = parseQualifiers(qualifiers)
|
|
80
|
+
if (!qualifierResult.ok) return { ok: false, error: qualifierResult.error }
|
|
81
|
+
const { author } = qualifierResult.value
|
|
82
|
+
|
|
83
|
+
// Reject legacy shorthand prefixes with a hint to the canonical form.
|
|
84
|
+
const legacy = LEGACY_PREFIXES.find((p) => scope === p.from || scope.startsWith(`${p.from}:`))
|
|
85
|
+
if (legacy) {
|
|
86
|
+
const replaced = scope === legacy.from ? legacy.to : `${legacy.to}${scope.slice(legacy.from.length)}`
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
error: `legacy prefix '${legacy.from}'; use '${replaced}' instead`,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Top-level keyword scopes.
|
|
94
|
+
if (scope === 'tui') {
|
|
95
|
+
if (author !== undefined) return { ok: false, error: "qualifier 'author:' requires a channel scope" }
|
|
96
|
+
return { ok: true, value: { kind: 'tui' } }
|
|
97
|
+
}
|
|
98
|
+
if (scope === 'cron') {
|
|
99
|
+
if (author !== undefined) return { ok: false, error: "qualifier 'author:' requires a channel scope" }
|
|
100
|
+
return { ok: true, value: { kind: 'cron' } }
|
|
101
|
+
}
|
|
102
|
+
if (scope === 'subagent' || scope.startsWith('subagent:')) {
|
|
103
|
+
if (author !== undefined) return { ok: false, error: "qualifier 'author:' requires a channel scope" }
|
|
104
|
+
if (scope === 'subagent') return { ok: true, value: { kind: 'subagent' } }
|
|
105
|
+
const name = scope.slice('subagent:'.length)
|
|
106
|
+
if (!SUBAGENT_NAME.test(name)) {
|
|
107
|
+
return { ok: false, error: `subagent name '${name}' must match ${SUBAGENT_NAME.source}` }
|
|
108
|
+
}
|
|
109
|
+
return { ok: true, value: { kind: 'subagent', subagent: name } }
|
|
110
|
+
}
|
|
111
|
+
if (scope === '*') {
|
|
112
|
+
if (author !== undefined) return { ok: false, error: "qualifier 'author:' requires a specific channel scope" }
|
|
113
|
+
return { ok: true, value: { kind: 'wildcard' } }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Channel scopes: `<platform>:<rest>`.
|
|
117
|
+
const colon = scope.indexOf(':')
|
|
118
|
+
if (colon === -1) {
|
|
119
|
+
return { ok: false, error: suggestUnknownScope(scope) }
|
|
120
|
+
}
|
|
121
|
+
const prefix = scope.slice(0, colon)
|
|
122
|
+
const rest = scope.slice(colon + 1)
|
|
123
|
+
if (!(PLATFORMS as readonly string[]).includes(prefix)) {
|
|
124
|
+
return { ok: false, error: suggestUnknownScope(scope) }
|
|
125
|
+
}
|
|
126
|
+
const platform = prefix as Platform
|
|
127
|
+
|
|
128
|
+
return parseChannelScope(platform, rest, author)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseChannelScope(platform: Platform, rest: string, author: string | undefined): ParseMatchRuleResult {
|
|
132
|
+
if (rest.length === 0) {
|
|
133
|
+
return { ok: false, error: `channel scope '${platform}:' is missing a coordinate` }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Wildcards and redundant forms.
|
|
137
|
+
if (rest === '*') {
|
|
138
|
+
return { ok: true, value: buildChannelRule(platform, { author }) }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Bucket scopes: `dm/*`, `dm/<id>`, `group/*`, `open/*`. Slack's `im` is
|
|
142
|
+
// renamed to `dm`; that mapping is enforced by the legacy-prefix table at
|
|
143
|
+
// the top of parseMatchRule for unprefixed forms — here we just refuse the
|
|
144
|
+
// bare `im` bucket.
|
|
145
|
+
const slash = rest.indexOf('/')
|
|
146
|
+
if (slash !== -1) {
|
|
147
|
+
const head = rest.slice(0, slash)
|
|
148
|
+
const tail = rest.slice(slash + 1)
|
|
149
|
+
|
|
150
|
+
if (head === 'im') {
|
|
151
|
+
return { ok: false, error: `bucket 'im' renamed; use '${platform}:dm/${tail}'` }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (head === '*') {
|
|
155
|
+
// `slack:*/...` is always wrong — a chat ID is workspace-scoped, so any
|
|
156
|
+
// concrete chat ID under a wildcard workspace is logically impossible.
|
|
157
|
+
// Even `slack:*/*` simplifies to `slack:*`.
|
|
158
|
+
const suggestion = tail === '*' ? `${platform}:*` : `${platform}:*`
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
error: `wildcard workspace combined with '/${tail}' is nonsensical; use '${suggestion}'`,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (head === 'dm' || head === 'group' || head === 'open') {
|
|
166
|
+
if (platform !== 'kakao' && (head === 'group' || head === 'open')) {
|
|
167
|
+
return { ok: false, error: `bucket '${head}' is only valid for kakao` }
|
|
168
|
+
}
|
|
169
|
+
if (tail === '') {
|
|
170
|
+
return { ok: false, error: `bucket '${platform}:${head}/' requires '*' or a chat id` }
|
|
171
|
+
}
|
|
172
|
+
if (tail === '*') {
|
|
173
|
+
return { ok: true, value: buildChannelRule(platform, { bucket: head as 'dm' | 'group' | 'open', author }) }
|
|
174
|
+
}
|
|
175
|
+
// `slack:dm/<id>` — keep the bucket plus the specific chat. We omit a
|
|
176
|
+
// separate workspace field; DM IDs are globally unique within a
|
|
177
|
+
// platform's adapter.
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
value: buildChannelRule(platform, {
|
|
181
|
+
bucket: head as 'dm' | 'group' | 'open',
|
|
182
|
+
chat: tail,
|
|
183
|
+
author,
|
|
184
|
+
}),
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// `slack:T0123/*` is redundant — drop the trailing `/*`.
|
|
189
|
+
if (tail === '*') {
|
|
190
|
+
return { ok: false, error: `trailing '/*' is redundant; use '${platform}:${head}'` }
|
|
191
|
+
}
|
|
192
|
+
// `slack:T0123/C0ABCDE` — workspace + chat.
|
|
193
|
+
return {
|
|
194
|
+
ok: true,
|
|
195
|
+
value: buildChannelRule(platform, { workspace: head, chat: tail, author }),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// No slash: `slack:T0123` or `kakao:dm` (bare bucket — error).
|
|
200
|
+
if (rest === 'dm' || rest === 'group' || rest === 'open') {
|
|
201
|
+
return { ok: false, error: `bucket '${platform}:${rest}' requires a chat id or '*'` }
|
|
202
|
+
}
|
|
203
|
+
return { ok: true, value: buildChannelRule(platform, { workspace: rest, author }) }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function buildChannelRule(
|
|
207
|
+
platform: Platform,
|
|
208
|
+
parts: {
|
|
209
|
+
workspace?: string
|
|
210
|
+
chat?: string
|
|
211
|
+
bucket?: 'dm' | 'group' | 'open'
|
|
212
|
+
author?: string
|
|
213
|
+
},
|
|
214
|
+
): MatchRule {
|
|
215
|
+
const rule: MatchRule = { kind: 'channel', platform }
|
|
216
|
+
if (parts.workspace !== undefined) rule.workspace = parts.workspace
|
|
217
|
+
if (parts.chat !== undefined) rule.chat = parts.chat
|
|
218
|
+
if (parts.bucket !== undefined) rule.bucket = parts.bucket
|
|
219
|
+
if (parts.author !== undefined) rule.author = parts.author
|
|
220
|
+
return rule
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
type ParsedQualifiers = { author?: string }
|
|
224
|
+
function parseQualifiers(qualifiers: string[]): { ok: true; value: ParsedQualifiers } | { ok: false; error: string } {
|
|
225
|
+
const out: ParsedQualifiers = {}
|
|
226
|
+
for (const token of qualifiers) {
|
|
227
|
+
const eq = token.indexOf(':')
|
|
228
|
+
if (eq === -1) {
|
|
229
|
+
return { ok: false, error: `qualifier '${token}' must have form '<name>:<value>'` }
|
|
230
|
+
}
|
|
231
|
+
const name = token.slice(0, eq)
|
|
232
|
+
const value = token.slice(eq + 1)
|
|
233
|
+
if (value.length === 0) {
|
|
234
|
+
return { ok: false, error: `qualifier '${name}:' must have a value` }
|
|
235
|
+
}
|
|
236
|
+
if (name === 'author') {
|
|
237
|
+
if (out.author !== undefined) {
|
|
238
|
+
return { ok: false, error: `qualifier 'author:' may not appear more than once in a single rule` }
|
|
239
|
+
}
|
|
240
|
+
out.author = value
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
return { ok: false, error: `unknown qualifier '${name}:'.${suggestQualifier(name)}` }
|
|
244
|
+
}
|
|
245
|
+
return { ok: true, value: out }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Empirical typo distance: ed1 on the scope keyword catches `tem:` → `team:`
|
|
249
|
+
// (which is itself a legacy form), `slak:` → `slack:`, etc.
|
|
250
|
+
function suggestUnknownScope(scope: string): string {
|
|
251
|
+
const head = scope.split(':')[0] ?? scope
|
|
252
|
+
const candidates = ['tui', 'cron', 'subagent', ...PLATFORMS, '*']
|
|
253
|
+
const hit = closestEd1(head, candidates)
|
|
254
|
+
if (hit !== null) {
|
|
255
|
+
return `unknown scope '${scope}'; did you mean '${hit}'?`
|
|
256
|
+
}
|
|
257
|
+
return `unknown scope '${scope}'; expected one of: tui, cron, subagent, *, ${PLATFORMS.join(', ')}`
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function suggestQualifier(name: string): string {
|
|
261
|
+
const hit = closestEd1(name, ['author'])
|
|
262
|
+
return hit !== null ? ` Did you mean '${hit}:'?` : ''
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function closestEd1(input: string, candidates: readonly string[]): string | null {
|
|
266
|
+
for (const c of candidates) {
|
|
267
|
+
if (editDistanceAtMost1(input, c)) return c
|
|
268
|
+
}
|
|
269
|
+
return null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function editDistanceAtMost1(a: string, b: string): boolean {
|
|
273
|
+
if (a === b) return true
|
|
274
|
+
const la = a.length
|
|
275
|
+
const lb = b.length
|
|
276
|
+
if (Math.abs(la - lb) > 1) return false
|
|
277
|
+
let i = 0
|
|
278
|
+
let j = 0
|
|
279
|
+
let edits = 0
|
|
280
|
+
while (i < la && j < lb) {
|
|
281
|
+
if (a[i] === b[j]) {
|
|
282
|
+
i++
|
|
283
|
+
j++
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
edits++
|
|
287
|
+
if (edits > 1) return false
|
|
288
|
+
if (la === lb) {
|
|
289
|
+
i++
|
|
290
|
+
j++
|
|
291
|
+
} else if (la > lb) {
|
|
292
|
+
i++
|
|
293
|
+
} else {
|
|
294
|
+
j++
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (i < la || j < lb) edits++
|
|
298
|
+
return edits <= 1
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const LEGACY_PREFIXES: { from: string; to: string }[] = [
|
|
302
|
+
{ from: 'team', to: 'slack' },
|
|
303
|
+
{ from: 'guild', to: 'discord' },
|
|
304
|
+
{ from: 'tg', to: 'telegram' },
|
|
305
|
+
]
|