typeclaw 0.1.5 → 0.2.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 (128) hide show
  1. package/README.md +14 -12
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +7 -4
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +23 -9
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +799 -319
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +6 -1
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +8 -1
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +385 -12
  67. package/src/config/index.ts +7 -0
  68. package/src/config/models-mutation.ts +209 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +5 -0
  73. package/src/container/require-running.ts +33 -0
  74. package/src/container/start.ts +39 -58
  75. package/src/cron/consumer.ts +22 -2
  76. package/src/cron/index.ts +45 -4
  77. package/src/cron/schema.ts +104 -0
  78. package/src/doctor/checks.ts +50 -33
  79. package/src/git/system-commit.ts +103 -0
  80. package/src/hostd/daemon.ts +16 -0
  81. package/src/hostd/kakao-renewal-manager.ts +223 -0
  82. package/src/hostd/paths.ts +7 -0
  83. package/src/init/dockerfile.ts +32 -6
  84. package/src/init/index.ts +190 -61
  85. package/src/init/kakaotalk-auth.ts +18 -1
  86. package/src/init/models-dev.ts +26 -1
  87. package/src/init/run-owner-claim.ts +77 -0
  88. package/src/permissions/builtins.ts +70 -0
  89. package/src/permissions/grant.ts +99 -0
  90. package/src/permissions/index.ts +29 -0
  91. package/src/permissions/match-rule.ts +305 -0
  92. package/src/permissions/permissions.ts +196 -0
  93. package/src/permissions/resolve.ts +80 -0
  94. package/src/permissions/schema.ts +79 -0
  95. package/src/plugin/context.ts +8 -4
  96. package/src/plugin/define.ts +2 -0
  97. package/src/plugin/index.ts +2 -0
  98. package/src/plugin/manager.ts +41 -0
  99. package/src/plugin/registry.ts +9 -0
  100. package/src/plugin/types.ts +35 -1
  101. package/src/role-claim/client.ts +182 -0
  102. package/src/role-claim/code.ts +53 -0
  103. package/src/role-claim/controller.ts +194 -0
  104. package/src/role-claim/index.ts +19 -0
  105. package/src/role-claim/match-rule.ts +43 -0
  106. package/src/role-claim/pending.ts +100 -0
  107. package/src/run/channel-session-factory.ts +76 -5
  108. package/src/run/index.ts +55 -6
  109. package/src/secrets/encryption.ts +116 -0
  110. package/src/secrets/kakao-renewal.ts +248 -0
  111. package/src/secrets/kakao-store.ts +66 -7
  112. package/src/secrets/keys.ts +173 -0
  113. package/src/secrets/schema.ts +23 -0
  114. package/src/secrets/storage.ts +68 -0
  115. package/src/server/index.ts +122 -11
  116. package/src/shared/index.ts +4 -0
  117. package/src/shared/protocol.ts +27 -0
  118. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  119. package/src/skills/typeclaw-config/SKILL.md +38 -64
  120. package/src/skills/typeclaw-memory/SKILL.md +1 -1
  121. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  122. package/src/stream/types.ts +7 -1
  123. package/src/usage/aggregate.ts +117 -0
  124. package/src/usage/format.ts +30 -0
  125. package/src/usage/index.ts +68 -0
  126. package/src/usage/report.ts +354 -0
  127. package/src/usage/scan.ts +186 -0
  128. package/typeclaw.schema.json +57 -45
@@ -11,6 +11,7 @@ import { kakaoChannelBlockSchema } from '@/secrets/schema'
11
11
  import { SecretsBackend } from '@/secrets/storage'
12
12
 
13
13
  import { isDaemonReachable } from './client'
14
+ import type { KakaoRenewalCallbacks, KakaoRenewalLogEvent } from './kakao-renewal-manager'
14
15
  import { ensureDirs, registrationFilePath, registrationsDir, socketPath } from './paths'
