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.
Files changed (134) hide show
  1. package/README.md +15 -13
  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 +13 -10
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +137 -7
  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 +809 -300
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +11 -3
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +13 -3
  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 +491 -19
  67. package/src/config/index.ts +15 -1
  68. package/src/config/models-mutation.ts +200 -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 +6 -1
  73. package/src/container/port.ts +10 -0
  74. package/src/container/require-running.ts +33 -0
  75. package/src/container/start.ts +81 -63
  76. package/src/cron/consumer.ts +22 -2
  77. package/src/cron/index.ts +45 -4
  78. package/src/cron/schema.ts +104 -0
  79. package/src/doctor/checks.ts +51 -34
  80. package/src/doctor/plugin-bridge.ts +28 -4
  81. package/src/git/system-commit.ts +103 -0
  82. package/src/hostd/daemon.ts +16 -0
  83. package/src/hostd/kakao-renewal-manager.ts +223 -0
  84. package/src/hostd/paths.ts +7 -0
  85. package/src/init/dockerfile.ts +36 -10
  86. package/src/init/gitignore.ts +1 -1
  87. package/src/init/index.ts +213 -85
  88. package/src/init/kakaotalk-auth.ts +18 -1
  89. package/src/init/models-dev.ts +26 -1
  90. package/src/init/run-owner-claim.ts +77 -0
  91. package/src/permissions/builtins.ts +70 -0
  92. package/src/permissions/grant.ts +99 -0
  93. package/src/permissions/index.ts +29 -0
  94. package/src/permissions/match-rule.ts +305 -0
  95. package/src/permissions/permissions.ts +196 -0
  96. package/src/permissions/resolve.ts +80 -0
  97. package/src/permissions/schema.ts +79 -0
  98. package/src/plugin/context.ts +8 -4
  99. package/src/plugin/define.ts +2 -0
  100. package/src/plugin/index.ts +2 -0
  101. package/src/plugin/manager.ts +41 -0
  102. package/src/plugin/registry.ts +9 -0
  103. package/src/plugin/types.ts +35 -1
  104. package/src/reload/client.ts +25 -1
  105. package/src/role-claim/client.ts +182 -0
  106. package/src/role-claim/code.ts +53 -0
  107. package/src/role-claim/controller.ts +194 -0
  108. package/src/role-claim/index.ts +19 -0
  109. package/src/role-claim/match-rule.ts +43 -0
  110. package/src/role-claim/pending.ts +100 -0
  111. package/src/run/channel-session-factory.ts +76 -5
  112. package/src/run/index.ts +68 -7
  113. package/src/secrets/encryption.ts +116 -0
  114. package/src/secrets/kakao-renewal.ts +248 -0
  115. package/src/secrets/kakao-store.ts +66 -7
  116. package/src/secrets/keys.ts +173 -0
  117. package/src/secrets/schema.ts +23 -0
  118. package/src/secrets/storage.ts +83 -0
  119. package/src/server/index.ts +198 -71
  120. package/src/shared/index.ts +4 -0
  121. package/src/shared/protocol.ts +27 -0
  122. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  123. package/src/skills/typeclaw-config/SKILL.md +104 -112
  124. package/src/skills/typeclaw-memory/SKILL.md +9 -9
  125. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  126. package/src/stream/types.ts +7 -1
  127. package/src/tui/client.ts +66 -5
  128. package/src/tui/index.ts +61 -9
  129. package/src/usage/aggregate.ts +117 -0
  130. package/src/usage/format.ts +30 -0
  131. package/src/usage/index.ts +68 -0
  132. package/src/usage/report.ts +354 -0
  133. package/src/usage/scan.ts +186 -0
  134. package/typeclaw.schema.json +134 -98
