typeclaw 0.1.3 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -104,6 +104,25 @@ export const gitignoreSchema = z
104
104
 
105
105
  export type GitignoreConfig = z.infer<typeof gitignoreSchema>
106
106
 
107
+ const IPV4_CIDR_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/
108
+
109
+ const ipv4CidrSchema = z.string().refine(
110
+ (value) => {
111
+ const match = IPV4_CIDR_PATTERN.exec(value)
112
+ if (!match) return false
113
+ const octets = [match[1], match[2], match[3], match[4]].map(Number)
114
+ if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false
115
+ if (match[5] !== undefined) {
116
+ const prefix = Number(match[5])
117
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false
118
+ }
119
+ return true
120
+ },
121
+ {
122
+ message: 'network.allow entries must be IPv4 addresses or CIDR ranges (e.g. "10.0.0.0/16", "10.0.0.2")',
123
+ },
124
+ )
125
+
107
126
  // `blockInternal` is the kill-switch for the container-stage egress filter
108
127
  // installed by Dockerfile entrypoint shim: when true, the container is granted
109
128
  // CAP_NET_ADMIN at boot just long enough to install iptables OUTPUT rules
@@ -124,11 +143,38 @@ export type GitignoreConfig = z.infer<typeof gitignoreSchema>
124
143
  // field is discoverable in fresh `typeclaw.json` files. Loopback traffic
125
144
  // (`-o lo`) is always allowed by the shim, so `bun run dev` and local APIs
126
145
  // on `localhost` / `127.0.0.1` are unaffected.
146
+ //
147
+ // `autoAllowResolvers` (default `true`) makes the shim narrowly carve out
148
+ // the container's DNS resolvers — every `nameserver` line in
149
+ // `/etc/resolv.conf` gets a `udp/tcp --dport 53` ACCEPT inserted BEFORE the
150
+ // REJECT rules. This fixes the canonical EC2/GCE/Azure footgun: cloud VPC
151
+ // resolvers live inside RFC1918 (e.g. AWS VPC DNS at `10.0.0.2`), so
152
+ // `blockInternal: true` would otherwise kill every DNS lookup the agent
153
+ // makes. The carve-out is scoped to port 53 only — a compromised agent
154
+ // cannot reach the resolver host on any other port. On a laptop where
155
+ // `/etc/resolv.conf` points at a public resolver (1.1.1.1, 8.8.8.8), the
156
+ // generated ACCEPT rules are no-ops because public IPs are not in the
157
+ // block list to begin with. Opt-out (`false`) is for users who explicitly
158
+ // configure DNS via `.env` (e.g. `DOCKER_DNS=1.1.1.1`) and want a fully
159
+ // closed filter.
160
+ //
161
+ // `allow` is the power-user escape hatch: an explicit list of IPv4 CIDRs
162
+ // or bare IPv4 addresses that punch through the block list wholesale (all
163
+ // ports, all protocols). Use case: VPC-private services the agent must
164
+ // reach by IP — internal APIs, RDS endpoints, VPC interface endpoints for
165
+ // S3/Bedrock. Each entry inserts an unscoped `iptables -A OUTPUT -d <cidr>
166
+ // -j ACCEPT` before the REJECT rules. IPv4 only: the carve-out is for
167
+ // destinations the operator names explicitly, and every cloud VPC we
168
+ // support is IPv4-routable. Validation at parse time rejects non-CIDR
169
+ // strings, IPv6 forms, and out-of-range octets so a typo in
170
+ // `typeclaw.json` surfaces immediately instead of at container boot.
127
171
  export const networkSchema = z
128
172
  .object({
129
173
  blockInternal: z.boolean().default(true),
174
+ autoAllowResolvers: z.boolean().default(true),
175
+ allow: z.array(ipv4CidrSchema).default([]),
130
176
  })
131
- .default({ blockInternal: true })
177
+ .default({ blockInternal: true, autoAllowResolvers: true, allow: [] })
132
178
 
133
179
  export type NetworkConfig = z.infer<typeof networkSchema>
134
180
 
@@ -17,6 +17,8 @@ export {
17
17
  } from './shared'
