typeclaw 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -12
- 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 +7 -4
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +23 -9
- 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 +799 -319
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +6 -1
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +8 -1
- 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 +385 -12
- package/src/config/index.ts +7 -0
- package/src/config/models-mutation.ts +209 -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 +5 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +39 -58
- 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 +50 -33
- 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 +32 -6
- package/src/init/index.ts +190 -61
- 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/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 +55 -6
- 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 +68 -0
- package/src/server/index.ts +122 -11
- 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 +38 -64
- package/src/skills/typeclaw-memory/SKILL.md +1 -1
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- 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 +57 -45
|
@@ -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
|
+
]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { SessionOrigin } from '@/agent/session-origin'
|
|
2
|
+
|
|
3
|
+
import { BUILTIN_ROLE_NAMES, BUILTIN_ROLES, CORE_PERMISSIONS, expandOwnerWildcard, isBuiltinRoleName } from './builtins'
|
|
4
|
+
import type { MatchRule } from './match-rule'
|
|
5
|
+
import { matchesOrigin } from './resolve'
|
|
6
|
+
import type { RoleConfig, RolesConfig } from './schema'
|
|
7
|
+
|
|
8
|
+
export type PermissionService = {
|
|
9
|
+
has(origin: SessionOrigin | undefined, permission: string): boolean
|
|
10
|
+
resolveRole(origin: SessionOrigin | undefined): string
|
|
11
|
+
describe(origin: SessionOrigin | undefined): { role: string; permissions: readonly string[] }
|
|
12
|
+
// Rebuilds the resolved role table from the given roles config, preserving
|
|
13
|
+
// the same plugin-permission set captured at construction time. Used by
|
|
14
|
+
// the config reloadable so role match-rule edits (typeclaw role claim,
|
|
15
|
+
// hand-edits to typeclaw.json) take effect without a container restart.
|
|
16
|
+
replaceRoles(roles: RolesConfig | undefined): void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type UnknownPermissionWarning = {
|
|
20
|
+
role: string
|
|
21
|
+
permission: string
|
|
22
|
+
hint: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const noopPermissionService: PermissionService = {
|
|
26
|
+
has: () => false,
|
|
27
|
+
resolveRole: () => 'guest',
|
|
28
|
+
describe: () => ({ role: 'guest', permissions: [] }),
|
|
29
|
+
replaceRoles: () => {},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type ResolvedRole = {
|
|
33
|
+
name: string
|
|
34
|
+
match: readonly MatchRule[]
|
|
35
|
+
permissions: readonly string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type CreatePermissionServiceOptions = {
|
|
39
|
+
roles?: RolesConfig
|
|
40
|
+
pluginPermissions?: readonly string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Returns warnings for user-declared `permissions[]` strings that aren't
|
|
44
|
+
// in the known universe (core permissions ∪ plugin-declared). Non-fatal;
|
|
45
|
+
// the runtime still resolves the role with the unknown string in its
|
|
46
|
+
// permission list -- `has()` checks for that exact string and would return
|
|
47
|
+
// true if someone happened to check for it. The warning surfaces typos
|
|
48
|
+
// like `security.bypass.secretExfilBach` before they silently fail to
|
|
49
|
+
// gate the corresponding guard.
|
|
50
|
+
export function findUnknownPermissions(
|
|
51
|
+
roles: RolesConfig | undefined,
|
|
52
|
+
pluginPermissions: readonly string[],
|
|
53
|
+
): UnknownPermissionWarning[] {
|
|
54
|
+
if (!roles) return []
|
|
55
|
+
const known = new Set<string>([...Object.values(CORE_PERMISSIONS), ...pluginPermissions])
|
|
56
|
+
const out: UnknownPermissionWarning[] = []
|
|
57
|
+
for (const [role, config] of Object.entries(roles)) {
|
|
58
|
+
if (config.permissions === undefined) continue
|
|
59
|
+
for (const perm of config.permissions) {
|
|
60
|
+
if (!known.has(perm)) {
|
|
61
|
+
out.push({ role, permission: perm, hint: closestPermission(perm, known) })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function closestPermission(target: string, known: ReadonlySet<string>): string {
|
|
69
|
+
let best: { name: string; distance: number } | null = null
|
|
70
|
+
for (const name of known) {
|
|
71
|
+
const d = levenshtein(target, name)
|
|
72
|
+
if (best === null || d < best.distance) best = { name, distance: d }
|
|
73
|
+
}
|
|
74
|
+
if (best === null || best.distance > Math.max(3, Math.floor(target.length * 0.25))) {
|
|
75
|
+
return 'no close match in known permissions; check spelling'
|
|
76
|
+
}
|
|
77
|
+
return `did you mean '${best.name}'?`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function levenshtein(a: string, b: string): number {
|
|
81
|
+
const la = a.length
|
|
82
|
+
const lb = b.length
|
|
83
|
+
if (la === 0) return lb
|
|
84
|
+
if (lb === 0) return la
|
|
85
|
+
const prev = new Array<number>(lb + 1)
|
|
86
|
+
const curr = new Array<number>(lb + 1)
|
|
87
|
+
for (let j = 0; j <= lb; j++) prev[j] = j
|
|
88
|
+
for (let i = 1; i <= la; i++) {
|
|
89
|
+
curr[0] = i
|
|
90
|
+
for (let j = 1; j <= lb; j++) {
|
|
91
|
+
curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
92
|
+
}
|
|
93
|
+
for (let j = 0; j <= lb; j++) prev[j] = curr[j]!
|
|
94
|
+
}
|
|
95
|
+
return prev[lb]!
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createPermissionService(opts: CreatePermissionServiceOptions = {}): PermissionService {
|
|
99
|
+
const pluginPermissions = opts.pluginPermissions ?? []
|
|
100
|
+
let resolved = buildRoleTable(opts.roles ?? {}, pluginPermissions)
|
|
101
|
+
let byName = new Map(resolved.map((r) => [r.name, r]))
|
|
102
|
+
|
|
103
|
+
function resolveRole(origin: SessionOrigin | undefined): string {
|
|
104
|
+
if (origin === undefined) return 'guest'
|
|
105
|
+
|
|
106
|
+
if (origin.kind === 'cron') {
|
|
107
|
+
const role = origin.scheduledByRole
|
|
108
|
+
if (role !== undefined && byName.has(role)) return role
|
|
109
|
+
return 'guest'
|
|
110
|
+
}
|
|
111
|
+
if (origin.kind === 'subagent') {
|
|
112
|
+
const role = origin.spawnedByRole
|
|
113
|
+
if (role !== undefined && byName.has(role)) return role
|
|
114
|
+
return 'guest'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const matchable = toMatchable(origin)
|
|
118
|
+
if (matchable === null) return 'guest'
|
|
119
|
+
|
|
120
|
+
for (const role of resolved) {
|
|
121
|
+
for (const rule of role.match) {
|
|
122
|
+
if (matchesOrigin(rule, matchable)) return role.name
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return 'guest'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
has(origin, permission) {
|
|
130
|
+
const roleName = resolveRole(origin)
|
|
131
|
+
const role = byName.get(roleName)
|
|
132
|
+
if (!role) return false
|
|
133
|
+
return role.permissions.includes(permission)
|
|
134
|
+
},
|
|
135
|
+
resolveRole,
|
|
136
|
+
describe(origin) {
|
|
137
|
+
const name = resolveRole(origin)
|
|
138
|
+
const role = byName.get(name)
|
|
139
|
+
return { role: name, permissions: role?.permissions ?? [] }
|
|
140
|
+
},
|
|
141
|
+
replaceRoles(roles) {
|
|
142
|
+
resolved = buildRoleTable(roles ?? {}, pluginPermissions)
|
|
143
|
+
byName = new Map(resolved.map((r) => [r.name, r]))
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildRoleTable(roles: RolesConfig, pluginPermissions: readonly string[]): ResolvedRole[] {
|
|
149
|
+
const out: ResolvedRole[] = []
|
|
150
|
+
const seen = new Set<string>()
|
|
151
|
+
|
|
152
|
+
for (const name of Object.keys(roles)) {
|
|
153
|
+
if (seen.has(name)) continue
|
|
154
|
+
seen.add(name)
|
|
155
|
+
out.push(resolveOne(name, roles[name], pluginPermissions))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const name of BUILTIN_ROLE_NAMES) {
|
|
159
|
+
if (seen.has(name)) continue
|
|
160
|
+
out.push(resolveOne(name, undefined, pluginPermissions))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return out
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function resolveOne(name: string, user: RoleConfig | undefined, pluginPermissions: readonly string[]): ResolvedRole {
|
|
167
|
+
if (isBuiltinRoleName(name)) {
|
|
168
|
+
const builtin = BUILTIN_ROLES[name]
|
|
169
|
+
const match = [...builtin.match, ...(user?.match ?? [])]
|
|
170
|
+
const rawPerms = user?.permissions !== undefined ? user.permissions : [...builtin.permissions]
|
|
171
|
+
const permissions = name === 'owner' ? expandOwnerWildcard(rawPerms, pluginPermissions) : rawPerms
|
|
172
|
+
return { name, match, permissions }
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
name,
|
|
176
|
+
match: user?.match ?? [],
|
|
177
|
+
permissions: user?.permissions ?? [],
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function toMatchable(origin: SessionOrigin): Parameters<typeof matchesOrigin>[1] | null {
|
|
182
|
+
switch (origin.kind) {
|
|
183
|
+
case 'tui':
|
|
184
|
+
return { kind: 'tui', sessionId: origin.sessionId }
|
|
185
|
+
case 'channel':
|
|
186
|
+
return {
|
|
187
|
+
kind: 'channel',
|
|
188
|
+
adapter: origin.adapter,
|
|
189
|
+
workspace: origin.workspace,
|
|
190
|
+
chat: origin.chat,
|
|
191
|
+
...(origin.lastInboundAuthorId !== undefined ? { lastInboundAuthorId: origin.lastInboundAuthorId } : {}),
|
|
192
|
+
}
|
|
193
|
+
default:
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
}
|