@@ -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
+ }
@@ -0,0 +1,80 @@
1
+ import type { AdapterId } from '@/channels/schema'
2
+
3
+ import type { MatchRule, Platform } from './match-rule'
4
+
5
+ const ADAPTER_TO_PLATFORM: Record<AdapterId, Platform> = {
6
+ 'slack-bot': 'slack',
7
+ 'discord-bot': 'discord',
8
+ 'telegram-bot': 'telegram',
9
+ kakaotalk: 'kakao',
10
+ }
11
+
12
+ export type MatchableOrigin =
13
+ | { kind: 'tui'; sessionId?: string }
14
+ | { kind: 'cron'; jobId?: string }
15
+ | { kind: 'subagent'; subagent?: string }
16
+ | {
17
+ kind: 'channel'
18
+ adapter: AdapterId
19
+ workspace: string
20
+ chat: string
21
+ lastInboundAuthorId?: string
22
+ }
23
+
24
+ export function matchesOrigin(rule: MatchRule, origin: MatchableOrigin): boolean {
25
+ switch (rule.kind) {
26
+ case 'wildcard':
27
+ return origin.kind === 'channel'
28
+ case 'tui':
29
+ return origin.kind === 'tui'
30
+ case 'cron':
31
+ return origin.kind === 'cron'
32
+ case 'subagent':
33
+ if (origin.kind !== 'subagent') return false
34
+ if (rule.subagent === undefined) return true
35
+ return origin.subagent === rule.subagent
36
+ case 'channel':
37
+ return matchesChannel(rule, origin)
38
+ }
39
+ }
40
+
41
+ function matchesChannel(rule: Extract<MatchRule, { kind: 'channel' }>, origin: MatchableOrigin): boolean {
42
+ if (origin.kind !== 'channel') return false
43
+ if (ADAPTER_TO_PLATFORM[origin.adapter] !== rule.platform) return false
44
+
45
+ if (rule.bucket !== undefined) {
46
+ if (!matchesBucket(rule.bucket, origin)) return false
47
+ if (rule.chat !== undefined && rule.chat !== origin.chat) return false
48
+ } else {
49
+ if (rule.workspace !== undefined && rule.workspace !== origin.workspace) return false
50
+ if (rule.chat !== undefined && rule.chat !== origin.chat) return false
51
+ }
52
+
53
+ if (rule.author !== undefined && rule.author !== origin.lastInboundAuthorId) return false
54
+ return true
55
+ }
56
+
57
+ // DM and group buckets are inferred from the workspace/chat shape of the
58
+ // inbound origin. Different adapters mark DMs differently — Slack uses an
59
+ // `@dm` workspace marker today, Discord uses a `dm` workspace marker, and
60
+ // KakaoTalk preserves group/open/dm explicitly in its workspace field. We
61
+ // keep the heuristic narrow: a rule that says `slack:dm/*` matches only when
62
+ // the origin's workspace is `@dm` (Slack) or `dm` (Discord). KakaoTalk uses
63
+ // the workspace prefix itself.
64
+ function matchesBucket(
65
+ bucket: 'dm' | 'group' | 'open',
66
+ origin: Extract<MatchableOrigin, { kind: 'channel' }>,
67
+ ): boolean {
68
+ const platform = ADAPTER_TO_PLATFORM[origin.adapter]
69
+ if (platform === 'kakao') {
70
+ if (bucket === 'dm') return origin.workspace === '@kakao-dm'
71
+ if (bucket === 'group') return origin.workspace === '@kakao-group'
72
+ if (bucket === 'open') return origin.workspace === '@kakao-open'
73
+ return false
74
+ }
75
+ if (bucket !== 'dm') return false
76
+ if (platform === 'slack') return origin.workspace === '@dm'
77
+ if (platform === 'discord') return origin.workspace === 'dm' || origin.workspace === '@dm'
78
+ if (platform === 'telegram') return origin.workspace === 'dm' || origin.workspace.startsWith('@')
79
+ return false
80
+ }
@@ -0,0 +1,79 @@
1
+ import { z } from 'zod'
2
+
3
+ import { isBuiltinRoleName } from './builtins'
4
+ import { type MatchRule, MATCH_RULE_REGEX_SOURCE, parseMatchRule } from './match-rule'
5
+
6
+ // The `.regex()` is added so the generated JSON Schema emits a `pattern`
7
+ // for editor-time validation (catches typos like `tem:T0123` before the
8
+ // agent ever boots). The pattern is intentionally a permissive
9
+ // over-approximation of what `parseMatchRule` accepts; the parser owns
10
+ // the precise semantic errors (typo suggestions, redundant-form rejection,
11
+ // etc.) at boot time. Layering the two means editors flag obvious shape
12
+ // mistakes immediately and the parser flags the rest with actionable
13
+ // messages.
14
+ const matchRuleSchema: z.ZodType<MatchRule> = z
15
+ .string()
16
+ .regex(new RegExp(MATCH_RULE_REGEX_SOURCE), {
17
+ message: "match rule must look like 'tui' / 'slack:T0123' / 'discord:9999 author:U_X'",
18
+ })
19
+ .transform((raw, ctx) => {
20
+ const parsed = parseMatchRule(raw)
21
+ if (!parsed.ok) {
22
+ ctx.addIssue({ code: 'custom', message: parsed.error })
23
+ return z.NEVER
24
+ }
25
+ return parsed.value
26
+ })
27
+
28
+ export const MATCH_RULE_JSON_SCHEMA_PATTERN = MATCH_RULE_REGEX_SOURCE
29
+
30
+ const PERMISSION_NAME = /^[a-z][a-z0-9]*(\.[a-z][a-zA-Z0-9]*)+$/
31
+
32
+ const permissionSchema = z.string().min(1).regex(PERMISSION_NAME, {
33
+ message: "permission must be lowercase dotted form like 'cron.schedule' or 'security.bypass.foo'",
34
+ })
35
+
36
+ const roleConfigSchema = z
37
+ .object({
38
+ match: z.array(matchRuleSchema).default([]),
39
+ permissions: z.array(permissionSchema).optional(),
40
+ })
41
+ .strict()
42
+
43
+ export type RoleConfig = z.infer<typeof roleConfigSchema>
44
+
45
+ export const rolesConfigSchema = z.record(z.string(), roleConfigSchema).superRefine((roles, ctx) => {
46
+ for (const [name, role] of Object.entries(roles)) {
47
+ if (!isValidRoleName(name)) {
48
+ ctx.addIssue({
49
+ code: 'custom',
50
+ path: [name],
51
+ message: `role name '${name}' must match /^[a-z][a-z0-9-]*$/`,
52
+ })
53
+ continue
54
+ }
55
+ if (!isBuiltinRoleName(name)) {
56
+ if (role.permissions === undefined) {
57
+ ctx.addIssue({
58
+ code: 'custom',
59
+ path: [name, 'permissions'],
60
+ message: `custom role '${name}' must declare 'permissions' (built-in defaults apply only to built-in role names)`,
61
+ })
62
+ }
63
+ if (role.match.length === 0) {
64
+ ctx.addIssue({
65
+ code: 'custom',
66
+ path: [name, 'match'],
67
+ message: `custom role '${name}' must declare at least one 'match' rule`,
68
+ })
69
+ }
70
+ }
71
+ }
72
+ })
73
+
74
+ export type RolesConfig = z.infer<typeof rolesConfigSchema>
75
+
76
+ const ROLE_NAME = /^[a-z][a-z0-9-]*$/
77
+ function isValidRoleName(name: string): boolean {
78
+ return ROLE_NAME.test(name)
79
+ }
@@ -1,6 +1,8 @@
1
- import type { PluginContext, PluginLogger } from './types'
1
+ import type { PermissionService } from '@/permissions'
2
2
 