15
16
  import type {
16
17
  HttpInfoResult,
@@ -54,6 +55,11 @@ export type DaemonOptions = {
54
55
  // fields trigger broker spawn alongside supervisor registration. Tests omit
55
56
  // it to keep the broker out of unrelated suites.
56
57
  portbroker?: PortbrokerCallbacks
58
+ // KakaoTalk credential renewal capability. When provided, the daemon
59
+ // starts a per-container daily renewal tick on register and stops it on
60
+ // deregister. Omit to disable in tests / when the agent has no kakaotalk
61
+ // channel configured.
62
+ kakaoRenewal?: KakaoRenewalCallbacks
57
63
  }
58
64
 
59
65
  export type RestartPreflight = (input: {
@@ -94,6 +100,7 @@ export type DaemonLogEvent =
94
100
  | { kind: 'shutdown-requested' }
95
101
  | { kind: 'port-forward-event'; event: PortForwardEvent }
96
102
  | { kind: 'tailscale-serve-event'; event: TailscaleServeEvent }
103
+ | KakaoRenewalLogEvent
97
104
 
98
105
  export type Daemon = {
99
106
  registered: () => string[]
@@ -284,6 +291,9 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
284
291
  onTailscaleServeEvent: (event) => log({ kind: 'tailscale-serve-event', event }),
285
292
  })
286
293
  }
294
+ if (opts.kakaoRenewal) {
295
+ opts.kakaoRenewal.start({ containerName: payload.containerName, cwd: payload.cwd })
296
+ }
287
297
  }
288
298
 
289
299
  const handleRegister = async (req: RegisterPayload): Promise<RpcResponse> => {
@@ -309,6 +319,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
309
319
  restartTokens.delete(req.containerName)
310
320
  gcMisses.delete(req.containerName)
311
321
  if (opts.portbroker) await opts.portbroker.stop(req.containerName, 'deregistered').catch(() => {})
322
+ if (opts.kakaoRenewal) await opts.kakaoRenewal.stop(req.containerName).catch(() => {})
312
323
  await removeRegistrationFile(req.containerName)
313
324
  if (hadCwd) log({ kind: 'deregister', containerName: req.containerName, reason: 'requested' })
314
325
  return { ok: true }
@@ -574,6 +585,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
574
585
  const hadCwd = cwds.delete(name)
575
586
  restartTokens.delete(name)
576
587
  if (opts.portbroker) await opts.portbroker.stop(name, 'deregistered').catch(() => {})
588
+ if (opts.kakaoRenewal) await opts.kakaoRenewal.stop(name).catch(() => {})
577
589
  await removeRegistrationFile(name)
578
590
  if (hadCwd) log({ kind: 'deregister', containerName: name, reason: 'gone' })
579
591
  return { ok: true }
@@ -601,6 +613,10 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
601
613
  const names = Array.from(cwds.keys())
602
614
  await Promise.allSettled(names.map((n) => opts.portbroker!.stop(n, 'broker-stopped')))
603
615
  }
616
+ if (opts.kakaoRenewal) {
617
+ const names = Array.from(cwds.keys())
618
+ await Promise.allSettled(names.map((n) => opts.kakaoRenewal!.stop(n)))
619
+ }
604
620
  cwds.clear()
605
621
  restartTokens.clear()
