typeclaw 0.1.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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/auth.schema.json +63 -0
  4. package/cron.schema.json +96 -0
  5. package/package.json +72 -0
  6. package/scripts/emit-base-dockerfile.ts +5 -0
  7. package/scripts/generate-schema.ts +34 -0
  8. package/secrets.schema.json +63 -0
  9. package/src/agent/auth.ts +119 -0
  10. package/src/agent/compaction.ts +35 -0
  11. package/src/agent/git-nudge.ts +95 -0
  12. package/src/agent/index.ts +451 -0
  13. package/src/agent/plugin-tools.ts +269 -0
  14. package/src/agent/reload-tool.ts +71 -0
  15. package/src/agent/self.ts +45 -0
  16. package/src/agent/session-origin.ts +288 -0
  17. package/src/agent/subagents.ts +253 -0
  18. package/src/agent/system-prompt.ts +68 -0
  19. package/src/agent/tools/channel-fetch-attachment.ts +118 -0
  20. package/src/agent/tools/channel-history.ts +119 -0
  21. package/src/agent/tools/channel-reply.ts +182 -0
  22. package/src/agent/tools/channel-send.ts +212 -0
  23. package/src/agent/tools/ddg.ts +218 -0
  24. package/src/agent/tools/restart.ts +122 -0
  25. package/src/agent/tools/stream-snapshot.ts +181 -0
  26. package/src/agent/tools/webfetch/fetch.ts +102 -0
  27. package/src/agent/tools/webfetch/index.ts +1 -0
  28. package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
  29. package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
  30. package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
  31. package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
  32. package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
  33. package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
  34. package/src/agent/tools/webfetch/tool.ts +281 -0
  35. package/src/agent/tools/webfetch/types.ts +33 -0
  36. package/src/agent/tools/websearch.ts +96 -0
  37. package/src/agent/tools/wikipedia.ts +52 -0
  38. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
  39. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
  40. package/src/bundled-plugins/agent-browser/index.ts +179 -0
  41. package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
  42. package/src/bundled-plugins/agent-browser/shim.ts +152 -0
  43. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
  44. package/src/bundled-plugins/guard/index.ts +26 -0
  45. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
  46. package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
  47. package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
  48. package/src/bundled-plugins/guard/policy.ts +18 -0
  49. package/src/bundled-plugins/memory/README.md +71 -0
  50. package/src/bundled-plugins/memory/append-tool.ts +84 -0
  51. package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
  52. package/src/bundled-plugins/memory/dreaming.ts +470 -0
  53. package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
  54. package/src/bundled-plugins/memory/index.ts +238 -0
  55. package/src/bundled-plugins/memory/load-memory.ts +122 -0
  56. package/src/bundled-plugins/memory/memory-logger.ts +257 -0
  57. package/src/bundled-plugins/memory/secret-detector.ts +49 -0
  58. package/src/bundled-plugins/memory/watermark.ts +15 -0
  59. package/src/bundled-plugins/security/index.ts +35 -0
  60. package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
  61. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
  62. package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
  63. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
  64. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
  65. package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
  66. package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
  67. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
  68. package/src/bundled-plugins/security/policy.ts +9 -0
  69. package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
  70. package/src/channels/adapters/discord-bot-classify.ts +148 -0
  71. package/src/channels/adapters/discord-bot.ts +640 -0
  72. package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
  73. package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
  74. package/src/channels/adapters/kakaotalk-classify.ts +77 -0
  75. package/src/channels/adapters/kakaotalk.ts +622 -0
  76. package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
  77. package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
  78. package/src/channels/adapters/slack-bot-classify.ts +213 -0
  79. package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
  80. package/src/channels/adapters/slack-bot-time.ts +10 -0
  81. package/src/channels/adapters/slack-bot.ts +881 -0
  82. package/src/channels/adapters/telegram-bot-classify.ts +155 -0
  83. package/src/channels/adapters/telegram-bot-format.ts +309 -0
  84. package/src/channels/adapters/telegram-bot.ts +604 -0
  85. package/src/channels/engagement.ts +227 -0
  86. package/src/channels/index.ts +21 -0
  87. package/src/channels/manager.ts +292 -0
  88. package/src/channels/membership-cache.ts +116 -0
  89. package/src/channels/membership-from-history.ts +53 -0
  90. package/src/channels/membership.ts +30 -0
  91. package/src/channels/participants.ts +47 -0
  92. package/src/channels/persistence.ts +209 -0
  93. package/src/channels/reloadable.ts +28 -0
  94. package/src/channels/router.ts +1570 -0
  95. package/src/channels/schema.ts +273 -0
  96. package/src/channels/types.ts +160 -0
  97. package/src/cli/channel.ts +403 -0
  98. package/src/cli/compose-status.ts +95 -0
  99. package/src/cli/compose.ts +240 -0
  100. package/src/cli/hostd.ts +163 -0
  101. package/src/cli/index.ts +27 -0
  102. package/src/cli/init.ts +592 -0
  103. package/src/cli/logs.ts +38 -0
  104. package/src/cli/reload.ts +68 -0
  105. package/src/cli/restart.ts +66 -0
  106. package/src/cli/run.ts +77 -0
  107. package/src/cli/shell.ts +33 -0
  108. package/src/cli/start.ts +57 -0
  109. package/src/cli/status.ts +178 -0
  110. package/src/cli/stop.ts +31 -0
  111. package/src/cli/tui.ts +35 -0
  112. package/src/cli/ui.ts +110 -0
  113. package/src/commands/index.ts +74 -0
  114. package/src/compose/discover.ts +43 -0
  115. package/src/compose/index.ts +25 -0
  116. package/src/compose/logs.ts +162 -0
  117. package/src/compose/restart.ts +69 -0
  118. package/src/compose/start.ts +62 -0
  119. package/src/compose/status.ts +28 -0
  120. package/src/compose/stop.ts +43 -0
  121. package/src/config/config.ts +424 -0
  122. package/src/config/index.ts +25 -0
  123. package/src/config/providers.ts +234 -0
  124. package/src/config/reloadable.ts +47 -0
  125. package/src/container/index.ts +27 -0
  126. package/src/container/logs.ts +37 -0
  127. package/src/container/port.ts +137 -0
  128. package/src/container/shared.ts +290 -0
  129. package/src/container/shell.ts +58 -0
  130. package/src/container/start.ts +670 -0
  131. package/src/container/status.ts +76 -0
  132. package/src/container/stop.ts +120 -0
  133. package/src/container/verify-running.ts +149 -0
  134. package/src/cron/consumer.ts +138 -0
  135. package/src/cron/index.ts +54 -0
  136. package/src/cron/reloadable.ts +64 -0
  137. package/src/cron/scheduler.ts +200 -0
  138. package/src/cron/schema.ts +96 -0
  139. package/src/hostd/client.ts +113 -0
  140. package/src/hostd/daemon.ts +587 -0
  141. package/src/hostd/index.ts +25 -0
  142. package/src/hostd/paths.ts +82 -0
  143. package/src/hostd/portbroker-manager.ts +101 -0
  144. package/src/hostd/protocol.ts +48 -0
  145. package/src/hostd/spawn.ts +224 -0
  146. package/src/hostd/supervisor.ts +60 -0
  147. package/src/hostd/tailscale.ts +172 -0
  148. package/src/hostd/version.ts +115 -0
  149. package/src/init/dockerfile.ts +327 -0
  150. package/src/init/ensure-deps.ts +152 -0
  151. package/src/init/gitignore.ts +46 -0
  152. package/src/init/hatching.ts +60 -0
  153. package/src/init/index.ts +786 -0
  154. package/src/init/kakaotalk-auth.ts +114 -0
  155. package/src/init/models-dev.ts +130 -0
  156. package/src/init/oauth-login.ts +74 -0
  157. package/src/init/packagejson.ts +94 -0
  158. package/src/init/paths.ts +2 -0
  159. package/src/init/run-bun-install.ts +20 -0
  160. package/src/markdown/chunk.ts +299 -0
  161. package/src/markdown/index.ts +1 -0
  162. package/src/plugin/context.ts +40 -0
  163. package/src/plugin/define.ts +35 -0
  164. package/src/plugin/hooks.ts +204 -0
  165. package/src/plugin/index.ts +63 -0
  166. package/src/plugin/loader.ts +111 -0
  167. package/src/plugin/manager.ts +136 -0
  168. package/src/plugin/registry.ts +145 -0
  169. package/src/plugin/skills.ts +62 -0
  170. package/src/plugin/types.ts +172 -0
  171. package/src/portbroker/bind-with-forward.ts +102 -0
  172. package/src/portbroker/container-server.ts +305 -0
  173. package/src/portbroker/forward-result-bus.ts +36 -0
  174. package/src/portbroker/hostd-client.ts +443 -0
  175. package/src/portbroker/index.ts +33 -0
  176. package/src/portbroker/policy.ts +24 -0
  177. package/src/portbroker/proc-net-tcp.ts +72 -0
  178. package/src/portbroker/protocol.ts +39 -0
  179. package/src/reload/client.ts +59 -0
  180. package/src/reload/index.ts +3 -0
  181. package/src/reload/registry.ts +60 -0
  182. package/src/reload/types.ts +13 -0
  183. package/src/run/bundled-plugins.ts +24 -0
  184. package/src/run/channel-session-factory.ts +105 -0
  185. package/src/run/index.ts +432 -0
  186. package/src/run/plugin-runtime.ts +43 -0
  187. package/src/run/schema-with-plugins.ts +14 -0
  188. package/src/secrets/index.ts +13 -0
  189. package/src/secrets/migrate.ts +95 -0
  190. package/src/secrets/schema.ts +75 -0
  191. package/src/secrets/storage.ts +231 -0
  192. package/src/server/index.ts +436 -0
  193. package/src/sessions/index.ts +23 -0
  194. package/src/shared/index.ts +9 -0
  195. package/src/shared/local-time.ts +21 -0
  196. package/src/shared/protocol.ts +25 -0
  197. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
  198. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
  199. package/src/skills/typeclaw-config/SKILL.md +643 -0
  200. package/src/skills/typeclaw-cron/SKILL.md +159 -0
  201. package/src/skills/typeclaw-git/SKILL.md +89 -0
  202. package/src/skills/typeclaw-memory/SKILL.md +174 -0
  203. package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
  204. package/src/skills/typeclaw-plugins/SKILL.md +594 -0
  205. package/src/skills/typeclaw-skills/SKILL.md +246 -0
  206. package/src/stream/broker.ts +161 -0
  207. package/src/stream/index.ts +16 -0
  208. package/src/stream/types.ts +69 -0
  209. package/src/tui/client.ts +45 -0
  210. package/src/tui/format.ts +317 -0
  211. package/src/tui/index.ts +225 -0
  212. package/src/tui/theme.ts +41 -0
  213. package/typeclaw.schema.json +826 -0