3
- export type SpawnSubagentFn = (name: string, payload?: unknown) => Promise<void>
3
+ import type { PluginContext, PluginLogger, SpawnSubagentOptions } from './types'
4
+
5
+ export type SpawnSubagentFn = (name: string, payload?: unknown, options?: SpawnSubagentOptions) => Promise<void>
4
6
 
5
7
  export type CreatePluginContextOptions<TConfig> = {
6
8
  name: string
@@ -8,6 +10,7 @@ export type CreatePluginContextOptions<TConfig> = {
8
10
  agentDir: string
9
11
  config: TConfig
10
12
  logger: PluginLogger
13
+ permissions: PermissionService
11
14
  spawnSubagent: SpawnSubagentFn
12
15
  isBooted: () => boolean
13
16
  }
@@ -19,13 +22,14 @@ export function createPluginContext<TConfig>(opts: CreatePluginContextOptions<TC
19
22
  agentDir: opts.agentDir,
20
23
  config: opts.config,
21
24
  logger: opts.logger,
22
- spawnSubagent: async (name: string, payload?: unknown) => {
25
+ permissions: opts.permissions,
26
+ spawnSubagent: async (name: string, payload?: unknown, options?: SpawnSubagentOptions) => {
23
27
  if (!opts.isBooted()) {
24
28
  throw new Error(
25
29
  `plugin ${opts.name}: spawnSubagent("${name}") called before boot completed; subagent registry is not yet wired`,
26
30
  )
27
31
  }
28
- await opts.spawnSubagent(name, payload)
32
+ await opts.spawnSubagent(name, payload, options)
29
33
  },
30
34
  })
31
35
  }
@@ -6,9 +6,11 @@ type DefinePluginSpec<S extends z.ZodType<unknown> | undefined> =
6
6
  S extends z.ZodType<infer T>
7
7
  ? {
8
8
  configSchema: S
9
+ permissions?: readonly string[]
9
10
  plugin: (ctx: PluginContext<T>) => Promise<PluginExports>
10
11
  }