606
622
  try {
@@ -0,0 +1,223 @@
1
+ import { renewCurrentAccount, type AttemptLoginFn } from '@/secrets/kakao-renewal'
2
+ import { createKeyStore, type KeyStore } from '@/secrets/keys'
3
+
4
+ import { keysDir } from './paths'
5
+
6
+ const DEFAULT_TICK_INTERVAL_MS = 24 * 60 * 60 * 1000
7
+
8
+ export type KakaoRenewalCallbacks = {
9
+ start: (input: KakaoRenewalStartInput) => void
10
+ stop: (containerName: string) => Promise<void>
11
+ drain: () => Promise<void>
12
+ }
13
+
14
+ export type KakaoRenewalStartInput = {
15
+ containerName: string
16
+ cwd: string
17
+ }
18
+
19
+ export type KakaoRenewalLogEvent =
20
+ | { kind: 'kakao-renewal-tick-start'; containerName: string }
21
+ | { kind: 'kakao-renewal-tick-skipped'; containerName: string; reason: string; ageMs?: number }
22
+ | { kind: 'kakao-renewal-tick-ok'; containerName: string; accountId: string; previousUpdatedAt: string }
23
+ | {
24
+ kind: 'kakao-renewal-tick-reauth-required'
25
+ containerName: string
26
+ accountId: string
27
+ reason: string
28
+ message: string
29
+ }
30
+ | { kind: 'kakao-renewal-tick-transient-failure'; containerName: string; accountId: string; reason: string }
31
+ | { kind: 'kakao-renewal-tick-error'; containerName: string; error: string }
32
+ | { kind: 'kakao-renewal-restart-scheduled'; containerName: string; accountId: string }
33
+ | { kind: 'kakao-renewal-restart-failed'; containerName: string; accountId: string; reason: string }
34
+
35
+ export type KakaoRenewalManagerOptions = {
36
+ onLog?: (event: KakaoRenewalLogEvent) => void
37
+ tickIntervalMs?: number
38
+ keyStoreFactory?: () => KeyStore
39
+ attemptLogin?: AttemptLoginFn
40
+ schedule?: (fn: () => void, intervalMs: number) => { stop: () => void }
41
+ // Invoked after a successful renewal so the host can restart the container
42
+ // and the in-memory adapter picks up the fresh tokens. Without this, the
43
+ // cron writes new tokens to secrets.json but the live LOCO client keeps the
44
+ // old token in its closure and still hits 401 at the ~7-day wall. Production
45
+ // wires this to the same restart path the `restart` RPC uses; tests can
46
+ // observe it via a fake.
47
+ onRenewalOk?: (input: { containerName: string; cwd: string; accountId: string }) => Promise<void>
48
+ // Optional predicate: only start the renewal cron for containers whose
49
+ // `typeclaw.json` actually has a `channels.kakaotalk` block. Without this,
50
+ // every typeclaw agent on the host emits daily `no_account` skip logs.
51
+ shouldRenew?: (input: KakaoRenewalStartInput) => boolean
52
+ }
53
+
54
+ // Per-container daily renewal tick. Mirrors portbroker-manager.ts: hostd calls
55
+ // start() on register and stop() on deregister, and the manager owns timer
56
+ // lifecycle plus the actual renewal work. The keystore lives on the host
57
+ // (~/.typeclaw/keys/<name>.key), unreachable from inside the container —
58
+ // that's load-bearing for encryption.ts's threat model.
59
+ export function createKakaoRenewalManager(opts: KakaoRenewalManagerOptions = {}): KakaoRenewalCallbacks {
60
+ const intervalMs = opts.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS
61
+ const keyStore = (opts.keyStoreFactory ?? (() => createKeyStore({ keysDir: keysDir() })))()
62
+ const log = opts.onLog ?? (() => {})
63
+ const schedule =
64
+ opts.schedule ??
65
+ ((fn: () => void, ms: number) => {
66
+ const handle = setInterval(fn, ms)
67
+ return { stop: () => clearInterval(handle) }
68
+ })
69
+
70
+ const timers = new Map<string, { stop: () => void }>()
71
+ // Track the latest registration input per container so a re-register
72
+ // arriving during an in-flight tick can both (a) prevent the in-flight tick
73
+ // from acting on stale cwd, and (b) trigger a fresh tick once the in-flight
74
+ // one finishes. The Map's value is the most recent input.
75
+ const latestInput = new Map<string, KakaoRenewalStartInput>()
76
+ // Track in-flight tick promises per container so stop()/drain() can await
77
+ // them. Without this, daemon shutdown abandons an in-flight attemptLogin
78
+ // HTTPS request mid-write.
79
+ const inFlight = new Map<string, Promise<void>>()
80
+ // Pending immediate-tick request: set when start() is called while a tick
81
+ // is in flight, so we re-fire one tick after the in-flight settles.
82
+ const pendingRerun = new Set<string>()
83
+
84
+ const runTick = async (input: KakaoRenewalStartInput): Promise<void> => {
85
+ log({ kind: 'kakao-renewal-tick-start', containerName: input.containerName })
86
+ try {
87
+ const result = await renewCurrentAccount({
88
+ containerName: input.containerName,
89
+ agentDir: input.cwd,
90
+ keyStore,
91
+ ...(opts.attemptLogin ? { attemptLogin: opts.attemptLogin } : {}),
92
+ })
93
+ if (result.kind === 'skipped') {
94
+ log({
95
+ kind: 'kakao-renewal-tick-skipped',
96
+ containerName: input.containerName,
97
+ reason: result.reason,
98
+ ...(result.ageMs !== undefined ? { ageMs: result.ageMs } : {}),
99
+ })
100
+ } else if (result.kind === 'ok') {
101
+ log({
102
+ kind: 'kakao-renewal-tick-ok',
103
+ containerName: input.containerName,
104
+ accountId: result.account_id,
105
+ previousUpdatedAt: result.previousUpdatedAt,
106
+ })
107
+ if (opts.onRenewalOk) {
108
+ log({
109
+ kind: 'kakao-renewal-restart-scheduled',
110
+ containerName: input.containerName,
111
+ accountId: result.account_id,
112
+ })
113
+ try {
114
+ await opts.onRenewalOk({
115
+ containerName: input.containerName,
116
+ cwd: input.cwd,
117
+ accountId: result.account_id,
118
+ })
119
+ } catch (err) {
120
+ log({
121
+ kind: 'kakao-renewal-restart-failed',
122
+ containerName: input.containerName,
123
+ accountId: result.account_id,
124
+ reason: err instanceof Error ? err.message : String(err),
125
+ })
126
+ }
127
+ }
128
+ } else if (result.kind === 'reauth_required') {
129
+ log({
130
+ kind: 'kakao-renewal-tick-reauth-required',
131
+ containerName: input.containerName,
132
+ accountId: result.account_id,
133
+ reason: result.reason,
134
+ message: result.message,
135
+ })
136
+ } else {
137
+ log({
138
+ kind: 'kakao-renewal-tick-transient-failure',
139
+ containerName: input.containerName,
140
+ accountId: result.account_id,
141
+ reason: result.reason,
142
+ })
143
+ }
144
+ } catch (err) {
145
+ // Defensive: renewCurrentAccount's contract is to return a structured
146
+ // result, but a malformed secrets.json or a disk error could surface
147
+ // here. Log and move on — the next tick retries.
148
+ log({
149
+ kind: 'kakao-renewal-tick-error',
150
+ containerName: input.containerName,
151
+ error: err instanceof Error ? err.message : String(err),
152
+ })
153
+ }
154
+ }
155
+
156
+ // Single-flight per container: dedupe overlapping ticks, but if a new
157
+ // tick request arrives while one is in flight, queue ONE rerun so the new
158
+ // registration's cwd (or a manual nudge) gets a chance after the in-flight
159
+ // settles. The Promise stored in inFlight resolves only after any queued
160
+ // rerun also completes, so stop()/drain() awaiting inFlight is enough to
161
+ // observe a quiescent manager.
162
+ const scheduleTick = (containerName: string): Promise<void> => {
163
+ const existing = inFlight.get(containerName)
164
+ if (existing) {
165
+ pendingRerun.add(containerName)
166
+ return existing
167
+ }
168
+ const promise = (async () => {
169
+ // Loop until no rerun was queued during this tick, so the LAST input
170
+ // recorded for the container is the one we end on. This handles the
171
+ // re-register-while-in-flight + cwd-change case described in the
172
+ // pendingRerun comment above.
173
+ while (true) {
174
+ const input = latestInput.get(containerName)
175
+ if (!input) return
176
+ await runTick(input)
177
+ if (!pendingRerun.has(containerName)) return
178
+ pendingRerun.delete(containerName)
179
+ }
180
+ })().finally(() => {
181
+ inFlight.delete(containerName)
182
+ pendingRerun.delete(containerName)
183
+ })
184
+ inFlight.set(containerName, promise)
185
+ return promise
186
+ }
187
+
188
+ return {
189
+ start(input: KakaoRenewalStartInput): void {
190
+ if (opts.shouldRenew && !opts.shouldRenew(input)) return
191
+ const existing = timers.get(input.containerName)
192
+ if (existing) existing.stop()
193
+ latestInput.set(input.containerName, input)
194
+ const handle = schedule(() => {
195
+ void scheduleTick(input.containerName)
196
+ }, intervalMs)
197
+ timers.set(input.containerName, handle)
198
+ void scheduleTick(input.containerName)
199
+ },
200
+
201
+ async stop(containerName: string): Promise<void> {
202
+ const handle = timers.get(containerName)
203
+ if (handle) {
204
+ timers.delete(containerName)
205
+ handle.stop()
206
+ }
207
+ latestInput.delete(containerName)
208
+ // Await any in-flight tick before resolving so the caller can rely on
209
+ // "stop returned → no work outstanding". Daemon shutdown depends on
210
+ // this; without it, a mid-tick `attemptLogin` HTTPS call is abandoned.
211
+ const promise = inFlight.get(containerName)
212
+ if (promise) await promise.catch(() => {})
213
+ },
214
+
215
+ async drain(): Promise<void> {
216
+ for (const [, handle] of timers) handle.stop()
217
+ timers.clear()
218
+ latestInput.clear()
219
+ const promises = Array.from(inFlight.values())
220
+ await Promise.allSettled(promises)
221
+ },
222
+ }
223
+ }
@@ -9,6 +9,7 @@ import { join } from 'node:path'
9
9
  const CONTAINER_HOST_RUN_DIR = '/run/typeclaw-host'
10
10
  const SOCKET_FILE = 'hostd.sock'
11
11
  const REGISTRATIONS_DIR = 'registrations'
12
+ const KEYS_DIR = 'keys'
12
13
 
13
14
  // Defense-in-depth: containerName arrives from RPC payloads (some of which
14
15
  // originate inside the container). Docker already forbids slashes and most
@@ -51,6 +52,10 @@ export function registrationsDir(): string {
51
52
  return join(runDir(), REGISTRATIONS_DIR)
52
53
  }
53
54
 
55
+ export function keysDir(): string {
56
+ return join(homeRoot(), KEYS_DIR)
57
+ }
58
+
54
59
  // Throws on any name that could traverse out of registrationsDir() or
55
60
  // confuse the filesystem. Caller's responsibility to handle the error;
56
61
  // don't catch-and-ignore — an invalid name is a protocol violation.
@@ -76,7 +81,9 @@ export async function ensureDirs(): Promise<void> {
76
81
  await mkdir(runDir(), { recursive: true })
77
82
  await mkdir(logDir(), { recursive: true })
78
83
  await mkdir(registrationsDir(), { recursive: true })
84
+ await mkdir(keysDir(), { recursive: true })
79
85
  await chmod(runDir(), 0o700).catch(() => {})
80
86
  await chmod(logDir(), 0o700).catch(() => {})
81
87
  await chmod(registrationsDir(), 0o700).catch(() => {})
88
+ await chmod(keysDir(), 0o700).catch(() => {})
82
89
  }
@@ -136,14 +136,38 @@ export const NETWORK_BLOCK_IPV6_NETS = ['fc00::/7', 'fe80::/10', 'ff00::/8', '::
136
136
  // Carve-out ordering is load-bearing. iptables OUTPUT is first-match-wins,
137
137
  // and we use -A (append). So the order written into the shim is the order
138
138
  // rules will be evaluated:
139
- // 1. loopback ACCEPT
140
- // 2. hostd port ACCEPT (narrow: tcp + single dport on the host gateway)
141
- // 3. resolver ACCEPT (narrow: udp/tcp dport 53 to each /etc/resolv.conf
139
+ // 1. ESTABLISHED,RELATED ACCEPT (return path for any connection initiated
140
+ // from outside the container see comment block below)
141
+ // 2. loopback ACCEPT
142
+ // 3. hostd port ACCEPT (narrow: tcp + single dport on the host gateway)
143
+ // 4. resolver ACCEPT (narrow: udp/tcp dport 53 to each /etc/resolv.conf
142
144
  // nameserver) — gated on TYPECLAW_NETWORK_AUTO_ALLOW_RESOLVERS=1
143
- // 4. user-supplied allowlist ACCEPT (wholesale: -d <cidr>) — driven by
145
+ // 5. user-supplied allowlist ACCEPT (wholesale: -d <cidr>) — driven by
144
146
  // TYPECLAW_NETWORK_ALLOW comma-separated env
145
- // 5. RFC1918 + link-local + CGNAT + multicast + reserved REJECTs
146
- // A resolver at 10.0.0.2 hits (3) and ACCEPTs before (5) DROPs it.
147
+ // 6. RFC1918 + link-local + CGNAT + multicast + reserved REJECTs
148
+ // A resolver at 10.0.0.2 hits (4) and ACCEPTs before (6) DROPs it.
149
+ //
150
+ // Rule 1 (conntrack ESTABLISHED,RELATED) is what makes Docker port-forward
151
+ // reply traffic survive the RFC1918 REJECT. On Docker Desktop and OrbStack
152
+ // the bridge gateway is in 192.168.0.0/16 (OrbStack: 192.168.215.1 or
153
+ // 192.168.139.1; Docker Desktop: 192.168.65.1). A host -> container request
154
+ // via `docker run -p 127.0.0.1:HOST:CONTAINER` arrives at the container
155
+ // from the bridge gateway IP. Without rule 1, the reply packets would
156
+ // match rule 6 (192.168.0.0/16 REJECT) and never reach the host — TCP
157
+ // handshake completes (kernel SYN/ACK is in INPUT, not OUTPUT), the
158
+ // request body is delivered, but the agent's HTTP response is dropped at
159
+ // OUTPUT. Symptom: `curl http://127.0.0.1:HOST` connects but receives
160
+ // zero bytes and times out. Stateful inversion via conntrack is the
161
+ // canonical fix: ESTABLISHED matches packets belonging to a connection
162
+ // the kernel already tracks (including the inbound port-forward), and
163
+ // RELATED covers ICMP error packets for those connections. No new
164
+ // outbound capability is granted — a compromised agent still cannot
165
+ // initiate connections to RFC1918, only respond to inbound ones.
166
+ //
167
+ // Requires the `xt_conntrack` kernel module (universal on Linux 2.6.20+
168
+ // and on every Docker/OrbStack VM kernel) and the userspace iptables
169
+ // `conntrack` match (shipped in the `iptables` Debian package on trixie
170
+ // alongside the binary itself; no extra apt install needed).
147
171
  //
148
172
  // The resolver carve-out reads /etc/resolv.conf inside the container, NOT
149
173
  // on the host. Docker propagates the host's resolver into the container by
@@ -186,6 +210,7 @@ if [ "\${TYPECLAW_NETWORK_BLOCK_INTERNAL:-0}" != "1" ]; then
186
210
  exec bun run typeclaw "$@"
187
211
  fi
188
212
 
213
+ iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
189
214
  iptables -A OUTPUT -o lo -j ACCEPT
190
215
 
191
216
  # Hostd HTTP control carve-out: narrow ACCEPT, scoped to one TCP port on
@@ -222,6 +247,7 @@ if [ -n "\${TYPECLAW_NETWORK_ALLOW:-}" ]; then
222
247
  fi
223
248
  ${ipv4Rules.join('\n')}
224
249
 
250
+ ip6tables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
225
251
  ip6tables -A OUTPUT -o lo -j ACCEPT
226
252
  ${ipv6Rules.join('\n')}
227
253