typeclaw 0.8.0 → 0.9.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 (92) hide show
  1. package/README.md +6 -6
  2. package/package.json +5 -3
  3. package/scripts/require-parallel.ts +41 -0
  4. package/src/agent/index.ts +55 -6
  5. package/src/agent/live-sessions.ts +34 -0
  6. package/src/agent/plugin-tools.ts +2 -0
  7. package/src/agent/session-meta.ts +21 -2
  8. package/src/agent/subagent-completion-reminder.ts +89 -0
  9. package/src/agent/subagents.ts +3 -2
  10. package/src/agent/system-prompt.ts +10 -8
  11. package/src/bundled-plugins/explorer/explorer.ts +2 -2
  12. package/src/bundled-plugins/guard/index.ts +14 -1
  13. package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
  14. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
  15. package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
  16. package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
  17. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
  18. package/src/bundled-plugins/guard/policy.ts +7 -0
  19. package/src/bundled-plugins/memory/README.md +76 -62
  20. package/src/bundled-plugins/memory/append-tool.ts +3 -2
  21. package/src/bundled-plugins/memory/citation-superset.ts +49 -11
  22. package/src/bundled-plugins/memory/citations.ts +19 -8
  23. package/src/bundled-plugins/memory/delete-tool.ts +57 -0
  24. package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
  25. package/src/bundled-plugins/memory/dreaming.ts +364 -146
  26. package/src/bundled-plugins/memory/frontmatter.ts +165 -0
  27. package/src/bundled-plugins/memory/index.ts +236 -16
  28. package/src/bundled-plugins/memory/injection-plan.ts +15 -0
  29. package/src/bundled-plugins/memory/load-memory.ts +102 -103
  30. package/src/bundled-plugins/memory/load-shards.ts +156 -0
  31. package/src/bundled-plugins/memory/memory-logger.ts +16 -15
  32. package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
  33. package/src/bundled-plugins/memory/migration.ts +282 -1
  34. package/src/bundled-plugins/memory/paths.ts +42 -0
  35. package/src/bundled-plugins/memory/search-tool.ts +232 -0
  36. package/src/bundled-plugins/memory/secret-detector.ts +2 -2
  37. package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
  38. package/src/bundled-plugins/memory/slug.ts +59 -0
  39. package/src/bundled-plugins/memory/stream-io.ts +110 -1
  40. package/src/bundled-plugins/memory/strength.ts +3 -3
  41. package/src/bundled-plugins/memory/topics.ts +70 -16
  42. package/src/bundled-plugins/security/index.ts +24 -0
  43. package/src/bundled-plugins/security/permissions.ts +4 -0
  44. package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
  45. package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
  46. package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
  47. package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
  48. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
  49. package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
  50. package/src/channels/adapters/kakaotalk.ts +64 -37
  51. package/src/channels/adapters/slack-bot-classify.ts +2 -27
  52. package/src/channels/index.ts +5 -0
  53. package/src/channels/router.ts +201 -17
  54. package/src/channels/subagent-completion-bridge.ts +84 -0
  55. package/src/cli/builtins.ts +1 -0
  56. package/src/cli/index.ts +1 -0
  57. package/src/cli/init.ts +122 -14
  58. package/src/cli/inspect.ts +151 -0
  59. package/src/cron/consumer.ts +1 -1
  60. package/src/init/dockerfile.ts +268 -4
  61. package/src/init/hatching.ts +5 -6
  62. package/src/init/kakaotalk-auth.ts +6 -47
  63. package/src/init/validate-api-key.ts +121 -0
  64. package/src/inspect/index.ts +213 -0
  65. package/src/inspect/label.ts +50 -0
  66. package/src/inspect/live.ts +221 -0
  67. package/src/inspect/render.ts +163 -0
  68. package/src/inspect/replay.ts +265 -0
  69. package/src/inspect/session-list.ts +160 -0
  70. package/src/inspect/types.ts +110 -0
  71. package/src/plugin/hooks.ts +23 -1
  72. package/src/plugin/index.ts +2 -0
  73. package/src/plugin/manager.ts +1 -1
  74. package/src/plugin/registry.ts +1 -1
  75. package/src/plugin/types.ts +10 -0
  76. package/src/run/channel-session-factory.ts +7 -1
  77. package/src/run/index.ts +87 -21
  78. package/src/secrets/kakao-renewal.ts +3 -47
  79. package/src/server/index.ts +241 -60
  80. package/src/shared/index.ts +3 -0
  81. package/src/shared/protocol.ts +49 -0
  82. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
  83. package/src/skills/typeclaw-claude-code/SKILL.md +57 -39
  84. package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
  85. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
  86. package/src/skills/typeclaw-config/SKILL.md +1 -1
  87. package/src/skills/typeclaw-cron/SKILL.md +1 -1
  88. package/src/skills/typeclaw-memory/SKILL.md +16 -163
  89. package/src/skills/typeclaw-permissions/SKILL.md +2 -2
  90. package/src/skills/typeclaw-plugins/SKILL.md +25 -14
  91. package/src/test-helpers/wait-for.ts +7 -1
  92. package/typeclaw.schema.json +7 -0