11
12
  : {
13
+ permissions?: readonly string[]
12
14
  plugin: (ctx: PluginContext<unknown>) => Promise<PluginExports>
13
15
  }
14
16
 
@@ -36,6 +36,7 @@ export type {
36
36
  SessionIdleEvent,
37
37
  SessionPromptEvent,
38
38
  SessionStartEvent,
39
+ SpawnSubagentOptions,
39
40
  Subagent,
40
41
  SubagentContext,
41
42
  Tool,
@@ -54,6 +55,7 @@ export {
54
55
  type LoadPluginsOptions,
55
56
  type LoadPluginsResult,
56
57
  } from './manager'
58
+ export type { PermissionService } from '@/permissions'
57
59
  export type { LoadPluginEntryFn, ResolvedPlugin } from './loader'
58
60
  export { loadPluginEntry, derivePluginNameFromPackage } from './loader'
59
61
  export { materializeSkills, type MaterializedSkills, type SkillEntry } from './skills'
@@ -1,6 +1,12 @@
1
1
  import { z } from 'zod'
2
2
 
3
3
  import type { CronJob } from '@/cron'
4
+ import {
5
+ createPermissionService,
6
+ findUnknownPermissions,
7
+ type PermissionService,
8
+ type RolesConfig,
9
+ } from '@/permissions'
4
10
 
5
11
  import { createPluginContext, createPluginLogger, type SpawnSubagentFn } from './context'
6
12
  import { createHookBus, type HookBus } from './hooks'
@@ -13,6 +19,7 @@ export type LoadPluginsOptions = {
13
19
  agentDir: string
14
20
  configsByName: Record<string, unknown>
15
21
  loadEntry?: LoadPluginEntryFn
22
+ roles?: RolesConfig
16
23
  // Bundled plugins resolved by the runtime (not from typeclaw.json). Loaded
17
24
  // before user-declared `entries` so a config block named after a bundled
18
25
  // plugin (e.g. "memory") is consumed by the bundled plugin, and so plugin-
@@ -23,6 +30,8 @@ export type LoadPluginsOptions = {
23
30
  export type LoadPluginsResult = {
24
31
  registry: PluginRegistry
25
32
  hooks: HookBus
33
+ permissions: PermissionService
34
+ declaredPermissions: readonly string[]
26
35
  loadedPlugins: { name: string; version: string | undefined; source: string }[]
27
36
  markBooted: () => void
28
37
  setSpawnSubagent: (fn: SpawnSubagentFn) => void
@@ -46,6 +55,23 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
46
55
  )),
47
56
  ]
48
57
 
58
+ const declaredPermissions = collectDeclaredPermissions(allPlugins)
59
+ const permissions = createPermissionService({
60
+ ...(opts.roles !== undefined ? { roles: opts.roles } : {}),
61
+ pluginPermissions: declaredPermissions,
62
+ })
63
+
64
+ // Non-fatal: surface user-declared `permissions[]` strings that aren't in
65
+ // the known set, so a typo like `security.bypass.secretExfilBach` is
66
+ // visible at boot rather than silently failing to bypass the matching
67
+ // guard. We log instead of throw because the runtime still functions --
68
+ // the unknown string just never matches anything.
69
+ for (const warning of findUnknownPermissions(opts.roles, declaredPermissions)) {
70
+ console.warn(
71
+ `[permissions] role "${warning.role}" declares unknown permission "${warning.permission}" — ${warning.hint}`,
72
+ )
73
+ }
74
+
49
75
  for (const { entry, resolved } of allPlugins) {
50
76
  if (loaded.find((l) => l.name === resolved.name)) {
51
77
  throw new Error(`plugin name conflict: ${resolved.name} (entry ${entry}) already loaded`)
@@ -72,6 +98,7 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
72
98
  agentDir: opts.agentDir,
73
99
  config: validatedConfig as never,
74
100
  logger,
101
+ permissions,
75
102
  spawnSubagent: (name, payload) => spawnSubagentImpl(name, payload),
76
103
  isBooted: () => booted,
77
104
  })
@@ -106,6 +133,8 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
106
133
  return {
107
134
  registry,
108
135
  hooks,
136
+ permissions,
137
+ declaredPermissions,
109
138
  loadedPlugins: loaded,
110
139
  markBooted: () => {
111
140
  booted = true
@@ -116,6 +145,18 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
116
145
  }
117
146
  }
118
147
 
148
+ function collectDeclaredPermissions(
149
+ plugins: readonly { entry: string; resolved: ResolvedPlugin }[],
150
+ ): readonly string[] {
151
+ const out: string[] = []
152
+ for (const { resolved } of plugins) {
153
+ for (const perm of resolved.defined.permissions ?? []) {
154
+ if (!out.includes(perm)) out.push(perm)
155
+ }
156
+ }
157
+ return out
158
+ }
159
+
119
160
  export function summarizeLoaded(loaded: LoadPluginsResult['loadedPlugins'], registry: PluginRegistry): string {
120
161
  const head = loaded.map((p) => (p.version !== undefined ? `${p.name} v${p.version}` : p.name)).join(', ')
121
162
  const counts = [
@@ -150,6 +150,13 @@ function assertNotEmpty(kind: string, value: string, pluginName: string): void {
150
150
  }
151
151
 
152
152
  function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
153
+ // Plugin-contributed jobs default to `owner` because they are part of the
154
+ // agent's bundled (or operator-installed) runtime, not user-channel
155
+ // schedules. Without this default they would resolve to `guest` and the
156
+ // bundled memory dreaming cron (which writes MEMORY.md, runs git, etc.)
157
+ // would lose every security bypass. Hand-authored cron.json entries take
158
+ // a different path and must declare scheduledByRole explicitly.
159
+ const scheduledByRole: PromptJob['scheduledByRole'] = 'owner'
153
160
  if (spec.kind === 'prompt') {
154
161
  const job: PromptJob = {
155
162
  id: globalId,
@@ -157,6 +164,7 @@ function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
157
164
  enabled: spec.enabled ?? true,
158
165
  kind: 'prompt',
159
166
  prompt: spec.prompt,
167
+ scheduledByRole,
160
168
  ...(spec.timezone !== undefined ? { timezone: spec.timezone } : {}),
161
169
  ...(spec.subagent !== undefined ? { subagent: spec.subagent } : {}),
162
170
  ...(spec.payload !== undefined ? { payload: spec.payload } : {}),
@@ -169,6 +177,7 @@ function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
169
177
  enabled: spec.enabled ?? true,
170
178
  kind: 'exec',
171
179
  command: spec.command,
180
+ scheduledByRole,
172
181
  ...(spec.timezone !== undefined ? { timezone: spec.timezone } : {}),
173
182
  }
174
183
  }
@@ -1,6 +1,8 @@
1
1
  import type { z } from 'zod'
2
2
 
3
3
  import type { SessionOrigin } from '@/agent/session-origin'
4
+ import type { ToolResultBudget } from '@/agent/tool-result-budget'
5
+ import type { PermissionService } from '@/permissions'
4
6
 
5
7
  export type ContentPart = { type: 'text'; text: string } | { type: 'image'; mimeType: string; data: string }
6
8
 
@@ -40,6 +42,13 @@ export type RunSession = (override?: { userPrompt?: string }) => Promise<void>
40
42
 
41
43
  export type Subagent<P = unknown> = {
42
44
  systemPrompt: string
45
+ // Model profile this subagent prefers. Resolved against `models` in
46
+ // typeclaw.json at session construction. Unknown profile names fall back to
47
+ // `default` with a warning. Well-known names: `default`, `fast`, `deep`,
48
+ // `vision`. Subagents that want a specific tier (e.g. memory-logger wants
49
+ // `fast`, dreaming wants `deep`) declare it here so the user only has to
50
+ // map tier → model in config rather than wire each subagent individually.
51
+ profile?: string
43
52
  tools?: BuiltinToolRef[]
44
53
  customTools?: Tool<any>[]
45
54
  payloadSchema?: z.ZodType<P>
@@ -50,6 +59,16 @@ export type Subagent<P = unknown> = {
50
59
  // parentSessionId so different parent sessions run in parallel while
51
60
  // duplicate runs against the same session deduplicate.
52
61
  inFlightKey?: (payload: P) => string
62
+ // Defensive ceiling on cumulative bytes of tool-result text per subagent
63
+ // run, applied to the named tools only. Once exceeded, subsequent calls to
64
+ // those tools short-circuit with a fixed message instructing the agent to
65
+ // stop reading. See `src/agent/tool-result-budget.ts` for the full
66
+ // rationale; the short version is: a single broken tool (e.g. find_entry
67
+ // failing because of a schema mismatch) can cause an agent to fall back to
68
+ // chunked reads of huge files, ballooning subagent token cost. The budget
69
+ // bounds the blast radius without changing per-call semantics for healthy
70
+ // runs.
71
+ toolResultBudget?: ToolResultBudget
53
72
  }
54
73
 
55
74
  // Cron job map keys are local; the runtime prefixes with `__plugin_<plugin-name>_`
@@ -88,6 +107,7 @@ export type SessionStartEvent = {
88
107
 
89
108
  export type SessionEndEvent = {
90
109
  sessionId: string
110
+ origin?: SessionOrigin
91
111
  }
92
112
 
93
113
  export type SessionIdleEvent = {
@@ -132,6 +152,7 @@ export type ToolBeforeEvent = {
132
152
  sessionId: string
133
153
  callId: string
134
154
  args: Record<string, unknown>
155
+ origin?: SessionOrigin
135
156
  }
136
157
 
137
158
  export type ToolBeforeResult = void | undefined | { block: true; reason: string }
@@ -168,13 +189,25 @@ export type PluginLogger = {
168
189
  error: (msg: string) => void
169
190
  }
170
191
 
192
+ export type SpawnSubagentOptions = {
193
+ // Identifies the spawning session so the subagent's session origin carries
194
+ // parent provenance. Hook handlers that own this context (e.g. session.idle,
195
+ // session.turn.end) should pass at minimum `parentSessionId` and
196
+ // `spawnedByOrigin: event.origin`. The runtime resolves `spawnedByRole`
197
+ // from the origin via the PermissionService, so the spawning session's
198
+ // role is inherited rather than forged from outside.
199
+ parentSessionId?: string
200
+ spawnedByOrigin?: SessionOrigin
201
+ }
202
+
171
203
  export type PluginContext<TConfig = never> = {
172
204
  readonly name: string
173
205
  readonly version: string | undefined
174
206
  readonly agentDir: string
175
207
  readonly config: TConfig
176
208
  readonly logger: PluginLogger
177
- spawnSubagent: (name: string, payload?: unknown) => Promise<void>
209
+ readonly permissions: PermissionService
210
+ spawnSubagent: (name: string, payload?: unknown, options?: SpawnSubagentOptions) => Promise<void>
178
211
  }
179
212
 
180
213
  export type PluginExports = {
@@ -233,5 +266,6 @@ export type PluginFixResult = {
233
266
 
234
267
  export type DefinedPlugin<TConfig = never> = {
235
268
  readonly configSchema?: z.ZodType<TConfig>
269
+ readonly permissions?: readonly string[]
236
270
  readonly plugin: (ctx: PluginContext<TConfig>) => Promise<PluginExports>
237
271
  }
@@ -16,22 +16,36 @@ export async function requestReload({
16
16
  timeoutMs = DEFAULT_TIMEOUT_MS,
17
17
  }: RequestReloadOptions): Promise<ReloadResult[]> {
18
18
  const ws = new WebSocket(url)
19
+ const displayUrl = redactUrl(url)
19
20
 
20
21
  await new Promise<void>((resolve, reject) => {
22
+ let timer: ReturnType<typeof setTimeout> | undefined
21
23
  const onOpen = () => {
22
24
  cleanup()
23
25
  resolve()
24
26
  }
25
27
  const onError = (err: unknown) => {
26
28
  cleanup()
27
- reject(err instanceof Error ? err : new Error(`failed to connect to ${url}`))
29
+ reject(new Error(`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`))
30
+ }
31
+ const onClose = () => {
32
+ cleanup()
33
+ reject(new Error(`connection to ${displayUrl} closed before opening`))
28
34
  }
29
35
  const cleanup = () => {
36
+ if (timer !== undefined) clearTimeout(timer)
30
37
  ws.removeEventListener('open', onOpen)
31
38
  ws.removeEventListener('error', onError)
39
+ ws.removeEventListener('close', onClose)
32
40
  }
41
+ timer = setTimeout(() => {
42
+ cleanup()
43
+ ws.close()
44
+ reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
45
+ }, timeoutMs)
33
46
  ws.addEventListener('open', onOpen, { once: true })
34
47
  ws.addEventListener('error', onError, { once: true })
48
+ ws.addEventListener('close', onClose, { once: true })
35
49
  })
36
50
 
37
51
  try {
@@ -57,3 +71,13 @@ export async function requestReload({
57
71
  ws.close()
58
72
  }
59
73
  }
74
+
75
+ function redactUrl(url: string): string {
76
+ try {
77
+ const parsed = new URL(url)
78
+ if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
79
+ return parsed.toString()
80
+ } catch {
81
+ return url
82
+ }
83
+ }