typeclaw 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -13
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +13 -10
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +137 -7
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +809 -300
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +11 -3
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +13 -3
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +491 -19
- package/src/config/index.ts +15 -1
- package/src/config/models-mutation.ts +200 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +6 -1
- package/src/container/port.ts +10 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +81 -63
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +51 -34
- package/src/doctor/plugin-bridge.ts +28 -4
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +36 -10
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +213 -85
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/reload/client.ts +25 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +68 -7
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +83 -0
- package/src/server/index.ts +198 -71
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +104 -112
- package/src/skills/typeclaw-memory/SKILL.md +9 -9
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/tui/client.ts +66 -5
- package/src/tui/index.ts +61 -9
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +134 -98
|
@@ -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
|
+
}
|
package/src/plugin/context.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PermissionService } from '@/permissions'
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/plugin/define.ts
CHANGED
|
@@ -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
|
|
package/src/plugin/index.ts
CHANGED
|
@@ -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'
|
package/src/plugin/manager.ts
CHANGED
|
@@ -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 = [
|
package/src/plugin/registry.ts
CHANGED
|
@@ -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
|
}
|
package/src/plugin/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/reload/client.ts
CHANGED
|
@@ -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(
|
|
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
|
+
}
|