@@ -0,0 +1,127 @@
1
+ import path from 'node:path'
2
+
3
+ import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
4
+
5
+ export const GUARD_SECRET_EXFIL_READ = 'secretExfilRead'
6
+
7
+ const SENSITIVE_BASENAMES = new Set([
8
+ '.env',
9
+ '.envrc',
10
+ '.netrc',
11
+ '.pgpass',
12
+ 'credentials',
13
+ 'credentials.json',
14
+ 'credentials.yaml',
15
+ 'credentials.yml',
16
+ 'service-account.json',
17
+ 'gha-creds.json',
18
+ 'token.json',
19
+ ])
20
+
21
+ const SENSITIVE_BASENAME_PATTERNS: ReadonlyArray<RegExp> = [
22
+ /^\.env\.[^/\\]+$/,
23
+ /^id_(?:rsa|ed25519|ecdsa|dsa)(?:\.pub)?$/,
24
+ ]
25
+
26
+ const SENSITIVE_DIRECTORY_SEGMENTS = [
27
+ '.ssh',
28
+ '.gnupg',
29
+ '.aws',
30
+ '.docker',
31
+ '.kube',
32
+ '.hermes',
33
+ '.config/gh',
34
+ '.config/hub',
35
+ '.config/sops',
36
+ '.config/op',
37
+ '.config/hermes',
38
+ ]
39
+
40
+ const HISTORY_BASENAMES = new Set([
41
+ '.bash_history',
42
+ '.zsh_history',
43
+ '.python_history',
44
+ '.node_repl_history',
45
+ '.lesshst',
46
+ '.viminfo',
47
+ '.mysql_history',
48
+ '.psql_history',
49
+ ])
50
+
51
+ const PATH_LIKE_KEYS = ['path', 'paths', 'pattern', 'patterns', 'glob', 'globs', 'cwd', 'dir', 'directory']
52
+
53
+ export function checkSecretExfilReadGuard(options: {
54
+ tool: string
55
+ args: Record<string, unknown>
56
+ }): SecurityBlock | undefined {
57
+ const { tool, args } = options
58
+ if (tool !== 'read' && tool !== 'grep' && tool !== 'find' && tool !== 'ls') return undefined
59
+ if (isGuardAcknowledged(args, GUARD_SECRET_EXFIL_READ)) return undefined
60
+
61
+ for (const key of PATH_LIKE_KEYS) {
62
+ const value = args[key]
63
+ const candidates = collectStringValues(value)
64
+ for (const candidate of candidates) {
65
+ const reason = classifySensitivePath(candidate)
66
+ if (reason) {
67
+ return {
68
+ block: true,
69
+ reason: [
70
+ `Guard \`${GUARD_SECRET_EXFIL_READ}\` blocked ${tool} of ${reason}: ${candidate}.`,
71
+ 'Reading secret material is treated as exfiltration even when the agent only intends to inspect it.',
72
+ `If this is genuinely intentional, retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_SECRET_EXFIL_READ}: true\` in the tool arguments.`,
73
+ ].join(' '),
74
+ }
75
+ }
76
+ }
77
+ }
78
+ return undefined
79
+ }
80
+
81
+ function collectStringValues(value: unknown): string[] {
82
+ if (typeof value === 'string') return [value]
83
+ if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string')
84
+ return []
85
+ }
86
+
87
+ function classifySensitivePath(rawPath: string): string | undefined {
88
+ const normalized = rawPath.replace(/\\/g, '/').replace(/^~\//, '/home/__user__/').replace(/^~/, '/home/__user__/')
89
+ const basename = path.basename(normalized)
90
+
91
+ if (SENSITIVE_BASENAMES.has(basename)) return `${basename} (credentials file)`
92
+ if (HISTORY_BASENAMES.has(basename)) return `${basename} (shell history; may contain accidentally typed secrets)`
93
+ for (const pat of SENSITIVE_BASENAME_PATTERNS) {
94
+ if (pat.test(basename)) return `${basename} (looks like a credentials/secret file)`
95
+ }
96
+
97
+ const segments = normalized.split('/').filter((s) => s.length > 0)
98
+ for (const seg of SENSITIVE_DIRECTORY_SEGMENTS) {
99
+ const parts = seg.split('/')
100
+ if (containsSubsequence(segments, parts)) return `${seg}/ (sensitive directory)`
101
+ }
102
+
103
+ if (/(?:^|\/)\.config\/[^/]+\/(?:credentials|token|secret|auth|cookies|session)/.test(normalized)) {
104
+ return '~/.config/**/credentials-like file'
105
+ }
106
+
107
+ if (normalized.startsWith('/proc/') && normalized.endsWith('/environ')) {
108
+ return '/proc/*/environ (process environment)'
109
+ }
110
+
111
+ return undefined
112
+ }
113
+
114
+ function containsSubsequence<T>(haystack: T[], needle: T[]): boolean {
115
+ if (needle.length === 0) return false
116
+ for (let i = 0; i + needle.length <= haystack.length; i++) {
117
+ let ok = true
118
+ for (let j = 0; j < needle.length; j++) {
119
+ if (haystack[i + j] !== needle[j]) {
120
+ ok = false
121
+ break
122
+ }
123
+ }
124
+ if (ok) return true
125
+ }
126
+ return false
127
+ }
@@ -0,0 +1,86 @@
1
+ import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
2
+
3
+ export const GUARD_SESSION_SEARCH_SECRETS = 'sessionSearchSecrets'
4
+
5
+ const SESSION_SEARCH_TOOLS: ReadonlySet<string> = new Set([
6
+ 'session_search',
7
+ 'session-search',
8
+ 'sessionSearch',
9
+ 'session_history_search',
10
+ 'sessionHistorySearch',
11
+ 'history_search',
12
+ 'historySearch',
13
+ ])
14
+
15
+ const QUERY_KEYS: ReadonlyArray<string> = ['query', 'q', 'search', 'pattern', 'keywords', 'text']
16
+
17
+ const SECRET_KEYWORD_PATTERNS: ReadonlyArray<{ pattern: RegExp; label: string }> = [
18
+ { pattern: /\bpassword\b/i, label: 'password' },
19
+ { pattern: /\bpasswd\b/i, label: 'passwd' },
20
+ { pattern: /\bpassphrase\b/i, label: 'passphrase' },
21
+ { pattern: /\bapi[_-]?keys?\b/i, label: 'api_key' },
22
+ { pattern: /\bapikey\b/i, label: 'apikey' },
23
+ { pattern: /\bsecret(?:s|_key)?\b/i, label: 'secret' },
24
+ { pattern: /\bbearer\b/i, label: 'bearer' },
25
+ { pattern: /\bauth[_-]?token\b/i, label: 'auth_token' },
26
+ { pattern: /\baccess[_-]?token\b/i, label: 'access_token' },
27
+ { pattern: /\brefresh[_-]?token\b/i, label: 'refresh_token' },
28
+ { pattern: /\bprivate[_-]?key\b/i, label: 'private_key' },
29
+ { pattern: /\bcredentials?\b/i, label: 'credentials' },
30
+ { pattern: /\bcredit[_-]?card\b/i, label: 'credit_card' },
31
+ { pattern: /\bxox[baprs]-/i, label: 'slack token prefix' },
32
+ { pattern: /\bghp_/i, label: 'github PAT prefix' },
33
+ { pattern: /\bgho_/i, label: 'github OAuth prefix' },
34
+ { pattern: /\bsk-(?:ant|proj)?/i, label: 'openai/anthropic key prefix' },
35
+ { pattern: /\bAKIA\b/, label: 'AWS access key prefix' },
36
+ { pattern: /\bAIza[A-Za-z0-9]/, label: 'Google API key prefix' },
37
+ ]
38
+
39
+ export function detectSessionSearchSecretQuery(query: string): ReadonlyArray<{ label: string }> {
40
+ const hits: Array<{ label: string }> = []
41
+ const seen = new Set<string>()
42
+ for (const { pattern, label } of SECRET_KEYWORD_PATTERNS) {
43
+ if (pattern.test(query) && !seen.has(label)) {
44
+ seen.add(label)
45
+ hits.push({ label })
46
+ }
47
+ }
48
+ return hits
49
+ }
50
+
51
+ export function checkSessionSearchSecretsGuard(options: {
52
+ tool: string
53
+ args: Record<string, unknown>
54
+ }): SecurityBlock | undefined {
55
+ const { tool, args } = options
56
+ if (!SESSION_SEARCH_TOOLS.has(tool)) return undefined
57
+ if (isGuardAcknowledged(args, GUARD_SESSION_SEARCH_SECRETS)) return undefined
58
+
59
+ const queries = collectQueryStrings(args)
60
+ for (const query of queries) {
61
+ const hits = detectSessionSearchSecretQuery(query)
62
+ if (hits.length === 0) continue
63
+ const summary = hits.map((h) => h.label).join(', ')
64
+ return {
65
+ block: true,
66
+ reason: [
67
+ `Guard \`${GUARD_SESSION_SEARCH_SECRETS}\` blocked ${tool}: query targets credential-shaped keywords (${summary}).`,
68
+ 'Searching session history for secret-shaped strings is a recon pattern - even if the index returns no hits today, a future session that accidentally typed a credential will match.',
69
+ `If this is genuinely intentional (e.g. auditing past leaks deliberately), retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_SESSION_SEARCH_SECRETS}: true\` in the tool arguments.`,
70
+ ].join(' '),
71
+ }
72
+ }
73
+ return undefined
74
+ }
75
+
76
+ function collectQueryStrings(args: Record<string, unknown>): string[] {
77
+ const out: string[] = []
78
+ for (const key of QUERY_KEYS) {
79
+ const value = args[key]
80
+ if (typeof value === 'string' && value.length > 0) out.push(value)
81
+ else if (Array.isArray(value)) {
82
+ for (const v of value) if (typeof v === 'string' && v.length > 0) out.push(v)
83
+ }
84
+ }
85
+ return out
86
+ }
@@ -0,0 +1,196 @@
1
+ import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
2
+
3
+ export const GUARD_SSRF = 'ssrf'
4
+
5
+ const ALWAYS_BLOCKED_HOSTS = new Set([
6
+ 'localhost',
7
+ 'localhost.localdomain',
8
+ 'ip6-localhost',
9
+ 'ip6-loopback',
10
+ 'metadata.google.internal',
11
+ 'metadata',
12
+ 'metadata.aws.internal',
13
+ 'instance-data',
14
+ 'instance-data.ec2.internal',
15
+ ])
16
+
17
+ const ALWAYS_BLOCKED_HOST_SUFFIXES = ['.internal', '.local', '.localhost', '.lan', '.intranet', '.corp', '.home']
18
+
19
+ export type SsrfClassification = {
20
+ blocked: boolean
21
+ category?:
22
+ | 'loopback'
23
+ | 'private_ipv4'
24
+ | 'link_local'
25
+ | 'cloud_metadata'
26
+ | 'ipv6_internal'
27
+ | 'unspecified'
28
+ | 'shared_cgnat'
29
+ | 'reserved_internal_host'
30
+ | 'unsupported_scheme'
31
+ reason?: string
32
+ }
33
+
34
+ export function classifyUrl(rawUrl: string): SsrfClassification {
35
+ let parsed: URL
36
+ try {
37
+ parsed = new URL(rawUrl)
38
+ } catch {
39
+ return { blocked: false }
40
+ }
41
+
42
+ if (
43
+ parsed.protocol === 'file:' ||
44
+ parsed.protocol === 'gopher:' ||
45
+ parsed.protocol === 'ftp:' ||
46
+ parsed.protocol === 'data:' ||
47
+ parsed.protocol === 'jar:' ||
48
+ parsed.protocol === 'php:' ||
49
+ parsed.protocol === 'dict:'
50
+ ) {
51
+ return {
52
+ blocked: true,
53
+ category: 'unsupported_scheme',
54
+ reason: `${parsed.protocol} URL is not allowed for outbound fetch`,
55
+ }
56
+ }
57
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
58
+ return { blocked: false }
59
+ }
60
+
61
+ const host = parsed.hostname.toLowerCase()
62
+ const decoded = decodeBracketedIpv6(host)
63
+
64
+ if (ALWAYS_BLOCKED_HOSTS.has(decoded)) {
65
+ return {
66
+ blocked: true,
67
+ category: 'reserved_internal_host',
68
+ reason: `host "${decoded}" resolves to internal/loopback infrastructure`,
69
+ }
70
+ }
71
+ for (const suffix of ALWAYS_BLOCKED_HOST_SUFFIXES) {
72
+ if (decoded.endsWith(suffix)) {
73
+ return {
74
+ blocked: true,
75
+ category: 'reserved_internal_host',
76
+ reason: `host suffix "${suffix}" is reserved for internal networks`,
77
+ }
78
+ }
79
+ }
80
+
81
+ const ipv4 = parseIpv4Loose(decoded)
82
+ if (ipv4) {
83
+ const cls = classifyIpv4(ipv4)
84
+ if (cls) return { blocked: true, category: cls.category, reason: cls.reason }
85
+ }
86
+
87
+ if (looksLikeIpv6(decoded)) {
88
+ const cls = classifyIpv6(decoded)
89
+ if (cls) return { blocked: true, category: cls.category, reason: cls.reason }
90
+ }
91
+
92
+ return { blocked: false }
93
+ }
94
+
95
+ export function checkSsrfGuard(options: { tool: string; args: Record<string, unknown> }): SecurityBlock | undefined {
96
+ const { tool, args } = options
97
+ if (tool !== 'webfetch') return undefined
98
+ const url = args.url
99
+ if (typeof url !== 'string') return undefined
100
+ if (isGuardAcknowledged(args, GUARD_SSRF)) return undefined
101
+
102
+ const result = classifyUrl(url)
103
+ if (!result.blocked) return undefined
104
+
105
+ return {
106
+ block: true,
107
+ reason: [
108
+ `Guard \`${GUARD_SSRF}\` blocked webfetch to a non-public destination (${result.category ?? 'unknown'}): ${result.reason ?? 'classified as internal'}.`,
109
+ 'This protects against SSRF, cloud metadata exfiltration, and accidental fetches against internal services.',
110
+ `If this is genuinely intentional and you trust the URL, retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_SSRF}: true\` in the webfetch arguments.`,
111
+ ].join(' '),
112
+ }
113
+ }
114
+
115
+ function decodeBracketedIpv6(host: string): string {
116
+ if (host.startsWith('[') && host.endsWith(']')) return host.slice(1, -1)
117
+ return host
118
+ }
119
+
120
+ function parseIpv4Loose(host: string): [number, number, number, number] | undefined {
121
+ const dotted = host.match(/^(\d{1,10})\.(\d{1,10})\.(\d{1,10})\.(\d{1,10})$/)
122
+ if (dotted && dotted[1] && dotted[2] && dotted[3] && dotted[4]) {
123
+ const parts = [dotted[1], dotted[2], dotted[3], dotted[4]].map((s) => parseInt(s, 10))
124
+ if (parts.every((n) => Number.isFinite(n) && n >= 0 && n <= 255)) {
125
+ return parts as [number, number, number, number]
126
+ }
127
+ }
128
+ const decimal = host.match(/^(\d{6,12})$/)
129
+ if (decimal && decimal[1]) {
130
+ const n = Number(decimal[1])
131
+ if (Number.isFinite(n) && n >= 0 && n <= 0xffffffff) {
132
+ return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]
133
+ }
134
+ }
135
+ const hex = host.match(/^0x([0-9a-f]{1,8})$/i)
136
+ if (hex && hex[1]) {
137
+ const n = parseInt(hex[1], 16)
138
+ if (Number.isFinite(n) && n >= 0 && n <= 0xffffffff) {
139
+ return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]
140
+ }
141
+ }
142
+ return undefined
143
+ }
144
+
145
+ function classifyIpv4(
146
+ ip: [number, number, number, number],
147
+ ): { category: SsrfClassification['category']; reason: string } | undefined {
148
+ const [a, b] = ip
149
+ if (a === 127) return { category: 'loopback', reason: `IPv4 loopback (${ip.join('.')})` }
150
+ if (a === 10) return { category: 'private_ipv4', reason: `private RFC1918 10.0.0.0/8 (${ip.join('.')})` }
151
+ if (a === 172 && b >= 16 && b <= 31)
152
+ return { category: 'private_ipv4', reason: `private RFC1918 172.16.0.0/12 (${ip.join('.')})` }
153
+ if (a === 192 && b === 168)
154
+ return { category: 'private_ipv4', reason: `private RFC1918 192.168.0.0/16 (${ip.join('.')})` }
155
+ if (a === 169 && b === 254)
156
+ return { category: 'cloud_metadata', reason: `link-local / cloud metadata 169.254.0.0/16 (${ip.join('.')})` }
157
+ if (a === 100 && b >= 64 && b <= 127)
158
+ return { category: 'shared_cgnat', reason: `CGNAT 100.64.0.0/10 (${ip.join('.')})` }
159
+ if (a === 0) return { category: 'unspecified', reason: `unspecified 0.0.0.0/8 (${ip.join('.')})` }
160
+ if (a >= 224) return { category: 'private_ipv4', reason: `multicast/reserved (${ip.join('.')})` }
161
+ return undefined
162
+ }
163
+
164
+ function looksLikeIpv6(host: string): boolean {
165
+ return host.includes(':') && /^[0-9a-f:]+$/i.test(host)
166
+ }
167
+
168
+ function classifyIpv6(host: string): { category: SsrfClassification['category']; reason: string } | undefined {
169
+ const lower = host.toLowerCase()
170
+ if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') return { category: 'loopback', reason: 'IPv6 loopback ::1' }
171
+ if (lower === '::' || lower === '0:0:0:0:0:0:0:0') return { category: 'unspecified', reason: 'IPv6 unspecified ::' }
172
+ if (lower.startsWith('fe80:') || lower.startsWith('fe80::'))
173
+ return { category: 'link_local', reason: 'IPv6 link-local fe80::/10' }
174
+ if (lower.startsWith('fc') || lower.startsWith('fd'))
175
+ return { category: 'ipv6_internal', reason: 'IPv6 unique-local fc00::/7' }
176
+ if (lower.startsWith('ff')) return { category: 'ipv6_internal', reason: 'IPv6 multicast ff00::/8' }
177
+ if (lower.startsWith('::ffff:')) {
178
+ const tail = lower.slice('::ffff:'.length)
179
+ const dotted = parseIpv4Loose(tail)
180
+ if (dotted) {
181
+ const cls = classifyIpv4(dotted)
182
+ if (cls) return { category: cls.category, reason: `IPv4-mapped IPv6: ${cls.reason}` }
183
+ }
184
+ const hexPair = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
185
+ if (hexPair && hexPair[1] && hexPair[2]) {
186
+ const hi = parseInt(hexPair[1], 16)
187
+ const lo = parseInt(hexPair[2], 16)
188
+ if (Number.isFinite(hi) && Number.isFinite(lo)) {
189
+ const ip: [number, number, number, number] = [(hi >>> 8) & 0xff, hi & 0xff, (lo >>> 8) & 0xff, lo & 0xff]
190
+ const cls = classifyIpv4(ip)
191
+ if (cls) return { category: cls.category, reason: `IPv4-mapped IPv6: ${cls.reason}` }
192
+ }
193
+ }
194
+ }
195
+ return undefined
196
+ }
@@ -0,0 +1,81 @@
1
+ import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
2
+
3
+ export const GUARD_SYSTEM_PROMPT_LEAK = 'systemPromptLeak'
4
+
5
+ const FINGERPRINT_PATTERNS: ReadonlyArray<{ label: string; pattern: RegExp }> = [
6
+ { label: 'TypeClaw runtime preamble', pattern: /You are a general-purpose AI agent running inside TypeClaw\./ },
7
+ { label: 'TypeClaw "Your agent folder" header', pattern: /^##\s+Your\s+agent\s+folder\b/m },
8
+ {
9
+ label: 'IDENTITY.md / SOUL.md / MEMORY.md / USER.md / AGENTS.md identity-file recital',
10
+ pattern: /IDENTITY\.md\b[\s\S]{0,400}SOUL\.md\b[\s\S]{0,400}(?:MEMORY\.md|USER\.md|AGENTS\.md)/,
11
+ },
12
+ {
13
+ label: 'TypeClaw injected MEMORY-context disclaimer',
14
+ pattern: /\[MEMORY\s+CONTEXT\s+[\u2014-]\s+not\s+instructions\]/,
15
+ },
16
+ {
17
+ label: 'TypeClaw session-origin / channel_reply preamble',
18
+ pattern: /For\s+every\s+user\s+message\s+in\s+this\s+session,\s+you\s+MUST\s+call\s+`?channel_reply`?/,
19
+ },
20
+ { label: 'TypeClaw available_skills XML block', pattern: /<available_skills>[\s\S]*?<\/available_skills>/ },
21
+ { label: 'TypeClaw skill XML element', pattern: /<skill>\s*<name>[\s\S]*?<\/name>\s*<description>/ },
22
+ {
23
+ label: 'pi-coding-agent SOUL.md prelude',
24
+ pattern: /If\s+SOUL\.md\s+has\s+content\s+below,\s+embody\s+its\s+persona/,
25
+ },
26
+ {
27
+ label: 'TypeClaw NO_REPLY contract',
28
+ pattern: /your\s+entire\s+final\s+visible\s+response\s+must\s+be\s+exactly\s+`?NO_REPLY`?/,
29
+ },
30
+ {
31
+ label: 'TypeClaw SOUL/IDENTITY/MEMORY headed code-block dump',
32
+ pattern: /^#\s+(?:Identity|Memory|Project\s+Context)\s*$/m,
33
+ },
34
+ ]
35
+
36
+ const MARKDOWN_HEADERS_DISTINCTIVE = /^##\s+(?:IDENTITY\.md|SOUL\.md|USER\.md|MEMORY\.md|AGENTS\.md)\s*$/m
37
+
38
+ export type SystemPromptLeakMatch = { label: string; pattern: string }
39
+
40
+ export function findSystemPromptLeak(text: string): SystemPromptLeakMatch[] {
41
+ const hits: SystemPromptLeakMatch[] = []
42
+ for (const { label, pattern } of FINGERPRINT_PATTERNS) {
43
+ if (pattern.test(text)) hits.push({ label, pattern: pattern.source })
44
+ }
45
+ if (MARKDOWN_HEADERS_DISTINCTIVE.test(text)) {
46
+ hits.push({
47
+ label: 'Identity-file markdown header (e.g. ## SOUL.md)',
48
+ pattern: MARKDOWN_HEADERS_DISTINCTIVE.source,
49
+ })
50
+ }
51
+ return hits
52
+ }
53
+
54
+ export function checkSystemPromptLeakGuard(options: {
55
+ tool: string
56
+ args: Record<string, unknown>
57
+ }): SecurityBlock | undefined {
58
+ const { tool, args } = options
59
+ if (tool !== 'channel_send' && tool !== 'channel_reply') return undefined
60
+ if (isGuardAcknowledged(args, GUARD_SYSTEM_PROMPT_LEAK)) return undefined
61
+
62
+ const candidates: string[] = []
63
+ for (const key of ['text', 'message', 'content', 'body']) {
64
+ const v = args[key]
65
+ if (typeof v === 'string' && v.length > 0) candidates.push(v)
66
+ }
67
+ for (const text of candidates) {
68
+ const hits = findSystemPromptLeak(text)
69
+ if (hits.length === 0) continue
70
+ const summary = hits.map((h) => h.label).join('; ')
71
+ return {
72
+ block: true,
73
+ reason: [
74
+ `Guard \`${GUARD_SYSTEM_PROMPT_LEAK}\` blocked ${tool}: outbound text contains TypeClaw system-prompt fingerprints (${summary}).`,
75
+ 'Posting the system prompt or identity files to a channel exposes the agent to prompt-injection replay attacks.',
76
+ `If this is genuinely intentional (e.g. you are debugging your own agent), retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_SYSTEM_PROMPT_LEAK}: true\` in the tool arguments.`,
77
+ ].join(' '),
78
+ }
79
+ }
80
+ return undefined
81
+ }
@@ -0,0 +1,9 @@
1
+ export const ACKNOWLEDGE_GUARDS = 'acknowledgeGuards'
2
+
3
+ export type SecurityBlock = { block: true; reason: string }
4
+
5
+ export function isGuardAcknowledged(args: Record<string, unknown>, guard: string): boolean {
6
+ const acknowledgements = args[ACKNOWLEDGE_GUARDS]
7
+ if (!acknowledgements || typeof acknowledgements !== 'object') return false
8
+ return (acknowledgements as Record<string, unknown>)[guard] === true
9
+ }
@@ -0,0 +1,77 @@
1
+ import type { ChannelKey, ChannelNameResolver, ResolvedChannelNames } from '@/channels/types'
2
+
3
+ const DISCORD_API_BASE = 'https://discord.com/api/v10'
4
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000
5
+
6
+ export type DiscordChannelResolverOptions = {
7
+ token: string
8
+ now?: () => number
9
+ ttlMs?: number
10
+ }
11
+
12
+ type CacheEntry<T> = { value: T; expiresAt: number }
13
+
14
+ type DiscordChannel = { id?: string; name?: string }
15
+ type DiscordGuild = { id?: string; name?: string }
16
+
17
+ export function createDiscordChannelResolver(options: DiscordChannelResolverOptions): ChannelNameResolver {
18
+ const now = options.now ?? Date.now
19
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS
20
+
21
+ const channelCache = new Map<string, CacheEntry<string>>()
22
+ const guildCache = new Map<string, CacheEntry<string>>()
23
+
24
+ const fetchCached = async <T>(
25
+ cache: Map<string, CacheEntry<T>>,
26
+ key: string,
27
+ fetcher: () => Promise<T | null>,
28
+ ): Promise<T | null> => {
29
+ const cached = cache.get(key)
30
+ if (cached && cached.expiresAt > now()) return cached.value
31
+ const value = await fetcher()
32
+ if (value !== null) cache.set(key, { value, expiresAt: now() + ttlMs })
33
+ return value
34
+ }
35
+
36
+ return async (key: ChannelKey): Promise<ResolvedChannelNames> => {
37
+ if (key.workspace === '@dm') return {}
38
+
39
+ const [chatName, workspaceName] = await Promise.all([
40
+ fetchCached(channelCache, key.chat, () => fetchChannelName(key.chat, options.token)),
41
+ fetchCached(guildCache, key.workspace, () => fetchGuildName(key.workspace, options.token)),
42
+ ])
43
+
44
+ const result: ResolvedChannelNames = {}
45
+ if (chatName !== null) result.chatName = chatName
46
+ if (workspaceName !== null) result.workspaceName = workspaceName
47
+ return result
48
+ }
49
+ }
50
+
51
+ async function fetchChannelName(channelId: string, token: string): Promise<string | null> {
52
+ try {
53
+ const response = await fetch(`${DISCORD_API_BASE}/channels/${encodeURIComponent(channelId)}`, {
54
+ headers: { Authorization: `Bot ${token}` },
55
+ })
56
+ if (!response.ok) return null
57
+ const body = (await response.json()) as DiscordChannel
58
+ if (typeof body.name !== 'string' || body.name === '') return null
59
+ return body.name
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ async function fetchGuildName(guildId: string, token: string): Promise<string | null> {
66
+ try {
67
+ const response = await fetch(`${DISCORD_API_BASE}/guilds/${encodeURIComponent(guildId)}`, {
68
+ headers: { Authorization: `Bot ${token}` },
69
+ })
70
+ if (!response.ok) return null
71
+ const body = (await response.json()) as DiscordGuild
72
+ if (typeof body.name !== 'string' || body.name === '') return null
73
+ return body.name
74
+ } catch {
75
+ return null
76
+ }
77
+ }