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.
Files changed (128) hide show
  1. package/README.md +14 -12
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +7 -4
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +23 -9
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +799 -319
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +6 -1
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +8 -1
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +385 -12
  67. package/src/config/index.ts +7 -0
  68. package/src/config/models-mutation.ts +209 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +5 -0
  73. package/src/container/require-running.ts +33 -0
  74. package/src/container/start.ts +39 -58
  75. package/src/cron/consumer.ts +22 -2
  76. package/src/cron/index.ts +45 -4
  77. package/src/cron/schema.ts +104 -0
  78. package/src/doctor/checks.ts +50 -33
  79. package/src/git/system-commit.ts +103 -0
  80. package/src/hostd/daemon.ts +16 -0
  81. package/src/hostd/kakao-renewal-manager.ts +223 -0
  82. package/src/hostd/paths.ts +7 -0
  83. package/src/init/dockerfile.ts +32 -6
  84. package/src/init/index.ts +190 -61
  85. package/src/init/kakaotalk-auth.ts +18 -1
  86. package/src/init/models-dev.ts +26 -1
  87. package/src/init/run-owner-claim.ts +77 -0
  88. package/src/permissions/builtins.ts +70 -0
  89. package/src/permissions/grant.ts +99 -0
  90. package/src/permissions/index.ts +29 -0
  91. package/src/permissions/match-rule.ts +305 -0
  92. package/src/permissions/permissions.ts +196 -0
  93. package/src/permissions/resolve.ts +80 -0
  94. package/src/permissions/schema.ts +79 -0
  95. package/src/plugin/context.ts +8 -4
  96. package/src/plugin/define.ts +2 -0
  97. package/src/plugin/index.ts +2 -0
  98. package/src/plugin/manager.ts +41 -0
  99. package/src/plugin/registry.ts +9 -0
  100. package/src/plugin/types.ts +35 -1
  101. package/src/role-claim/client.ts +182 -0
  102. package/src/role-claim/code.ts +53 -0
  103. package/src/role-claim/controller.ts +194 -0
  104. package/src/role-claim/index.ts +19 -0
  105. package/src/role-claim/match-rule.ts +43 -0
  106. package/src/role-claim/pending.ts +100 -0
  107. package/src/run/channel-session-factory.ts +76 -5
  108. package/src/run/index.ts +55 -6
  109. package/src/secrets/encryption.ts +116 -0
  110. package/src/secrets/kakao-renewal.ts +248 -0
  111. package/src/secrets/kakao-store.ts +66 -7
  112. package/src/secrets/keys.ts +173 -0
  113. package/src/secrets/schema.ts +23 -0
  114. package/src/secrets/storage.ts +68 -0
  115. package/src/server/index.ts +122 -11
  116. package/src/shared/index.ts +4 -0
  117. package/src/shared/protocol.ts +27 -0
  118. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  119. package/src/skills/typeclaw-config/SKILL.md +38 -64
  120. package/src/skills/typeclaw-memory/SKILL.md +1 -1
  121. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  122. package/src/stream/types.ts +7 -1
  123. package/src/usage/aggregate.ts +117 -0
  124. package/src/usage/format.ts +30 -0
  125. package/src/usage/index.ts +68 -0
  126. package/src/usage/report.ts +354 -0
  127. package/src/usage/scan.ts +186 -0
  128. 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
+ }