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 +1 -1
- package/src/config/config.ts +47 -1
- package/src/container/index.ts +2 -0
- package/src/container/start.ts +8 -1
- package/src/doctor/checks.ts +7 -27
- package/src/doctor/commit.ts +44 -3
- package/src/doctor/plugin-bridge.ts +19 -0
- package/src/init/dockerfile.ts +58 -0
- package/typeclaw.schema.json +14 -1
package/package.json
CHANGED
package/src/config/config.ts
CHANGED
|
@@ -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
|
|
package/src/container/index.ts
CHANGED
package/src/container/start.ts
CHANGED
|
@@ -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) {
|
package/src/doctor/checks.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from 'node:fs'
|
|
2
|
-
import { readFile
|
|
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
|
|
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
|
|
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
|
}
|
package/src/doctor/commit.ts
CHANGED
|
@@ -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
|
|
22
|
-
if (
|
|
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
|
})
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -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
|
package/typeclaw.schema.json
CHANGED
|
@@ -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
|
},
|