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.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- 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
|
+
}
|