18
18
  export {
19
19
  planStart,
20
+ refreshDockerfile,
21
+ refreshGitignore,
20
22
  start,
21
23
  type HostDaemonStatus,
22
24
  type PlanStartOptions,
@@ -424,9 +424,16 @@ export async function planStart({
424
424
  // bounding set via setpriv before exec'ing the agent — see the shim source
425
425
  // in src/init/dockerfile.ts for the full handoff. The `-e` flag is what
426
426
  // tells the shim to take the on-path; absent or set to anything other than
427
- // "1", the shim is a no-op.
427
+ // "1", the shim is a no-op. `autoAllowResolvers` / `allow` envs are only
428
+ // emitted on the on-path because the shim's off-path doesn't read them;
429
+ // `TYPECLAW_NETWORK_ALLOW` is comma-joined to match the shim's `IFS=','`
430
+ // loop, and CIDR validation already happened at config parse time.
428
431
  if (cfg.network.blockInternal) {
429
432
  runArgs.push('--cap-add=NET_ADMIN', '-e', 'TYPECLAW_NETWORK_BLOCK_INTERNAL=1')
433
+ runArgs.push('-e', `TYPECLAW_NETWORK_AUTO_ALLOW_RESOLVERS=${cfg.network.autoAllowResolvers ? '1' : '0'}`)
434
+ if (cfg.network.allow.length > 0) {
435
+ runArgs.push('-e', `TYPECLAW_NETWORK_ALLOW=${cfg.network.allow.join(',')}`)
436
+ }
430
437
  }
431
438
 
432
439
  if (hostdControl) {
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs'
2
- import { readFile, writeFile } from 'node:fs/promises'
2
+ import { readFile } from 'node:fs/promises'
3
3
  import { homedir } from 'node:os'
4
4
  import { join, relative } from 'node:path'
5
5
 
@@ -10,10 +10,13 @@ import {
10
10
  defaultDockerExec,
11
11
  imageTagFromCwd,
12
12
  inspectContainer,
13
+ refreshDockerfile,
14
+ refreshGitignore,
13
15
  resolveHostPort,
14
16
  type DockerExec,
15
17
  } from '@/container'
16
18
  import { isDaemonReachable, send } from '@/hostd'
19
+ import { resolveBaseImageVersion } from '@/init/cli-version'
17
20
  import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
18
21
  import { detectMissingDeps } from '@/init/ensure-deps'
19
22
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
@@ -33,7 +36,6 @@ export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): Docto
33
36
  agentFolderDockerfileTemplate(),
34
37
  agentFolderGitignoreTemplate(),
35
38
  agentFolderNodeModules(),
36
- agentFolderEnvFile(),
37
39
  agentFolderGitRepo(),
38
40
  configValid(),
39
41
  hostdHomeWritable(),
@@ -152,7 +154,7 @@ function agentFolderDockerfileTemplate(): DoctorCheck {
152
154
  fix: {
153
155
  description: 'Regenerate the Dockerfile from the typeclaw template.',
154
156
  autoFix: async () => {
155
- await writeAtomic(dockerfilePath, expected)
157
+ await refreshDockerfile(ctx.cwd)
156
158
  return { summary: 'refreshed Dockerfile from template', changedPaths: [DOCKERFILE] }
157
159
  },
158
160
  },
@@ -181,7 +183,7 @@ function agentFolderGitignoreTemplate(): DoctorCheck {
181
183
  fix: {
182
184
  description: 'Regenerate .gitignore from the typeclaw template.',
183
185
  autoFix: async () => {
184
- await writeAtomic(gitignorePath, expected)
186
+ await refreshGitignore(ctx.cwd)
185
187
  return { summary: 'refreshed .gitignore from template', changedPaths: [GITIGNORE_FILE] }
186
188
  },
187
189
  },
@@ -209,24 +211,6 @@ function agentFolderNodeModules(): DoctorCheck {
209
211
  }
210
212
  }
211
213
 
212
- function agentFolderEnvFile(): DoctorCheck {
213
- return {
214
- name: 'agent-folder.env-file',
215
- category: 'agent-folder',
216
- description: '.env file is present',
217
- applies: (ctx) => ctx.hasAgentFolder,
218
- async run(ctx) {
219
- if (existsSync(join(ctx.cwd, '.env'))) return { status: 'ok', message: '.env present' }
220
- return {
221
- status: 'warning',
222
- message: '.env is missing',
223
- details: ['Channels and external API integrations will not have their secrets injected.'],
224
- fix: { description: 'Create a .env file with the credentials your agent needs.' },
225
- }
226
- },
227
- }
228
- }
229
-
230
214
  function agentFolderGitRepo(): DoctorCheck {
231
215
  return {
232
216
  name: 'agent-folder.git-repo',
@@ -384,7 +368,7 @@ function buildExpectedDockerfile(cwd: string): string | null {
384
368
  try {
385
369
  const cfg = loadConfigStrictForTemplate(cwd)
386
370
  if (cfg === null) return null
387
- return buildDockerfile(cfg.dockerfile)
371
+ return buildDockerfile(cfg.dockerfile, { baseImageVersion: resolveBaseImageVersion(cwd) })
388
372
  } catch {
389
373
  return null
390
374
  }
@@ -417,10 +401,6 @@ async function safeRead(path: string): Promise<string | null> {
417
401
  }
418
402
  }
419
403
 
420
- async function writeAtomic(path: string, content: string): Promise<void> {
421
- await writeFile(path, content)
422
- }
423
-
424
404
  export function relativeToCwd(cwd: string, path: string): string {
425
405
  return relative(cwd, path) || '.'
426
406
  }
@@ -18,8 +18,8 @@ export async function commitAutoFixes(opts: CommitOptions): Promise<CommitOutcom
18
18
  return { kind: 'skipped', reason: 'no successful auto-fixes' }
19
19
  }
20
20
 
21
- const pathsStaged = uniqueSorted(successes.flatMap((a) => a.changedPaths))
22
- if (pathsStaged.length === 0) {
21
+ const requested = uniqueSorted(successes.flatMap((a) => a.changedPaths))
22
+ if (requested.length === 0) {
23
23
  return { kind: 'skipped', reason: 'auto-fixes reported no changed paths' }
24
24
  }
25
25
 
@@ -29,13 +29,23 @@ export async function commitAutoFixes(opts: CommitOptions): Promise<CommitOutcom
29
29
 
30
30
  const spawnGit = opts.spawnGit ?? defaultSpawnGit
31
31
 
32
+ const filter = await filterCommittable(spawnGit, opts.cwd, requested)
33
+ if (filter.kind === 'failed') return filter
34
+ const pathsStaged = filter.paths
35
+ if (pathsStaged.length === 0) {
36
+ return {
37
+ kind: 'skipped',
38
+ reason: `all changed path(s) are gitignored or untracked-and-ignored (${requested.join(', ')})`,
39
+ }
40
+ }
41
+
32
42
  const add = await spawnGit(['add', '--', ...pathsStaged], opts.cwd)
33
43
  if (add.exitCode !== 0) {
34
44
  return { kind: 'failed', reason: `git add failed: ${add.stderr.trim() || `exit ${add.exitCode}`}` }
35
45
  }
36
46
 
37
47
  const message = buildCommitMessage(opts.attempts)
38
- const commit = await spawnGit(['commit', '-m', message], opts.cwd)
48
+ const commit = await spawnGit(['commit', '-m', message, '--only', '--', ...pathsStaged], opts.cwd)
39
49
  if (commit.exitCode !== 0) {
40
50
  return { kind: 'failed', reason: `git commit failed: ${commit.stderr.trim() || `exit ${commit.exitCode}`}` }
41
51
  }
@@ -45,6 +55,37 @@ export async function commitAutoFixes(opts: CommitOptions): Promise<CommitOutcom
45
55
  return { kind: 'committed', commitSha, pathsStaged }
46
56
  }
47
57
 
58
+ // TypeClaw-owned files like `Dockerfile` live in the "truly-ignored" gitignore
59
+ // category — they're regenerated from the CLI template on every `typeclaw
60
+ // start`, so tracking them would produce noisy "Update Dockerfile" commits.
61
+ // `commitSystemFile` in src/container/start.ts skips them silently because
62
+ // `git status --porcelain -- <ignored>` returns empty. We replicate that
63
+ // behavior here so `doctor --fix` produces the same skip semantics instead
64
+ // of failing with `git add` hints about the ignored file.
65
+ //
66
+ // A non-zero git-status exit IS NOT the same signal as 'empty stdout' — the
67
+ // former means git itself failed (broken index, malformed pathspec, etc.).
68
+ // Surface that as { kind: 'failed' } so the user sees the real cause instead
69
+ // of a misleading 'all paths are gitignored' message.
70
+ async function filterCommittable(
71
+ spawnGit: SpawnGit,
72
+ cwd: string,
73
+ paths: string[],
74
+ ): Promise<{ kind: 'ok'; paths: string[] } | { kind: 'failed'; reason: string }> {
75
+ const out: string[] = []
76
+ for (const p of paths) {
77
+ const status = await spawnGit(['status', '--porcelain', '--', p], cwd)
78
+ if (status.exitCode !== 0) {
79
+ return {
80
+ kind: 'failed',
81
+ reason: `git status failed for ${p}: ${status.stderr.trim() || `exit ${status.exitCode}`}`,
82
+ }
83
+ }
84
+ if (status.stdout.trim().length > 0) out.push(p)
85
+ }
86
+ return { kind: 'ok', paths: out }
87
+ }
88
+
48
89
  export function buildCommitMessage(attempts: FixAttempt[]): string {
49
90
  const successes = attempts.filter((a): a is Extract<FixAttempt, { ok: true }> => a.ok === true)
50
91
  const subject = `typeclaw doctor: auto-fix ${successes.length} issue${successes.length === 1 ? '' : 's'}`
@@ -88,7 +88,13 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
88
88
  const ws = new WebSocket(url)
89
89
  try {
90
90
  await new Promise<void>((resolve, reject) => {
91
+ // `timer` is declared up front so `cleanup` can reference it without
92
+ // hitting the TDZ if the WS fires a synchronous error event during
93
+ // addEventListener (theoretical, but const-after-cleanup-definition
94
+ // would throw ReferenceError instead of being the intended no-op).
95
+ let timer: ReturnType<typeof setTimeout> | undefined
91
96
  const cleanup = () => {
97
+ if (timer !== undefined) clearTimeout(timer)
92
98
  ws.removeEventListener('open', onOpen)
93
99
  ws.removeEventListener('error', onError)
94
100
  }
@@ -100,6 +106,19 @@ async function dial(opts: PluginBridgeOptions): Promise<DialResult> {
100
106
  cleanup()
101
107
  reject(err instanceof Error ? err : new Error(`failed to connect to ${url}`))
102
108
  }
109
+ // Bun's WebSocket has no built-in connect timeout. Without this, a TCP
110
+ // handshake that completes but never produces an Upgrade response
111
+ // (e.g. a wedged docker/orbstack port-forward) leaves the WS stuck in
112
+ // CONNECTING forever — neither 'open' nor 'error' ever fires, and
113
+ // `typeclaw doctor` hangs. The per-request timeout downstream doesn't
114
+ // help because we never reach it.
115
+ timer = setTimeout(() => {
116
+ cleanup()
117
+ try {
118
+ ws.close()
119
+ } catch {}
120
+ reject(new Error(`websocket connect timeout after ${timeoutMs}ms`))
121
+ }, timeoutMs)
103
122
  ws.addEventListener('open', onOpen, { once: true })
104
123
  ws.addEventListener('error', onError, { once: true })
105
124
  })
@@ -132,6 +132,44 @@ export const NETWORK_BLOCK_IPV6_NETS = ['fc00::/7', 'fe80::/10', 'ff00::/8', '::
132
132
  // `set -eu` propagates rule-install failures up to PID 1 exit, which kills
133
133
  // the container. Failing closed is the right thing: an unenforced
134
134
  // blockInternal=true is worse than blockInternal=false.
135
+ //
136
+ // Carve-out ordering is load-bearing. iptables OUTPUT is first-match-wins,
137
+ // and we use -A (append). So the order written into the shim is the order
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
142
+ // nameserver) — gated on TYPECLAW_NETWORK_AUTO_ALLOW_RESOLVERS=1
143
+ // 4. user-supplied allowlist ACCEPT (wholesale: -d <cidr>) — driven by
144
+ // 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
+ //
148
+ // The resolver carve-out reads /etc/resolv.conf inside the container, NOT
149
+ // on the host. Docker propagates the host's resolver into the container by
150
+ // default (Docker Desktop and OrbStack rewrite it to the embedded DNS
151
+ // proxy at 127.0.0.11; Docker on Linux copies the host's resolv.conf
152
+ // verbatim unless --dns is passed). On Docker Desktop / OrbStack the
153
+ // nameserver is loopback and the rule is a no-op (loopback is already
154
+ // ACCEPT'd by rule 1). On native Linux + EC2/GCE/Azure VMs, the
155
+ // nameserver is the VPC resolver inside RFC1918 — exactly the case this
156
+ // carve-out targets.
157
+ //
158
+ // `awk '/^nameserver/{print $2}'` extracts only the IP, skipping
159
+ // comments, `search`, `options`, and malformed lines. We don't validate
160
+ // the IP further: a malformed nameserver line would have crashed glibc's
161
+ // resolver long before the shim ran, so we trust resolv.conf's contents.
162
+ // IPv6 nameservers (rare in practice, never the case on EC2/GCE/Azure)
163
+ // are skipped by `grep -v ':'` to avoid feeding a v6 address to iptables.
164
+ // `iptables -C` (check) is intentionally NOT used to dedupe — duplicate
165
+ // ACCEPT rules are harmless (still first-match-wins), and the check
166
+ // flag's exit code is hard to reason about under `set -e`.
167
+ //
168
+ // The user-supplied allowlist (TYPECLAW_NETWORK_ALLOW) is splat on
169
+ // commas. Each entry is fed directly to iptables -d, which accepts both
170
+ // bare IPs and CIDR notation. Validation already happened in config.ts at
171
+ // parse time, so the shim trusts the env. Empty env → loop body never
172
+ // runs, zero ACCEPT rules added.
135
173
  export function buildEntrypointShim(): string {
136
174
  const ipv4Rules = NETWORK_BLOCK_IPV4_NETS.map(
137
175
  (net) => `iptables -A OUTPUT -d ${net} -j REJECT --reject-with icmp-port-unreachable`,
@@ -162,6 +200,26 @@ if [ -n "\${hostd_port:-}" ]; then
162
200
  iptables -A OUTPUT -p tcp -d "$host_gw_ip" --dport "$hostd_port" -j ACCEPT
163
201
  fi
164
202
  fi
203
+
204
+ # Resolver carve-out: parse /etc/resolv.conf nameservers and ACCEPT
205
+ # udp+tcp dport 53 to each. Gated on TYPECLAW_NETWORK_AUTO_ALLOW_RESOLVERS=1.
206
+ if [ "\${TYPECLAW_NETWORK_AUTO_ALLOW_RESOLVERS:-0}" = "1" ] && [ -r /etc/resolv.conf ]; then
207
+ for ns in $(awk '/^[[:space:]]*nameserver[[:space:]]+/{print $2}' /etc/resolv.conf | grep -v ':' || true); do
208
+ iptables -A OUTPUT -p udp -d "$ns" --dport 53 -j ACCEPT
209
+ iptables -A OUTPUT -p tcp -d "$ns" --dport 53 -j ACCEPT
210
+ done
211
+ fi
212
+
213
+ # User-supplied allowlist carve-out: comma-separated CIDRs/IPs from
214
+ # TYPECLAW_NETWORK_ALLOW. Already validated at config-parse time.
215
+ if [ -n "\${TYPECLAW_NETWORK_ALLOW:-}" ]; then
216
+ IFS=','
217
+ for cidr in $TYPECLAW_NETWORK_ALLOW; do
218
+ [ -z "$cidr" ] && continue
219
+ iptables -A OUTPUT -d "$cidr" -j ACCEPT
220
+ done
221
+ unset IFS
222
+ fi
165
223
  ${ipv4Rules.join('\n')}
166
224
 
167
225
  ip6tables -A OUTPUT -o lo -j ACCEPT
@@ -708,13 +708,26 @@
708
708
  },
709
709
  "network": {
710
710
  "default": {
711
- "blockInternal": true
711
+ "blockInternal": true,
712
+ "autoAllowResolvers": true,
713
+ "allow": []
712
714
  },
713
715
  "type": "object",
714
716
  "properties": {
715
717
  "blockInternal": {
716
718
  "default": true,
717
719
  "type": "boolean"
720
+ },
721
+ "autoAllowResolvers": {
722
+ "default": true,
723
+ "type": "boolean"
724
+ },
725
+ "allow": {
726
+ "default": [],
727
+ "type": "array",
728
+ "items": {
729
+ "type": "string"
730
+ }
718
731
  }
719
732
  }
720
733
  },