@@ -0,0 +1,419 @@
1
+ import { readFile, realpath } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { parseConfigJson } from '@/config'
5
+ import { isBuiltinRoleName, type RoleConfig, type RolesConfig } from '@/permissions'
6
+
7
+ import type { SecuritySeverity } from '../permissions'
8
+ import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
9
+
10
+ export const GUARD_ROLE_PROMOTION = 'rolePromotion'
11
+ // Classified `high` (audience-leak axis, adapted).
12
+ //
13
+ // Role promotion is privilege escalation: the agent rewrites
14
+ // `typeclaw.json#roles` so a previously-unprivileged actor now resolves
15
+ // to a privileged role. The breach pattern: a `member`-role speaker in a
16
+ // chat asks "give me permission" / "promote me to admin"; the agent
17
+ // edits typeclaw.json with what looks like a routine config change; the
18
+ // schema is valid, the managedConfig guard passes, nonWorkspaceWrite
19
+ // allowlists typeclaw.json — and on next reload the speaker resolves to
20
+ // `owner` with full bypasses.
21
+ //
22
+ // This is the same audience-leak shape as gitExfil and outboundSecret:
23
+ // the "audience" here is the future-self of the access-control table,
24
+ // which is outside the operator's per-call control loop. Even an `owner`
25
+ // operating from TUI must not silently rewrite the role table based on
26
+ // a channel message — the canonical owner-in-public-channel attack
27
+ // generalizes from "post credentials" to "promote the asker". No role
28
+ // auto-bypasses; per-call ack or an explicit `security.bypass.rolePromotion`
29
+ // grant is required.
30
+ //
31
+ // What counts as a promotion (any of):
32
+ // 1. A role's `permissions[]` gained an entry.
33
+ // 2. A role's `match[]` gained an entry (widens who fills the role).
34
+ // 3. A new role was added with non-empty `permissions[]` or non-empty
35
+ // `match[]` (introducing a role nobody used before is an
36
+ // escalation in two steps: this PR + add a match rule next).
37
+ //
38
+ // What does NOT count (allowed without ack):
39
+ // - Removing a permission from a role.
40
+ // - Removing a match rule from a role.
41
+ // - Deleting a role entirely.
42
+ // - Reordering entries within `permissions[]` or `match[]`.
43
+ // - Any edit to fields outside `roles`.
44
+ //
45
+ // Failure-open is deliberate: if the existing typeclaw.json cannot be
46
+ // read or parsed (first init, mid-corruption), we treat every role-
47
+ // bearing field in the proposed file as a NEW grant and block. That's
48
+ // the safe direction — the only false positive is "operator edited a
49
+ // broken config to fix it", which is fine because the operator can ack
50
+ // the call.
51
+ //
52
+ // What this guard does NOT cover, by design:
53
+ // - `grantRole()` in src/permissions/grant.ts. That function writes
54
+ // `typeclaw.json` directly via writeFileSync (atomic temp+rename) and
55
+ // bypasses `tool.before` by construction. The only production caller
56
+ // is the role-claim flow (src/role-claim/controller.ts), which is
57
+ // gated by an operator-issued pairing code from the host CLI: the
58
+ // agent cannot start a claim, only consume one whose code the
59
+ // operator already broadcast. That makes the bypass intentionally
60
+ // out-of-band — do not extend this guard to cover it.
61
+ export const GUARD_ROLE_PROMOTION_SEVERITY: SecuritySeverity = 'high'
62
+
63
+ export type RolePromotionFinding = {
64
+ role: string
65
+ kind: 'permissions-added' | 'match-added' | 'role-added'
66
+ added: readonly string[]
67
+ }
68
+
69
+ export async function checkRolePromotionGuard(options: {
70
+ tool: string
71
+ args: Record<string, unknown>
72
+ agentDir: string
73
+ }): Promise<SecurityBlock | undefined> {
74
+ const { tool, args, agentDir } = options
75
+ if (tool !== 'write' && tool !== 'edit') return undefined
76
+
77
+ const rawPath = args.path
78
+ if (typeof rawPath !== 'string') return undefined
79
+
80
+ const targetPath = path.resolve(agentDir, rawPath)
81
+ const isTypeclawJson = await pathIsTypeclawJson(agentDir, targetPath)
82
+ if (!isTypeclawJson) return undefined
83
+
84
+ if (isGuardAcknowledged(args, GUARD_ROLE_PROMOTION)) return undefined
85
+
86
+ const editRefusal = refuseRiskyEdit(tool, args, targetPath)
87
+ if (editRefusal) return editRefusal
88
+
89
+ const newContent = await intendedContent(tool, args, targetPath)
90
+ if (newContent === undefined) return undefined
91
+
92
+ const newRoles = parseRolesFromContent(newContent)
93
+ // managedConfig will block invalid JSON / schema separately. If parsing
94
+ // fails here we can't reason about promotion, so we don't block at this
95
+ // guard layer — the managedConfig schema check below us is the right
96
+ // place to surface that error.
97
+ if (newRoles === undefined) return undefined
98
+
99
+ const oldRoles = await readExistingRoles(targetPath)
100
+ const findings = diffRoles(oldRoles, newRoles)
101
+ if (findings.length === 0) return undefined
102
+
103
+ return {
104
+ block: true,
105
+ reason: buildBlockReason(tool, targetPath, findings),
106
+ }
107
+ }
108
+
109
+ // Oracle PR #305 findings #5 and #6. The earlier shape compared
110
+ // `basename(realpath(target))` to `'typeclaw.json'`. That misses two
111
+ // real attacks:
112
+ //
113
+ // (5) Symlink: root `typeclaw.json` is a symlink into workspace
114
+ // (`typeclaw.json -> workspace/tc.json`). Writing to
115
+ // `typeclaw.json` realpaths to a workspace file whose basename
116
+ // is `tc.json` — the guard skips, but the next reload follows
117
+ // the symlink and consumes the attacker's content.
118
+ // (6) Case-insensitive FS (macOS APFS, default): `TYPECLAW.JSON`
119
+ // addresses the same file as `typeclaw.json` but basename
120
+ // string-equality misses the casing variant.
121
+ //
122
+ // Both are closed by treating the canonical agent-root config file as
123
+ // an identity to compare against, not a basename to match. We accept
124
+ // a write/edit if EITHER:
125
+ //
126
+ // (a) the lexical agent-root path `<agentDir>/<managed-file-name>`
127
+ // resolves (after realpath) to the same file as the target, OR
128
+ // (b) the target's lexical path is exactly `<agentDir>/<managed-
129
+ // file-name>` regardless of what's at that path (symlink, file,
130
+ // missing — preserves first-init writes).
131
+ //
132
+ // Branch (a) catches both the symlink-into-workspace and the macOS-
133
+ // case-aliased attacks because realpath canonicalizes both. Branch
134
+ // (b) keeps the lexical name authoritative (a fresh write through the
135
+ // canonical name is always managed, even before the file exists).
136
+ async function pathIsTypeclawJson(agentDir: string, targetPath: string): Promise<boolean> {
137
+ return identifiesManagedFile(agentDir, targetPath, 'typeclaw.json')
138
+ }
139
+
140
+ async function identifiesManagedFile(agentDir: string, targetPath: string, managedBasename: string): Promise<boolean> {
141
+ const resolvedAgentDir = path.resolve(agentDir)
142
+ const canonicalManagedPath = path.join(resolvedAgentDir, managedBasename)
143
+ const resolvedTarget = path.resolve(targetPath)
144
+ if (canonicalManagedPath === resolvedTarget) return true
145
+ const realCanonical = await resolveRealPath(canonicalManagedPath)
146
+ const realTarget = await resolveRealPath(resolvedTarget)
147
+ return realCanonical === realTarget
148
+ }
149
+
150
+ // Oracle PR #305 finding #4. Our guard simulates edits as sequential
151
+ // `content.replace(oldText, newText)` calls — the next edit sees the
152
+ // output of the previous one. Pi's actual edit tool (in
153
+ // pi-coding-agent/dist/core/tools/edit-diff.js) applies each oldText
154
+ // against the ORIGINAL file content, requires uniqueness, and checks
155
+ // for overlapping replacements. A multi-edit call where the simulator
156
+ // and pi diverge would let an attacker validate one final file in our
157
+ // guard while pi writes a different final file to disk.
158
+ //
159
+ // We close the gap conservatively: refuse multi-edit AND non-unique-
160
+ // oldText edits on managed files. Tell the agent to use `write` with
161
+ // the full content instead (the typeclaw-cron and typeclaw-permissions
162
+ // skills already document this as the canonical path). Re-implementing
163
+ // pi's edit-diff inside the guard would be a maintenance hazard — any
164
+ // future pi version drift would silently re-open the bypass.
165
+ function refuseRiskyEdit(tool: string, args: Record<string, unknown>, targetPath: string): SecurityBlock | undefined {
166
+ if (tool !== 'edit') return undefined
167
+ const edits = args.edits
168
+ if (!Array.isArray(edits)) return undefined
169
+ if (edits.length > 1) {
170
+ return {
171
+ block: true,
172
+ reason: [
173
+ `Guard \`${GUARD_ROLE_PROMOTION}\` refuses multi-edit on ${targetPath}: the security guard's edit simulator cannot match the pi-coding-agent edit tool's original-content semantics for multi-edit calls, so a final file validated here may not match the final file actually written.`,
174
+ 'Use `write` with the full file content instead — this is the canonical workflow for managed config files (see the `typeclaw-cron` and `typeclaw-permissions` skills).',
175
+ ].join(' '),
176
+ }
177
+ }
178
+ return undefined
179
+ }
180
+
181
+ async function intendedContent(
182
+ tool: string,
183
+ args: Record<string, unknown>,
184
+ targetPath: string,
185
+ ): Promise<string | undefined> {
186
+ if (tool === 'write') {
187
+ return typeof args.content === 'string' ? args.content : undefined
188
+ }
189
+ const edits = args.edits
190
+ if (!Array.isArray(edits)) return undefined
191
+ let content: string
192
+ try {
193
+ content = await readFile(targetPath, 'utf8')
194
+ } catch {
195
+ return undefined
196
+ }
197
+ // refuseRiskyEdit already enforced edits.length <= 1 for managed
198
+ // files. The loop here therefore always executes at most one iteration
199
+ // — we still enforce oldText uniqueness inside it as defense in depth
200
+ // (and because pi's edit-diff requires uniqueness too, so a non-unique
201
+ // single-edit is a malformed input that would fail at the real tool).
202
+ for (const edit of edits) {
203
+ if (!edit || typeof edit !== 'object') return undefined
204
+ const { oldText, newText } = edit as Record<string, unknown>
205
+ if (typeof oldText !== 'string' || typeof newText !== 'string') return undefined
206
+ if (oldText.length === 0) return undefined
207
+ const firstIdx = content.indexOf(oldText)
208
+ if (firstIdx === -1) return undefined
209
+ if (content.indexOf(oldText, firstIdx + 1) !== -1) return undefined
210
+ // Slice-rebuild to avoid String.replace's $-substitution interpreting
211
+ // newText as a replacement pattern (a `$&` or `$1` in newText would
212
+ // otherwise expand against the match).
213
+ content = content.slice(0, firstIdx) + newText + content.slice(firstIdx + oldText.length)
214
+ }
215
+ return content
216
+ }
217
+
218
+ function parseRolesFromContent(content: string): RolesConfig | undefined {
219
+ const result = parseConfigJson(content, { migrate: false })
220
+ if (!result.ok) return undefined
221
+ return result.config.roles ?? {}
222
+ }
223
+
224
+ async function readExistingRoles(targetPath: string): Promise<RolesConfig> {
225
+ let raw: string
226
+ try {
227
+ raw = await readFile(targetPath, 'utf8')
228
+ } catch {
229
+ // No existing file (first write) — treat every role as a new grant.
230
+ return {}
231
+ }
232
+ // migrate:true on the on-disk read so the comparison matches what the
233
+ // runtime currently sees — `migrateLegacyConfigShape` rewrites legacy
234
+ // `channels.<adapter>.allow[]` into `roles.member.match[]` at every
235
+ // config load. Without this, a legacy-shape file on disk would surface
236
+ // as `roles: {}` to the guard and every legitimate operator edit on a
237
+ // legacy config would be flagged as "new role with grant." The proposed
238
+ // content (parseRolesFromContent) stays migrate:false so we diff the
239
+ // agent's literal intent, not a migrated rewrite of it.
240
+ const result = parseConfigJson(raw, { migrate: true })
241
+ if (!result.ok) return {}
242
+ return result.config.roles ?? {}
243
+ }
244
+
245
+ export function diffRoles(before: RolesConfig, after: RolesConfig): RolePromotionFinding[] {
246
+ const findings: RolePromotionFinding[] = []
247
+ for (const [role, afterCfg] of Object.entries(after)) {
248
+ const beforeCfg = before[role]
249
+ if (beforeCfg === undefined) {
250
+ // New role. Flag only when it actually carries a grant — declaring
251
+ // a role with empty permissions[] and empty match[] grants nothing.
252
+ const addedPerms = readPermissions(afterCfg)
253
+ const addedMatch = readMatchRaw(afterCfg)
254
+ if (addedPerms.length > 0 || addedMatch.length > 0) {
255
+ findings.push({
256
+ role,
257
+ kind: 'role-added',
258
+ added: [...addedPerms.map((p) => `permission:${p}`), ...addedMatch.map((m) => `match:${m}`)],
259
+ })
260
+ }
261
+ continue
262
+ }
263
+ const permsAdded = setDifference(readPermissions(afterCfg), readPermissions(beforeCfg))
264
+ if (permsAdded.length > 0) {
265
+ findings.push({ role, kind: 'permissions-added', added: permsAdded })
266
+ }
267
+ if (isBuiltinDefaultsRestoration(role, beforeCfg, afterCfg)) {
268
+ findings.push({ role, kind: 'permissions-added', added: ['<built-in defaults restored>'] })
269
+ }
270
+ const matchAdded = setDifference(readMatchRaw(afterCfg), readMatchRaw(beforeCfg))
271
+ if (matchAdded.length > 0) {
272
+ findings.push({ role, kind: 'match-added', added: matchAdded })
273
+ }
274
+ }
275
+ return findings
276
+ }
277
+
278
+ // Oracle PR #305 finding #3. For built-in roles (owner/trusted/member/
279
+ // guest), the runtime treats `permissions: undefined` as "use built-in
280
+ // defaults" — and the built-in defaults can be substantial (trusted
281
+ // carries channel.respond + cron.schedule + subagent perms +
282
+ // security.bypass.low even though its file representation may have
283
+ // been `permissions: []`). A write that removes an explicit
284
+ // `permissions[]` field on a built-in role therefore re-grants the
285
+ // built-in default set the file had narrowed away. The raw-array diff
286
+ // at readPermissions doesn't see this (both sides flatten to []), so
287
+ // we surface it here with a sentinel finding. Custom (non-built-in)
288
+ // roles do not have implicit defaults, so this rule applies only when
289
+ // the role name is built-in.
290
+ function isBuiltinDefaultsRestoration(role: string, before: RoleConfig, after: RoleConfig): boolean {
291
+ if (!isBuiltinRoleName(role)) return false
292
+ return before.permissions !== undefined && after.permissions === undefined
293
+ }
294
+
295
+ function readPermissions(cfg: RoleConfig | undefined): string[] {
296
+ if (cfg === undefined) return []
297
+ return cfg.permissions === undefined ? [] : [...cfg.permissions]
298
+ }
299
+
300
+ // We compare on the raw string forms of match rules even though the
301
+ // schema parses them into structured MatchRule objects. Two reasons:
302
+ // (1) the canonical migration in `migrateLegacyConfigShape` may rewrite
303
+ // legacy prefixes before they hit this guard, so the "before" we read
304
+ // from disk and the "after" we receive from args are both already in
305
+ // canonical DSL form — string equality is correct; (2) reconstructing a
306
+ // stable string key from MatchRule would duplicate the DSL formatter and
307
+ // drift over time.
308
+ function readMatchRaw(cfg: RoleConfig | undefined): string[] {
309
+ if (cfg === undefined) return []
310
+ const out: string[] = []
311
+ for (const rule of cfg.match) {
312
+ out.push(serializeMatchRule(rule))
313
+ }
314
+ return out
315
+ }
316
+
317
+ function serializeMatchRule(rule: RoleConfig['match'][number]): string {
318
+ // We need a stable string key per rule for diff equality. The schema
319
+ // parses rule strings into a typed union; we serialize back to a
320
+ // canonical DSL form. Any rule shape not handled here falls through to
321
+ // a JSON dump, which is correct for diff purposes (stable equality
322
+ // even if the surface text is lossy) and acts as a forced signal at
323
+ // code-review time when a new MatchRule kind ships.
324
+ if (rule.kind === 'tui') return 'tui'
325
+ if (rule.kind === 'cron') return 'cron'
326
+ if (rule.kind === 'wildcard') return '*'
327
+ if (rule.kind === 'subagent') {
328
+ return rule.subagent === undefined ? 'subagent' : `subagent:${rule.subagent}`
329
+ }
330
+ if (rule.kind === 'channel') {
331
+ const head = serializeChannelScope(rule)
332
+ if (rule.author === undefined) return head
333
+ return `${head} author:${rule.author}`
334
+ }
335
+ return JSON.stringify(rule)
336
+ }
337
+
338
+ function serializeChannelScope(rule: Extract<RoleConfig['match'][number], { kind: 'channel' }>): string {
339
+ const { platform, workspace, chat, bucket } = rule
340
+ if (bucket !== undefined) {
341
+ return chat === undefined ? `${platform}:${bucket}/*` : `${platform}:${bucket}/${chat}`
342
+ }
343
+ if (workspace === undefined && chat === undefined) return `${platform}:*`
344
+ if (workspace !== undefined && chat === undefined) return `${platform}:${workspace}`
345
+ if (workspace !== undefined && chat !== undefined) return `${platform}:${workspace}/${chat}`
346
+ return `${platform}:${JSON.stringify({ workspace, chat })}`
347
+ }
348
+
349
+ function setDifference(after: readonly string[], before: readonly string[]): string[] {
350
+ const beforeSet = new Set(before)
351
+ const out: string[] = []
352
+ for (const item of after) {
353
+ if (!beforeSet.has(item) && !out.includes(item)) out.push(item)
354
+ }
355
+ return out
356
+ }
357
+
358
+ function buildBlockReason(tool: string, targetPath: string, findings: readonly RolePromotionFinding[]): string {
359
+ const lines: string[] = []
360
+ for (const f of findings) {
361
+ const role = sanitizeForReason(f.role)
362
+ const added = dedup(f.added.map(sanitizeForReason)).join(', ')
363
+ if (f.kind === 'role-added') {
364
+ lines.push(`new role \`${role}\` adds: ${added}`)
365
+ } else if (f.kind === 'permissions-added') {
366
+ lines.push(`role \`${role}\` gains permissions: ${added}`)
367
+ } else {
368
+ lines.push(`role \`${role}\` gains match rules: ${added}`)
369
+ }
370
+ }
371
+ return [
372
+ `Guard \`${GUARD_ROLE_PROMOTION}\` blocked ${tool} on ${sanitizeForReason(targetPath)}: this change is a privilege escalation — ${lines.join('; ')}.`,
373
+ 'Granting `owner` / `trusted` (or widening any role) gives the matched actor security-bypass capabilities, cron scheduling, channel respond, and operator-only subagent spawn. Even an operator running from TUI must not silently rewrite the access-control table based on a channel message: the canonical attack is a member-role speaker socially-engineering the agent into adding their own author-id to `roles.owner.match[]`, which the schema check accepts as valid.',
374
+ `If this is genuinely intentional and the operator explicitly asked for it (not a channel message), retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_ROLE_PROMOTION}: true\` in the tool arguments.`,
375
+ ].join(' ')
376
+ }
377
+
378
+ const MAX_REASON_TOKEN_LEN = 200
379
+
380
+ // Strings flowing into the block reason can be attacker-controlled: an
381
+ // LLM-rendered role name, an author id inside a match rule, even the file
382
+ // path basename. The operator reads this reason in a TUI/terminal context,
383
+ // so ANSI escapes and other C0 controls would let an attacker forge or
384
+ // hide block-message UI. Same shape as sanitizeUrlForReason in git-exfil.
385
+ // Backticks are also replaced so an attacker can't break the inline-code
386
+ // formatting we use elsewhere in the message.
387
+ export function sanitizeForReason(value: string): string {
388
+ // eslint-disable-next-line no-control-regex
389
+ const cleaned = value.replace(/[\u0000-\u001f\u007f]/g, '').replace(/`/g, "'")
390
+ if (cleaned.length <= MAX_REASON_TOKEN_LEN) return cleaned
391
+ return `${cleaned.slice(0, MAX_REASON_TOKEN_LEN)}...`
392
+ }
393
+
394
+ function dedup(items: readonly string[]): string[] {
395
+ const out: string[] = []
396
+ for (const item of items) if (!out.includes(item)) out.push(item)
397
+ return out
398
+ }
399
+
400
+ async function resolveRealPath(absolutePath: string): Promise<string> {
401
+ const pending: string[] = []
402
+ let current = absolutePath
403
+ while (true) {
404
+ try {
405
+ const real = await realpath(current)
406
+ return path.join(real, ...pending.reverse())
407
+ } catch (err) {
408
+ if (!isNotFound(err)) throw err
409
+ }
410
+ const parent = path.dirname(current)
411
+ if (parent === current) return absolutePath
412
+ pending.push(path.basename(current))
413
+ current = parent
414
+ }
415
+ }
416
+
417
+ function isNotFound(err: unknown): boolean {
418
+ return err instanceof Error && 'code' in err && (err as { code: unknown }).code === 'ENOENT'
419
+ }
@@ -12,6 +12,7 @@ export const GUARD_SYSTEM_PROMPT_LEAK_SEVERITY: SecuritySeverity = 'high'
12
12
  const FINGERPRINT_PATTERNS: ReadonlyArray<{ label: string; pattern: RegExp }> = [
13
13
  { label: 'TypeClaw runtime preamble', pattern: /You are a general-purpose AI agent running inside TypeClaw\./ },
14
14
  { label: 'TypeClaw "Your agent folder" header', pattern: /^##\s+Your\s+agent\s+folder\b/m },
15
+ // kept: pre-migration agents may still have a root MEMORY.md.
15
16
  {
16
17
  label: 'IDENTITY.md / SOUL.md / MEMORY.md / USER.md / AGENTS.md identity-file recital',
17
18
  pattern: /IDENTITY\.md\b[\s\S]{0,400}SOUL\.md\b[\s\S]{0,400}(?:MEMORY\.md|USER\.md|AGENTS\.md)/,
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  KAKAO_EMOTICON_KIND_BY_TYPE,
3
+ KAKAO_MESSAGE_TYPE,
3
4
  type KakaoEmoticonKind,
4
5
  type KakaoMessage,
5
6
  type KakaoTalkPushEmoticonEvent,
@@ -21,17 +22,6 @@ import {
21
22
  // convention used by Slack/Discord/Telegram inbound classifiers, so the
22
23
  // agent sees a consistent placeholder shape across platforms.
23
24
 
24
- // KakaoTalk LOCO message_type values. Only the ones we explicitly format
25
- // are listed; anything else falls into the "generic attachment" branch.
26
- // Reference: src/skills/typeclaw-channel-kakaotalk/SKILL.md and
27
- // agent-messenger docs/cli/kakaotalk.mdx.
28
- const MESSAGE_TYPE_TEXT = 1
29
- const MESSAGE_TYPE_PHOTO = 2
30
- const MESSAGE_TYPE_VIDEO = 3
31
- const MESSAGE_TYPE_AUDIO = 5
32
- const MESSAGE_TYPE_FILE = 18
33
- const MESSAGE_TYPE_MULTIPHOTO = 27
34
-
35
25
  // Non-text inputs that the adapter accepts. We use a thin shared shape
36
26
  // rather than the SDK's union so the same formatter can serve both push
37
27
  // events (no `attachment` on emoticon events — emoticon fields live on
@@ -70,17 +60,17 @@ function summarizeAttachment(event: InboundLike): string | null {
70
60
  // agent isn't woken up by phantom `[KakaoTalk message with type=N]`
71
61
  // placeholders for noise.
72
62
  switch (event.message_type) {
73
- case MESSAGE_TYPE_TEXT:
63
+ case KAKAO_MESSAGE_TYPE.TEXT:
74
64
  return null
75
- case MESSAGE_TYPE_PHOTO:
65
+ case KAKAO_MESSAGE_TYPE.PHOTO:
76
66
  return summarizePhoto(event.attachment)
77
- case MESSAGE_TYPE_VIDEO:
67
+ case KAKAO_MESSAGE_TYPE.VIDEO:
78
68
  return summarizeGeneric('video', event.attachment)
79
- case MESSAGE_TYPE_AUDIO:
69
+ case KAKAO_MESSAGE_TYPE.AUDIO:
80
70
  return summarizeGeneric('audio', event.attachment)
81
- case MESSAGE_TYPE_FILE:
71
+ case KAKAO_MESSAGE_TYPE.FILE:
82
72
  return summarizeFile(event.attachment)
83
- case MESSAGE_TYPE_MULTIPHOTO:
73
+ case KAKAO_MESSAGE_TYPE.MULTIPHOTO:
84
74
  return summarizeGeneric('multiphoto', event.attachment)
85
75
  default:
86
76
  // Emoticon types route through the dedicated emoticon event before
@@ -2,7 +2,9 @@ import {
2
2
  KakaoCredentialManager,
3
3
  KakaoTalkClient as RealKakaoTalkClient,
4
4
  KakaoTalkListener as RealKakaoTalkListener,
5
+ type AttachmentInput,
5
6
  type KakaoChat,
7
+ type KakaoMarkReadResult,
6
8
  type KakaoMember,
7
9
  type KakaoMessage,
8
10
  type KakaoProfile,
@@ -32,16 +34,6 @@ import { createKakaoChannelResolver, type KakaoChannelResolver } from './kakaota
32
34
  import { classifyInbound, type InboundDropReason } from './kakaotalk-classify'
33
35
  import { createFetchAttachmentCallback } from './kakaotalk-fetch-attachment'
34
36
 
35
- // Inlined locally because agent-messenger/kakaotalk's index does not
36
- // re-export KakaoMarkReadResult even though client.markRead returns it
37
- // (agent-messenger 2.14.1). Upstream re-export fix is independent.
38
- export interface KakaoMarkReadResult {
39
- success: boolean
40
- status_code: number
41
- chat_id: string
42
- watermark: string
43
- }
44
-
45
37
  // Structural duck-type of the upstream KakaoTalkClient class. The upstream
46
38
  // type is a class with private fields, and TypeScript treats those
47
39
  // nominally — test fakes that match the public surface get rejected.
@@ -56,6 +48,13 @@ export interface KakaoTalkClient {
56
48
  getChats(options?: { all?: boolean; search?: string }): Promise<KakaoChat[]>
57
49
  getMessages(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessage[]>
58
50
  sendMessage(chatId: string, text: string): Promise<KakaoSendResult>
51
+ sendAttachment(
52
+ chatId: string,
53
+ data: Uint8Array | Buffer,
54
+ filename: string,
55
+ mimeType?: string,
56
+ ): Promise<KakaoSendResult>
57
+ sendAttachment(chatId: string, attachments: ReadonlyArray<AttachmentInput>): Promise<KakaoSendResult>
59
58
  markRead(chatId: string, logId: string, opts?: { linkId?: string }): Promise<KakaoMarkReadResult>
60
59
  getProfile(): Promise<KakaoProfile>
61
60
  getMembers(chatId: string): Promise<KakaoMember[]>
@@ -160,49 +159,77 @@ function formatLabel(name: string | undefined, id: string, prefix = ''): string
160
159
  return `${prefix}${name}(${id})`
161
160
  }
162
161
 
162
+ async function readAttachmentBuffer(path: string): Promise<Buffer> {
163
+ const { readFile } = await import('node:fs/promises')
164
+ return await readFile(path)
165
+ }
166
+
163
167
  export function createOutboundCallback(deps: {
164
- client: Pick<KakaoTalkClient, 'sendMessage'>
168
+ client: Pick<KakaoTalkClient, 'sendMessage' | 'sendAttachment'>
165
169
  logger: KakaotalkAdapterLogger
166
170
  formatChannelTag: (workspace: string, chat: string) => Promise<string>
171
+ readFile?: (path: string) => Promise<Buffer>
167
172
  }): OutboundCallback {
168
173
  const { client, logger, formatChannelTag } = deps
174
+ const readFile = deps.readFile ?? readAttachmentBuffer
169
175
  return async (msg: OutboundMessage): Promise<SendResult> => {
170
176
  if (msg.adapter !== 'kakaotalk') {
171
177
  return { ok: false, error: `unknown adapter: ${msg.adapter}` }
172
178
  }
173
179
  const text = msg.text ?? ''
174
180
  const attachments = msg.attachments ?? []
181
+ if (text === '' && attachments.length === 0) {
182
+ return { ok: false, error: 'message has neither text nor attachments' }
183
+ }
184
+ const tag = await formatChannelTag(msg.workspace, msg.chat)
185
+ logger.info(`[kakaotalk] outbound ${tag} text_len=${text.length} attachments=${attachments.length}`)
186
+
187
+ // KakaoTalk has no shared text-with-file send (Slack's initial_comment) — files first, then text.
175
188
  if (attachments.length > 0) {
176
- // Fail loudly rather than partial-send. The agent contract is "ok=true
177
- // means the request as a whole succeeded"; sending text while silently
178
- // dropping the attachments would let the agent confidently report
179
- // "I sent your file" when the file never arrived.
180
- logger.error(
181
- `[kakaotalk] outbound rejected: ${attachments.length} attachment(s) supplied but KakaoTalk is text-only`,
182
- )
183
- return {
184
- ok: false,
185
- error: 'KakaoTalk does not support attachments; send text without files or use a different channel for files',
189
+ let items: AttachmentInput[]
190
+ try {
191
+ items = await Promise.all(
192
+ attachments.map(async (a) => {
193
+ const filename = a.filename ?? a.path.split('/').pop() ?? 'file'
194
+ const data = await readFile(a.path)
195
+ return { data, filename }
196
+ }),
197
+ )
198
+ } catch (err) {
199
+ const message = describe(err)
200
+ logger.error(`[kakaotalk] readFile failed: ${message}`)
201
+ return { ok: false, error: `readFile failed: ${message}` }
202
+ }
203
+ try {
204
+ const result = await client.sendAttachment(msg.chat, items)
205
+ if (!result.success) {
206
+ logger.error(`[kakaotalk] sendAttachment status_code=${result.status_code} ${tag}`)
207
+ return { ok: false, error: `kakaotalk attachment send failed with status ${result.status_code}` }
208
+ }
209
+ logger.info(`[kakaotalk] uploaded log_id=${result.log_id} attachments=${items.length} ${tag}`)
210
+ } catch (err) {
211
+ const message = describe(err)
212
+ logger.error(`[kakaotalk] sendAttachment failed: ${message}`)
213
+ return { ok: false, error: message }
186
214
  }
187
215
  }
188
- if (text === '') {
189
- return { ok: false, error: 'message has no text (KakaoTalk does not support attachment-only messages)' }
190
- }
191
- const tag = await formatChannelTag(msg.workspace, msg.chat)
192
- logger.info(`[kakaotalk] outbound ${tag} text_len=${text.length}`)
193
- try {
194
- const result = await client.sendMessage(msg.chat, text)
195
- if (!result.success) {
196
- logger.error(`[kakaotalk] sendMessage status_code=${result.status_code} ${tag}`)
197
- return { ok: false, error: `kakaotalk send failed with status ${result.status_code}` }
216
+
217
+ if (text !== '') {
218
+ try {
219
+ const result = await client.sendMessage(msg.chat, text)
220
+ if (!result.success) {
221
+ logger.error(`[kakaotalk] sendMessage status_code=${result.status_code} ${tag}`)
222
+ return { ok: false, error: `kakaotalk send failed with status ${result.status_code}` }
223
+ }
224
+ logger.info(`[kakaotalk] sent log_id=${result.log_id} ${tag}`)
225
+ } catch (err) {
226
+ const message = describe(err)
227
+ logger.error(`[kakaotalk] sendMessage failed: ${message}`)
228
+ return { ok: false, error: message }
198
229
  }
199
- logger.info(`[kakaotalk] sent log_id=${result.log_id} ${tag}`)
200
- return { ok: true }
201
- } catch (err) {
202
- const message = describe(err)
203
- logger.error(`[kakaotalk] sendMessage failed: ${message}`)
204
- return { ok: false, error: message }
205
230
  }
231
+
232
+ return { ok: true }
206
233
  }
207
234
  }
208
235
 
@@ -6,33 +6,8 @@ import type { InboundMessage } from '@/channels/types'
6
6
 
7
7
  import { slackTsToMillis } from './slack-bot-time'
8
8
 
9
- // Upstream's `SlackSocketModeMessageEvent` carries `[key: string]: unknown`
10
- // for fields it does not type explicitly. Three of those untyped fields are
11
- // load-bearing for this adapter:
12
- // - `parent_user_id`: set on every reply within a thread; identifies the
13
- // author of the message the thread is rooted at. Used to decide whether
14
- // a reply targets the bot, another human, or an unknown parent.
15
- // - `client_msg_id`: client-generated UUID on user-authored messages,
16
- // stable across Slack-side resends of the same gesture. Primary dedupe
17
- // key for the "one user action surfaces as two events" case.
18
- // - `files`: attachments delivered inline on the same message event (Slack
19
- // does not fire a separate file_share for messages we receive).
20
- // Typing them here (rather than reading them via `as` casts at every call
21
- // site) keeps the classifier readable and makes it the single source of
22
- // truth for "what Slack actually sends" — anything else reading these
23
- // fields imports `SlackInboundMessageEvent` from this module.
24
- export type SlackInboundMessageEvent = SlackSocketModeMessageEvent & {
25
- parent_user_id?: string
26
- client_msg_id?: string
27
- files?: SlackFile[]
28
- }
29
-
30
- // `app_mention` envelopes do not always carry `client_msg_id`, but typing
31
- // it keeps the promotion to a message-shaped event lossless if Slack
32
- // starts sending it. Same reasoning as `SlackInboundMessageEvent` above.
33
- export type SlackInboundAppMentionEvent = SlackSocketModeAppMentionEvent & {
34
- client_msg_id?: string
35
- }
9
+ export type SlackInboundMessageEvent = SlackSocketModeMessageEvent
10
+ export type SlackInboundAppMentionEvent = SlackSocketModeAppMentionEvent
36
11
 
37
12
  export type InboundDropReason =
38
13
  | 'self_author' // event.user === botUserId; we never route our own messages back to ourselves
@@ -9,6 +9,11 @@ export {
9
9
  type CreateSessionForChannel,
10
10
  } from './router'
11
11
  export { createChannelsReloadable } from './reloadable'
12
+ export {
13
+ createSubagentCompletionBridge,
14
+ type SubagentCompletionBridge,
15
+ type SubagentCompletionBridgeOptions,
16
+ } from './subagent-completion-bridge'
12
17
  export {
13
18
  channelsSchema,
14
19
  ADAPTER_IDS,