From f4c6c0aec49e59f327a7b5f1b0fe9b61b970939d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 29 May 2026 09:44:21 +0100 Subject: [PATCH] refactor: extract net policy package --- ...work-runtime-boundary-critical-quality.yml | 1 + ...etwork-ssrf-boundary-critical-security.yml | 2 +- .github/workflows/codeql-critical-quality.yml | 3 +- docs/security/network-proxy.md | 2 +- packages/net-policy/package.json | 44 ++ packages/net-policy/src/index.ts | 4 + packages/net-policy/src/ip-test-fixtures.ts | 1 + packages/net-policy/src/ip.test.ts | 187 ++++++++ packages/net-policy/src/ip.ts | 423 ++++++++++++++++++ packages/net-policy/src/ipv4.test.ts | 35 ++ packages/net-policy/src/ipv4.ts | 16 + .../src/redact-sensitive-url.test.ts | 85 ++++ .../net-policy/src/redact-sensitive-url.ts | 82 ++++ packages/net-policy/src/url-userinfo.ts | 13 + packages/sdk/package.json | 3 + packages/sdk/src/transport.ts | 2 +- pnpm-lock.yaml | 12 +- scripts/run-node-watch-paths.mjs | 6 +- src/agents/agent-bundle-mcp-runtime.ts | 2 +- src/agents/mcp-http.ts | 2 +- src/agents/provider-transport-fetch.ts | 10 +- src/agents/skills-source-install.ts | 2 +- src/channels/account-snapshot-fields.ts | 2 +- src/commands/channels/status.ts | 2 +- src/commands/configure.gateway.ts | 2 +- src/commands/status.scan.shared.ts | 2 +- src/config/redact-snapshot.ts | 6 +- src/config/schema-base.ts | 2 +- src/config/schema.base.generated.test.ts | 2 +- src/config/schema.hints.test.ts | 2 +- src/config/schema.hints.ts | 8 +- src/config/schema.test.ts | 2 +- src/config/validation.ts | 2 +- src/gateway/call.ts | 4 +- src/gateway/connection-details.ts | 2 +- src/gateway/gateway-config-prompts.shared.ts | 2 +- src/gateway/net.ts | 14 +- src/gateway/operator-approvals-client.ts | 2 +- src/gateway/origin-check.ts | 2 +- .../net/configured-local-origin-bypass.ts | 2 +- src/infra/net/proxy/proxy-lifecycle.ts | 2 +- src/infra/net/ssrf.test.ts | 2 +- src/infra/net/ssrf.ts | 4 +- src/infra/tailnet.ts | 2 +- src/infra/watch-node.test.ts | 3 + src/logging/diagnostic-support-redaction.ts | 2 +- src/media/parse.ts | 4 +- src/pairing/setup-code.ts | 16 +- src/plugins/git-install.test.ts | 2 +- src/plugins/git-install.ts | 2 +- src/plugins/marketplace.ts | 2 +- src/wizard/setup.gateway-config.ts | 2 +- test/vitest/vitest.shared.config.ts | 26 ++ tsconfig.json | 8 + tsdown.config.ts | 25 ++ 55 files changed, 1034 insertions(+), 65 deletions(-) create mode 100644 packages/net-policy/package.json create mode 100644 packages/net-policy/src/index.ts create mode 100644 packages/net-policy/src/ip-test-fixtures.ts create mode 100644 packages/net-policy/src/ip.test.ts create mode 100644 packages/net-policy/src/ip.ts create mode 100644 packages/net-policy/src/ipv4.test.ts create mode 100644 packages/net-policy/src/ipv4.ts create mode 100644 packages/net-policy/src/redact-sensitive-url.test.ts create mode 100644 packages/net-policy/src/redact-sensitive-url.ts create mode 100644 packages/net-policy/src/url-userinfo.ts diff --git a/.github/codeql/codeql-network-runtime-boundary-critical-quality.yml b/.github/codeql/codeql-network-runtime-boundary-critical-quality.yml index 13afe6264f25..441a795b9329 100644 --- a/.github/codeql/codeql-network-runtime-boundary-critical-quality.yml +++ b/.github/codeql/codeql-network-runtime-boundary-critical-quality.yml @@ -9,6 +9,7 @@ queries: paths: - src - extensions + - packages/net-policy/src paths-ignore: - "**/node_modules" diff --git a/.github/codeql/codeql-network-ssrf-boundary-critical-security.yml b/.github/codeql/codeql-network-ssrf-boundary-critical-security.yml index 0e5fe784a02c..32e817b4f8c6 100644 --- a/.github/codeql/codeql-network-ssrf-boundary-critical-security.yml +++ b/.github/codeql/codeql-network-ssrf-boundary-critical-security.yml @@ -15,7 +15,6 @@ query-filters: paths: - src/infra/net - - src/shared/net - src/agents/tools/web-fetch.ts - src/agents/tools/web-guarded-fetch.ts - src/agents/tools/web-shared.ts @@ -23,6 +22,7 @@ paths: - src/web-fetch - src/web/provider-runtime-shared.ts - packages/memory-host-sdk/src/host/ssrf-policy.ts + - packages/net-policy/src paths-ignore: - "**/node_modules" diff --git a/.github/workflows/codeql-critical-quality.yml b/.github/workflows/codeql-critical-quality.yml index eaff98effee3..79ed611fa931 100644 --- a/.github/workflows/codeql-critical-quality.yml +++ b/.github/workflows/codeql-critical-quality.yml @@ -33,6 +33,7 @@ on: - "packages/plugin-package-contract/**" - "packages/plugin-sdk/**" - "packages/memory-host-sdk/**" + - "packages/net-policy/**" - "src/*.ts" - "src/**/*.ts" - "src/config/**" @@ -301,7 +302,7 @@ jobs: esac case "${file}" in - src/*.ts|src/**/*.ts|extensions/*.ts|extensions/**/*.ts) + src/*.ts|src/**/*.ts|extensions/*.ts|extensions/**/*.ts|packages/net-policy/src/*|packages/net-policy/src/**/*) network_runtime=true ;; esac diff --git a/docs/security/network-proxy.md b/docs/security/network-proxy.md index 25deb5df6178..997a3ab078a1 100644 --- a/docs/security/network-proxy.md +++ b/docs/security/network-proxy.md @@ -134,7 +134,7 @@ Configure the proxy to: Use this denylist as the starting point for any forward proxy, firewall, or egress policy. -OpenClaw application-level classifier logic lives in `src/infra/net/ssrf.ts` and `src/shared/net/ip.ts`. The relevant parity hooks are `BLOCKED_HOSTNAMES`, `BLOCKED_IPV4_SPECIAL_USE_RANGES`, `BLOCKED_IPV6_SPECIAL_USE_RANGES`, `RFC2544_BENCHMARK_PREFIX`, and the embedded IPv4 sentinel handling for NAT64, 6to4, Teredo, ISATAP, and IPv4-mapped forms. Those files are useful references when maintaining an external proxy policy, but OpenClaw does not automatically export or enforce those rules in your proxy. +OpenClaw application-level classifier logic lives in `src/infra/net/ssrf.ts` and `packages/net-policy/src/ip.ts`. The relevant parity hooks are `BLOCKED_HOSTNAMES`, `BLOCKED_IPV4_SPECIAL_USE_RANGES`, `BLOCKED_IPV6_SPECIAL_USE_RANGES`, `RFC2544_BENCHMARK_PREFIX`, and the embedded IPv4 sentinel handling for NAT64, 6to4, Teredo, ISATAP, and IPv4-mapped forms. Those files are useful references when maintaining an external proxy policy, but OpenClaw does not automatically export or enforce those rules in your proxy. | Range or host | Why to block | | ------------------------------------------------------------------------------------ | ---------------------------------------------------- | diff --git a/packages/net-policy/package.json b/packages/net-policy/package.json new file mode 100644 index 000000000000..e5849fb09223 --- /dev/null +++ b/packages/net-policy/package.json @@ -0,0 +1,44 @@ +{ + "name": "@openclaw/net-policy", + "version": "0.0.0-private", + "private": true, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + }, + "./ip": { + "types": "./dist/ip.d.mts", + "import": "./dist/ip.mjs", + "default": "./dist/ip.mjs" + }, + "./ipv4": { + "types": "./dist/ipv4.d.mts", + "import": "./dist/ipv4.mjs", + "default": "./dist/ipv4.mjs" + }, + "./redact-sensitive-url": { + "types": "./dist/redact-sensitive-url.d.mts", + "import": "./dist/redact-sensitive-url.mjs", + "default": "./dist/redact-sensitive-url.mjs" + }, + "./url-userinfo": { + "types": "./dist/url-userinfo.d.mts", + "import": "./dist/url-userinfo.mjs", + "default": "./dist/url-userinfo.mjs" + } + }, + "scripts": { + "build": "tsdown src/index.ts src/ip.ts src/ipv4.ts src/redact-sensitive-url.ts src/url-userinfo.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + }, + "dependencies": { + "ipaddr.js": "2.4.0" + } +} diff --git a/packages/net-policy/src/index.ts b/packages/net-policy/src/index.ts new file mode 100644 index 000000000000..d535fe828bec --- /dev/null +++ b/packages/net-policy/src/index.ts @@ -0,0 +1,4 @@ +export * from "./ip.js"; +export * from "./ipv4.js"; +export * from "./redact-sensitive-url.js"; +export * from "./url-userinfo.js"; diff --git a/packages/net-policy/src/ip-test-fixtures.ts b/packages/net-policy/src/ip-test-fixtures.ts new file mode 100644 index 000000000000..d2fa9cd5436c --- /dev/null +++ b/packages/net-policy/src/ip-test-fixtures.ts @@ -0,0 +1 @@ +export const blockedIpv6MulticastLiterals = ["ff02::1", "ff05::1:3", "[ff02::1]"] as const; diff --git a/packages/net-policy/src/ip.test.ts b/packages/net-policy/src/ip.test.ts new file mode 100644 index 000000000000..49deee393354 --- /dev/null +++ b/packages/net-policy/src/ip.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import { blockedIpv6MulticastLiterals } from "./ip-test-fixtures.js"; +import { + extractEmbeddedIpv4FromIpv6, + isBlockedSpecialUseIpv4Address, + isBlockedSpecialUseIpv6Address, + isCanonicalDottedDecimalIPv4, + isCarrierGradeNatIpv4Address, + isCloudMetadataIpAddress, + isIpInCidr, + isIpv4Address, + isIpv6Address, + isLegacyIpv4Literal, + isLinkLocalIpAddress, + isLoopbackIpAddress, + isPrivateOrLoopbackIpAddress, + isRfc1918Ipv4Address, + normalizeIpAddress, + parseCanonicalIpAddress, + parseLooseIpAddress, +} from "./ip.js"; + +describe("shared ip helpers", () => { + it("distinguishes canonical dotted IPv4 from legacy forms", () => { + expect(isCanonicalDottedDecimalIPv4("127.0.0.1")).toBe(true); + expect(isCanonicalDottedDecimalIPv4("0177.0.0.1")).toBe(false); + expect(isLegacyIpv4Literal("0177.0.0.1")).toBe(true); + expect(isLegacyIpv4Literal("127.1")).toBe(true); + expect(isLegacyIpv4Literal("example.com")).toBe(false); + }); + + it("matches both IPv4 and IPv6 CIDRs", () => { + expect(isIpInCidr("10.42.0.59", "10.42.0.0/24")).toBe(true); + expect(isIpInCidr("10.43.0.59", "10.42.0.0/24")).toBe(false); + expect(isIpInCidr("2001:db8::1234", "2001:db8::/32")).toBe(true); + expect(isIpInCidr("2001:db9::1234", "2001:db8::/32")).toBe(false); + expect(isIpInCidr("::ffff:127.0.0.1", "127.0.0.1")).toBe(true); + expect(isIpInCidr("127.0.0.1", "::ffff:127.0.0.2")).toBe(false); + }); + + it("extracts embedded IPv4 for transition prefixes", () => { + const cases = [ + ["::ffff:127.0.0.1", "127.0.0.1"], + ["::127.0.0.1", "127.0.0.1"], + ["64:ff9b::8.8.8.8", "8.8.8.8"], + ["64:ff9b:1::10.0.0.1", "10.0.0.1"], + ["2002:0808:0808::", "8.8.8.8"], + ["2001::f7f7:f7f7", "8.8.8.8"], + ["2001:4860:1::5efe:7f00:1", "127.0.0.1"], + ] as const; + for (const [ipv6Literal, expectedIpv4] of cases) { + const parsed = parseCanonicalIpAddress(ipv6Literal); + expect(parsed?.kind(), ipv6Literal).toBe("ipv6"); + if (!parsed || !isIpv6Address(parsed)) { + continue; + } + expect(extractEmbeddedIpv4FromIpv6(parsed)?.toString(), ipv6Literal).toBe(expectedIpv4); + } + }); + + it("treats blocked IPv6 classes as private/internal", () => { + expect(isPrivateOrLoopbackIpAddress("fec0::1")).toBe(true); + expect(isPrivateOrLoopbackIpAddress("2001:db8::1")).toBe(true); + expect(isPrivateOrLoopbackIpAddress("2001:2::1")).toBe(true); + expect(isPrivateOrLoopbackIpAddress("100::1")).toBe(true); + expect(isPrivateOrLoopbackIpAddress("2001:20::1")).toBe(true); + for (const literal of blockedIpv6MulticastLiterals) { + expect(isPrivateOrLoopbackIpAddress(literal)).toBe(true); + } + expect(isPrivateOrLoopbackIpAddress("2001:4860:4860::8888")).toBe(false); + }); + + it("normalizes canonical IP strings and loopback detection", () => { + expect(normalizeIpAddress("[::FFFF:127.0.0.1]")).toBe("127.0.0.1"); + expect(normalizeIpAddress(" [2001:DB8::1] ")).toBe("2001:db8::1"); + expect(isLoopbackIpAddress("::ffff:127.0.0.1")).toBe(true); + expect(isLoopbackIpAddress("198.18.0.1")).toBe(false); + }); + + it("detects link-local addresses without treating normal private ranges as link-local", () => { + expect(isLinkLocalIpAddress("169.254.169.254")).toBe(true); + expect(isLinkLocalIpAddress("::ffff:169.254.169.254")).toBe(true); + expect(isLinkLocalIpAddress("2852039166")).toBe(true); + expect(isLinkLocalIpAddress("0xa9fea9fe")).toBe(true); + expect(isLinkLocalIpAddress("0xa9.0xfe.0xa9.0xfe")).toBe(true); + expect(isLinkLocalIpAddress("64:ff9b::169.254.169.254")).toBe(true); + expect(isLinkLocalIpAddress("64:ff9b:1::a9fe:a9fe")).toBe(true); + expect(isLinkLocalIpAddress("2002:a9fe:a9fe::")).toBe(true); + expect(isLinkLocalIpAddress("fe80::1%lo0")).toBe(true); + expect(isLinkLocalIpAddress("[fe80::1]")).toBe(true); + expect(isLinkLocalIpAddress("10.0.0.5")).toBe(false); + expect(isLinkLocalIpAddress("127.0.0.1")).toBe(false); + expect(isLinkLocalIpAddress("fd00::1")).toBe(false); + }); + + it("detects known non-link-local cloud metadata IPs", () => { + expect(isCloudMetadataIpAddress("100.100.100.200")).toBe(true); + expect(isCloudMetadataIpAddress("::ffff:100.100.100.200")).toBe(true); + expect(isCloudMetadataIpAddress("64:ff9b::100.100.100.200")).toBe(true); + expect(isCloudMetadataIpAddress("64:ff9b:1::6464:64c8")).toBe(true); + expect(isCloudMetadataIpAddress("2002:6464:64c8::")).toBe(true); + expect(isCloudMetadataIpAddress("1684301000")).toBe(true); + expect(isCloudMetadataIpAddress("fd00:ec2::254")).toBe(true); + expect(isCloudMetadataIpAddress("[fd00:ec2::254]")).toBe(true); + expect(isCloudMetadataIpAddress("100.100.100.201")).toBe(false); + expect(isCloudMetadataIpAddress("169.254.169.254")).toBe(false); + expect(isCloudMetadataIpAddress("fd00::1")).toBe(false); + }); + + it("parses loose legacy IPv4 literals that canonical parsing rejects", () => { + expect(parseCanonicalIpAddress("0177.0.0.1")).toBeUndefined(); + expect(parseLooseIpAddress("0177.0.0.1")?.toString()).toBe("127.0.0.1"); + expect(parseLooseIpAddress("[::1]")?.toString()).toBe("::1"); + }); + + it("classifies RFC1918 and carrier-grade-nat IPv4 ranges", () => { + expect(isRfc1918Ipv4Address("10.42.0.59")).toBe(true); + expect(isRfc1918Ipv4Address("100.64.0.1")).toBe(false); + expect(isCarrierGradeNatIpv4Address("100.64.0.1")).toBe(true); + expect(isCarrierGradeNatIpv4Address("10.42.0.59")).toBe(false); + }); + + it("blocks special-use IPv4 ranges while allowing optional RFC2544 benchmark addresses", () => { + const loopback = parseCanonicalIpAddress("127.0.0.1"); + const benchmark = parseCanonicalIpAddress("198.18.0.1"); + + expect(loopback?.kind()).toBe("ipv4"); + expect(benchmark?.kind()).toBe("ipv4"); + if (!loopback || !isIpv4Address(loopback) || !benchmark || !isIpv4Address(benchmark)) { + throw new Error("expected ipv4 fixtures"); + } + + expect(isBlockedSpecialUseIpv4Address(loopback)).toBe(true); + expect(isBlockedSpecialUseIpv4Address(benchmark)).toBe(true); + expect(isBlockedSpecialUseIpv4Address(benchmark, { allowRfc2544BenchmarkRange: true })).toBe( + false, + ); + }); + + it("blocks IPv6 unique-local addresses by default and exempts them on opt-in (#74351)", () => { + // fc00::/7 is the IPv6 ULA range. Sing-box / Clash / Surge fake-ip + // proxies resolve foreign domains here, alongside the IPv4 198.18.0.0/15 + // benchmark range. Operators using those proxies need both ranges + // exempted to keep web_fetch working. + const ula = parseCanonicalIpAddress("fc00::1"); + expect(ula?.kind()).toBe("ipv6"); + if (!ula || !isIpv6Address(ula)) { + throw new Error("expected ipv6 fixture"); + } + + // Default policy (no options) must continue to block the ULA range. + expect(isBlockedSpecialUseIpv6Address(ula)).toBe(true); + expect(isBlockedSpecialUseIpv6Address(ula, {})).toBe(true); + expect(isBlockedSpecialUseIpv6Address(ula, { allowUniqueLocalRange: false })).toBe(true); + + // Opt-in flag — the only path the SSRF policy uses to thread fake-ip + // proxy intent through to the address classifier. + expect(isBlockedSpecialUseIpv6Address(ula, { allowUniqueLocalRange: true })).toBe(false); + }); + + it("opt-in unique-local exemption does NOT bleed into other special-use IPv6 ranges (#74351)", () => { + // The exemption must be scoped: loopback (::1), unspecified (::), and + // multicast (ff00::/8) all stay blocked even when `allowUniqueLocalRange` + // is set, otherwise the flag silently widens the SSRF escape hatch + // beyond what operators opted into. + const loopback = parseCanonicalIpAddress("::1"); + const multicast = parseCanonicalIpAddress("ff02::1"); + const siteLocal = parseCanonicalIpAddress("fec0::1"); // deprecated fec0::/10 + + if ( + !loopback || + !isIpv6Address(loopback) || + !multicast || + !isIpv6Address(multicast) || + !siteLocal || + !isIpv6Address(siteLocal) + ) { + throw new Error("expected ipv6 fixtures"); + } + + for (const options of [{}, { allowUniqueLocalRange: true }] as const) { + expect(isBlockedSpecialUseIpv6Address(loopback, options)).toBe(true); + expect(isBlockedSpecialUseIpv6Address(multicast, options)).toBe(true); + expect(isBlockedSpecialUseIpv6Address(siteLocal, options)).toBe(true); + } + }); +}); diff --git a/packages/net-policy/src/ip.ts b/packages/net-policy/src/ip.ts new file mode 100644 index 000000000000..d4dff3ee61fe --- /dev/null +++ b/packages/net-policy/src/ip.ts @@ -0,0 +1,423 @@ +import ipaddr from "ipaddr.js"; + +function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +export type ParsedIpAddress = ipaddr.IPv4 | ipaddr.IPv6; +type Ipv4Range = ReturnType; +type Ipv6Range = ReturnType; +type BlockedIpv6Range = Ipv6Range | "discard"; + +const BLOCKED_IPV4_SPECIAL_USE_RANGES = new Set([ + "unspecified", + "broadcast", + "multicast", + "linkLocal", + "loopback", + "carrierGradeNat", + "private", + "reserved", +]); + +const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set([ + "loopback", + "private", + "linkLocal", + "carrierGradeNat", +]); + +const BLOCKED_IPV6_SPECIAL_USE_RANGES = new Set([ + "unspecified", + "loopback", + "linkLocal", + "uniqueLocal", + "multicast", + "reserved", + "benchmarking", + "discard", + "orchid2", +]); +const RFC2544_BENCHMARK_PREFIX: [ipaddr.IPv4, number] = [ipaddr.IPv4.parse("198.18.0.0"), 15]; +const CLOUD_METADATA_IP_ADDRESSES = new Set(["100.100.100.200", "fd00:ec2::254"]); +export type Ipv4SpecialUseBlockOptions = { + allowRfc2544BenchmarkRange?: boolean; +}; + +/** + * Per-call exemptions for `isBlockedSpecialUseIpv6Address`. Mirror of + * {@link Ipv4SpecialUseBlockOptions} for the IPv6 side. Currently only + * `allowUniqueLocalRange` is exposed (#74351); other reserved IPv6 ranges stay + * unconditionally blocked because they have no documented fake-ip / proxy + * use case. + */ +export type Ipv6SpecialUseBlockOptions = { + /** + * When true, exempt addresses in `fc00::/7` (the IPv6 Unique Local Address + * block, RFC 4193) from the SSRF private-IP block. Sing-box / Clash / Surge + * fake-ip implementations resolve foreign domains to ULA addresses + * alongside RFC 2544 benchmark IPv4 addresses, and operators using those + * proxy stacks need both ranges exempted to keep `web_fetch` working. + */ + allowUniqueLocalRange?: boolean; +}; + +const EMBEDDED_IPV4_SENTINEL_RULES: Array<{ + matches: (parts: number[]) => boolean; + toHextets: (parts: number[]) => [high: number, low: number]; +}> = [ + { + // IPv4-compatible form ::w.x.y.z (deprecated, but still seen in parser edge-cases). + matches: (parts) => + parts[0] === 0 && + parts[1] === 0 && + parts[2] === 0 && + parts[3] === 0 && + parts[4] === 0 && + parts[5] === 0, + toHextets: (parts) => [parts[6], parts[7]], + }, + { + // NAT64 local-use prefix: 64:ff9b:1::/48. + matches: (parts) => + parts[0] === 0x0064 && + parts[1] === 0xff9b && + parts[2] === 0x0001 && + parts[3] === 0 && + parts[4] === 0 && + parts[5] === 0, + toHextets: (parts) => [parts[6], parts[7]], + }, + { + // 6to4 prefix: 2002::/16 (IPv4 lives in hextets 1..2). + matches: (parts) => parts[0] === 0x2002, + toHextets: (parts) => [parts[1], parts[2]], + }, + { + // Teredo prefix: 2001:0000::/32 (client IPv4 XOR 0xffff in hextets 6..7). + matches: (parts) => parts[0] === 0x2001 && parts[1] === 0x0000, + toHextets: (parts) => [parts[6] ^ 0xffff, parts[7] ^ 0xffff], + }, + { + // ISATAP IID marker: ....:0000:5efe:w.x.y.z with u/g bits allowed in hextet 4. + matches: (parts) => (parts[4] & 0xfcff) === 0 && parts[5] === 0x5efe, + toHextets: (parts) => [parts[6], parts[7]], + }, +]; + +function stripIpv6Brackets(value: string): string { + if (value.startsWith("[") && value.endsWith("]")) { + return value.slice(1, -1); + } + return value; +} + +function isNumericIpv4LiteralPart(value: string): boolean { + return /^[0-9]+$/.test(value) || /^0x[0-9a-f]+$/i.test(value); +} + +function parseIpv6WithEmbeddedIpv4(raw: string): ipaddr.IPv6 | undefined { + if (!raw.includes(":") || !raw.includes(".")) { + return undefined; + } + const match = /^(.*:)([^:%]+(?:\.[^:%]+){3})(%[0-9A-Za-z]+)?$/i.exec(raw); + if (!match) { + return undefined; + } + const [, prefix, embeddedIpv4, zoneSuffix = ""] = match; + if (!ipaddr.IPv4.isValidFourPartDecimal(embeddedIpv4)) { + return undefined; + } + const octets = embeddedIpv4.split(".").map((part) => Number.parseInt(part, 10)); + const high = ((octets[0] << 8) | octets[1]).toString(16); + const low = ((octets[2] << 8) | octets[3]).toString(16); + const normalizedIpv6 = `${prefix}${high}:${low}${zoneSuffix}`; + if (!ipaddr.IPv6.isValid(normalizedIpv6)) { + return undefined; + } + return ipaddr.IPv6.parse(normalizedIpv6); +} + +export function isIpv4Address(address: ParsedIpAddress): address is ipaddr.IPv4 { + return address.kind() === "ipv4"; +} + +export function isIpv6Address(address: ParsedIpAddress): address is ipaddr.IPv6 { + return address.kind() === "ipv6"; +} + +function normalizeIpv4MappedAddress(address: ParsedIpAddress): ParsedIpAddress { + if (!isIpv6Address(address)) { + return address; + } + if (!address.isIPv4MappedAddress()) { + return address; + } + return address.toIPv4Address(); +} + +function normalizeIpParseInput(raw: string | undefined): string | undefined { + const trimmed = normalizeOptionalString(raw); + if (!trimmed) { + return undefined; + } + return stripIpv6Brackets(trimmed); +} + +export function parseCanonicalIpAddress(raw: string | undefined): ParsedIpAddress | undefined { + const normalized = normalizeIpParseInput(raw); + if (!normalized) { + return undefined; + } + if (ipaddr.IPv4.isValid(normalized)) { + if (!ipaddr.IPv4.isValidFourPartDecimal(normalized)) { + return undefined; + } + return ipaddr.IPv4.parse(normalized); + } + if (ipaddr.IPv6.isValid(normalized)) { + return ipaddr.IPv6.parse(normalized); + } + return parseIpv6WithEmbeddedIpv4(normalized); +} + +export function parseLooseIpAddress(raw: string | undefined): ParsedIpAddress | undefined { + const normalized = normalizeIpParseInput(raw); + if (!normalized) { + return undefined; + } + if (ipaddr.isValid(normalized)) { + return ipaddr.parse(normalized); + } + return parseIpv6WithEmbeddedIpv4(normalized); +} + +export function normalizeIpAddress(raw: string | undefined): string | undefined { + const parsed = parseCanonicalIpAddress(raw); + if (!parsed) { + return undefined; + } + const normalized = normalizeIpv4MappedAddress(parsed); + return normalizeLowercaseStringOrEmpty(normalized.toString()); +} + +export function isCanonicalDottedDecimalIPv4(raw: string | undefined): boolean { + const trimmed = normalizeOptionalString(raw); + if (!trimmed) { + return false; + } + const normalized = stripIpv6Brackets(trimmed); + if (!normalized) { + return false; + } + return ipaddr.IPv4.isValidFourPartDecimal(normalized); +} + +export function isLegacyIpv4Literal(raw: string | undefined): boolean { + const trimmed = normalizeOptionalString(raw); + if (!trimmed) { + return false; + } + const normalized = stripIpv6Brackets(trimmed); + if (!normalized || normalized.includes(":")) { + return false; + } + if (isCanonicalDottedDecimalIPv4(normalized)) { + return false; + } + const parts = normalized.split("."); + if (parts.length === 0 || parts.length > 4) { + return false; + } + if (parts.some((part) => part.length === 0)) { + return false; + } + if (!parts.every((part) => isNumericIpv4LiteralPart(part))) { + return false; + } + return true; +} + +export function isLoopbackIpAddress(raw: string | undefined): boolean { + const parsed = parseCanonicalIpAddress(raw); + if (!parsed) { + return false; + } + const normalized = normalizeIpv4MappedAddress(parsed); + return normalized.range() === "loopback"; +} + +export function isLinkLocalIpAddress(raw: string | undefined): boolean { + const parsed = parseLooseIpAddress(raw); + if (!parsed) { + return false; + } + const normalized = normalizeIpv4MappedAddress(parsed); + if (isIpv4Address(normalized)) { + return normalized.range() === "linkLocal"; + } + const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(normalized); + if (embeddedIpv4?.range() === "linkLocal") { + return true; + } + return normalized.range() === "linkLocal"; +} + +export function isCloudMetadataIpAddress(raw: string | undefined): boolean { + const parsed = parseLooseIpAddress(raw); + if (!parsed) { + return false; + } + const normalized = normalizeIpv4MappedAddress(parsed); + if (isIpv6Address(normalized)) { + const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(normalized); + if (embeddedIpv4 && CLOUD_METADATA_IP_ADDRESSES.has(embeddedIpv4.toString())) { + return true; + } + } + return CLOUD_METADATA_IP_ADDRESSES.has(normalized.toString()); +} + +export function isPrivateOrLoopbackIpAddress(raw: string | undefined): boolean { + const parsed = parseCanonicalIpAddress(raw); + if (!parsed) { + return false; + } + const normalized = normalizeIpv4MappedAddress(parsed); + if (isIpv4Address(normalized)) { + return PRIVATE_OR_LOOPBACK_IPV4_RANGES.has(normalized.range()); + } + return isBlockedSpecialUseIpv6Address(normalized); +} + +export function isBlockedSpecialUseIpv6Address( + address: ipaddr.IPv6, + options: Ipv6SpecialUseBlockOptions = {}, +): boolean { + // ipaddr.js returns "discard" at runtime for 100::/64, but its published + // TypeScript IPv6Range union omits that literal. + const range = address.range() as BlockedIpv6Range; + if (range === "uniqueLocal" && options.allowUniqueLocalRange === true) { + // Operators running fake-ip proxy stacks (sing-box, Clash, Surge) opt in + // to fc00::/7 reaching the network — same intent as + // `allowRfc2544BenchmarkRange` for the IPv4 side (#74351). + return false; + } + if (BLOCKED_IPV6_SPECIAL_USE_RANGES.has(range)) { + return true; + } + // ipaddr.js does not classify deprecated site-local fec0::/10 as private. + return (address.parts[0] & 0xffc0) === 0xfec0; +} + +export function isRfc1918Ipv4Address(raw: string | undefined): boolean { + const parsed = parseCanonicalIpAddress(raw); + if (!parsed || !isIpv4Address(parsed)) { + return false; + } + return parsed.range() === "private"; +} + +export function isCarrierGradeNatIpv4Address(raw: string | undefined): boolean { + const parsed = parseCanonicalIpAddress(raw); + if (!parsed || !isIpv4Address(parsed)) { + return false; + } + return parsed.range() === "carrierGradeNat"; +} + +export function isBlockedSpecialUseIpv4Address( + address: ipaddr.IPv4, + options: Ipv4SpecialUseBlockOptions = {}, +): boolean { + const inRfc2544BenchmarkRange = address.match(RFC2544_BENCHMARK_PREFIX); + if (inRfc2544BenchmarkRange && options.allowRfc2544BenchmarkRange === true) { + return false; + } + return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(address.range()) || inRfc2544BenchmarkRange; +} + +function decodeIpv4FromHextets(high: number, low: number): ipaddr.IPv4 { + const octets: [number, number, number, number] = [ + (high >>> 8) & 0xff, + high & 0xff, + (low >>> 8) & 0xff, + low & 0xff, + ]; + return ipaddr.IPv4.parse(octets.join(".")); +} + +export function extractEmbeddedIpv4FromIpv6(address: ipaddr.IPv6): ipaddr.IPv4 | undefined { + if (address.isIPv4MappedAddress()) { + return address.toIPv4Address(); + } + if (address.range() === "rfc6145") { + return decodeIpv4FromHextets(address.parts[6], address.parts[7]); + } + if (address.range() === "rfc6052") { + return decodeIpv4FromHextets(address.parts[6], address.parts[7]); + } + for (const rule of EMBEDDED_IPV4_SENTINEL_RULES) { + if (!rule.matches(address.parts)) { + continue; + } + const [high, low] = rule.toHextets(address.parts); + return decodeIpv4FromHextets(high, low); + } + return undefined; +} + +export function isIpInCidr(ip: string, cidr: string): boolean { + const normalizedIp = parseCanonicalIpAddress(ip); + if (!normalizedIp) { + return false; + } + const candidate = cidr.trim(); + if (!candidate) { + return false; + } + const comparableIp = normalizeIpv4MappedAddress(normalizedIp); + if (!candidate.includes("/")) { + const exact = parseCanonicalIpAddress(candidate); + if (!exact) { + return false; + } + const comparableExact = normalizeIpv4MappedAddress(exact); + return ( + comparableIp.kind() === comparableExact.kind() && + comparableIp.toString() === comparableExact.toString() + ); + } + + let parsedCidr: [ParsedIpAddress, number]; + try { + parsedCidr = ipaddr.parseCIDR(candidate); + } catch { + return false; + } + + const [baseAddress, prefixLength] = parsedCidr; + const comparableBase = normalizeIpv4MappedAddress(baseAddress); + if (comparableIp.kind() !== comparableBase.kind()) { + return false; + } + try { + if (isIpv4Address(comparableIp) && isIpv4Address(comparableBase)) { + return comparableIp.match([comparableBase, prefixLength]); + } + if (isIpv6Address(comparableIp) && isIpv6Address(comparableBase)) { + return comparableIp.match([comparableBase, prefixLength]); + } + return false; + } catch { + return false; + } +} diff --git a/packages/net-policy/src/ipv4.test.ts b/packages/net-policy/src/ipv4.test.ts new file mode 100644 index 000000000000..165f3f48bcd8 --- /dev/null +++ b/packages/net-policy/src/ipv4.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { validateDottedDecimalIPv4Input, validateIPv4AddressInput } from "./ipv4.js"; + +describe("net-policy/ipv4", () => { + it("requires a value for custom bind mode", () => { + expect(validateDottedDecimalIPv4Input(undefined)).toBe( + "IP address is required for custom bind mode", + ); + expect(validateDottedDecimalIPv4Input("")).toBe("IP address is required for custom bind mode"); + expect(validateDottedDecimalIPv4Input(" ")).toBe( + "Invalid IPv4 address (e.g., 192.168.1.100)", + ); + }); + + it("accepts canonical dotted-decimal ipv4 only", () => { + expect(validateDottedDecimalIPv4Input("0.0.0.0")).toBeUndefined(); + expect(validateDottedDecimalIPv4Input("192.168.1.100")).toBeUndefined(); + expect(validateDottedDecimalIPv4Input(" 192.168.1.100 ")).toBeUndefined(); + expect(validateDottedDecimalIPv4Input("0177.0.0.1")).toBe( + "Invalid IPv4 address (e.g., 192.168.1.100)", + ); + expect(validateDottedDecimalIPv4Input("[192.168.1.100]")).toBeUndefined(); + expect(validateDottedDecimalIPv4Input("127.1")).toBe( + "Invalid IPv4 address (e.g., 192.168.1.100)", + ); + expect(validateDottedDecimalIPv4Input("example.com")).toBe( + "Invalid IPv4 address (e.g., 192.168.1.100)", + ); + }); + + it("keeps the backward-compatible alias wired to the same validation", () => { + expect(validateIPv4AddressInput("192.168.1.100")).toBeUndefined(); + expect(validateIPv4AddressInput("bad-ip")).toBe("Invalid IPv4 address (e.g., 192.168.1.100)"); + }); +}); diff --git a/packages/net-policy/src/ipv4.ts b/packages/net-policy/src/ipv4.ts new file mode 100644 index 000000000000..22638783dbcd --- /dev/null +++ b/packages/net-policy/src/ipv4.ts @@ -0,0 +1,16 @@ +import { isCanonicalDottedDecimalIPv4 } from "./ip.js"; + +export function validateDottedDecimalIPv4Input(value: string | undefined): string | undefined { + if (!value) { + return "IP address is required for custom bind mode"; + } + if (isCanonicalDottedDecimalIPv4(value)) { + return undefined; + } + return "Invalid IPv4 address (e.g., 192.168.1.100)"; +} + +/** @deprecated Use validateDottedDecimalIPv4Input. */ +export function validateIPv4AddressInput(value: string | undefined): string | undefined { + return validateDottedDecimalIPv4Input(value); +} diff --git a/packages/net-policy/src/redact-sensitive-url.test.ts b/packages/net-policy/src/redact-sensitive-url.test.ts new file mode 100644 index 000000000000..a849475e208c --- /dev/null +++ b/packages/net-policy/src/redact-sensitive-url.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + isSensitiveUrlQueryParamName, + isSensitiveUrlConfigPath, + SENSITIVE_URL_HINT_TAG, + hasSensitiveUrlHintTag, + redactSensitiveUrl, + redactSensitiveUrlLikeString, +} from "./redact-sensitive-url.js"; + +describe("redactSensitiveUrl", () => { + it("redacts userinfo and sensitive query params from valid URLs", () => { + expect(redactSensitiveUrl("https://user:pass@example.com/mcp?token=secret&safe=value")).toBe( + "https://***:***@example.com/mcp?token=***&safe=value", + ); + }); + + it("treats query param names case-insensitively", () => { + expect(redactSensitiveUrl("https://example.com/mcp?Access_Token=secret")).toBe( + "https://example.com/mcp?Access_Token=***", + ); + }); + + it("keeps non-sensitive URLs unchanged", () => { + expect(redactSensitiveUrl("https://example.com/mcp?safe=value")).toBe( + "https://example.com/mcp?safe=value", + ); + }); +}); + +describe("redactSensitiveUrlLikeString", () => { + it("redacts invalid URL-like strings", () => { + expect(redactSensitiveUrlLikeString("//user:pass@example.com/mcp?client_secret=secret")).toBe( + "//***:***@example.com/mcp?client_secret=***", + ); + }); + + it("redacts every URL-like userinfo occurrence in arbitrary text", () => { + expect( + redactSensitiveUrlLikeString( + "fatal https://a:b@github.com/one.git and https://c:d@github.com/two.git", + ), + ).toBe("fatal https://***:***@github.com/one.git and https://***:***@github.com/two.git"); + }); + + it("redacts protocol URLs that are too malformed to parse", () => { + expect( + redactSensitiveUrlLikeString( + "wss://fallback-user:fallback-pass@[bad-host/socket?token=fallback-secret&keep=visible)", + ), + ).toBe("wss://***:***@[bad-host/socket?token=***&keep=visible)"); + }); +}); + +describe("isSensitiveUrlQueryParamName", () => { + it("matches the auth-oriented query params used by MCP SSE config redaction", () => { + expect(isSensitiveUrlQueryParamName("token")).toBe(true); + expect(isSensitiveUrlQueryParamName("refresh_token")).toBe(true); + expect(isSensitiveUrlQueryParamName("access-token")).toBe(true); + expect(isSensitiveUrlQueryParamName("hook-token")).toBe(true); + expect(isSensitiveUrlQueryParamName("passwd")).toBe(true); + expect(isSensitiveUrlQueryParamName("signature")).toBe(true); + expect(isSensitiveUrlQueryParamName("safe")).toBe(false); + }); +}); + +describe("sensitive URL config metadata", () => { + it("recognizes config paths that may embed URL secrets", () => { + expect(isSensitiveUrlConfigPath("models.providers.*.baseUrl")).toBe(true); + expect(isSensitiveUrlConfigPath("mcp.servers.remote.url")).toBe(true); + expect(isSensitiveUrlConfigPath("gateway.remote.url")).toBe(false); + }); + + it("recognizes cdpUrl config paths as sensitive (browser CDP URLs can embed credentials)", () => { + expect(isSensitiveUrlConfigPath("browser.cdpUrl")).toBe(true); + expect(isSensitiveUrlConfigPath("browser.profiles.remote.cdpUrl")).toBe(true); + expect(isSensitiveUrlConfigPath("browser.profiles.staging.cdpUrl")).toBe(true); + }); + + it("uses an explicit url-secret hint tag", () => { + expect(SENSITIVE_URL_HINT_TAG).toBe("url-secret"); + expect(hasSensitiveUrlHintTag({ tags: [SENSITIVE_URL_HINT_TAG] })).toBe(true); + expect(hasSensitiveUrlHintTag({ tags: ["security"] })).toBe(false); + }); +}); diff --git a/packages/net-policy/src/redact-sensitive-url.ts b/packages/net-policy/src/redact-sensitive-url.ts new file mode 100644 index 000000000000..6b76c833641d --- /dev/null +++ b/packages/net-policy/src/redact-sensitive-url.ts @@ -0,0 +1,82 @@ +type ConfigUiHintTags = { + tags?: string[]; +}; + +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +export const SENSITIVE_URL_HINT_TAG = "url-secret"; + +const SENSITIVE_URL_QUERY_PARAM_NAMES = new Set([ + "token", + "key", + "api_key", + "apikey", + "secret", + "access_token", + "auth_token", + "password", + "pass", + "passwd", + "auth", + "client_secret", + "hook_token", + "refresh_token", + "signature", +]); + +export function isSensitiveUrlQueryParamName(name: string): boolean { + const normalized = normalizeLowercaseStringOrEmpty(name).replaceAll("-", "_"); + return SENSITIVE_URL_QUERY_PARAM_NAMES.has(normalized); +} + +export function isSensitiveUrlConfigPath(path: string): boolean { + if (path.endsWith(".baseUrl") || path.endsWith(".httpUrl")) { + return true; + } + if (path.endsWith(".cdpUrl")) { + return true; + } + if (path.endsWith(".request.proxy.url")) { + return true; + } + return /^mcp\.servers\.(?:\*|[^.]+)\.url$/.test(path); +} + +export function hasSensitiveUrlHintTag(hint: ConfigUiHintTags | undefined): boolean { + return hint?.tags?.includes(SENSITIVE_URL_HINT_TAG) === true; +} + +export function redactSensitiveUrl(value: string): string { + try { + const parsed = new URL(value); + let mutated = false; + if (parsed.username || parsed.password) { + parsed.username = parsed.username ? "***" : ""; + parsed.password = parsed.password ? "***" : ""; + mutated = true; + } + for (const key of Array.from(parsed.searchParams.keys())) { + if (isSensitiveUrlQueryParamName(key)) { + parsed.searchParams.set(key, "***"); + mutated = true; + } + } + return mutated ? parsed.toString() : value; + } catch { + return value; + } +} + +export function redactSensitiveUrlLikeString(value: string): string { + const redactedUrl = redactSensitiveUrl(value); + if (redactedUrl !== value) { + return redactedUrl; + } + return value + .replace(/\/\/([^@/?#\s]+)@/g, "//***:***@") + .replace(/([?&])([^=&]+)=([^&]*)/g, (match, prefix: string, key: string) => + isSensitiveUrlQueryParamName(key) ? `${prefix}${key}=***` : match, + ); +} diff --git a/packages/net-policy/src/url-userinfo.ts b/packages/net-policy/src/url-userinfo.ts new file mode 100644 index 000000000000..d9374a3d4c2d --- /dev/null +++ b/packages/net-policy/src/url-userinfo.ts @@ -0,0 +1,13 @@ +export function stripUrlUserInfo(value: string): string { + try { + const parsed = new URL(value); + if (!parsed.username && !parsed.password) { + return value; + } + parsed.username = ""; + parsed.password = ""; + return parsed.toString(); + } catch { + return value; + } +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 8d6d785ce2e5..aa14895a117b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -17,5 +17,8 @@ }, "scripts": { "build": "tsdown src/index.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + }, + "dependencies": { + "@openclaw/gateway-client": "workspace:*" } } diff --git a/packages/sdk/src/transport.ts b/packages/sdk/src/transport.ts index 50847e8ab0c9..dd3af4979edc 100644 --- a/packages/sdk/src/transport.ts +++ b/packages/sdk/src/transport.ts @@ -1,4 +1,4 @@ -import { GatewayClient } from "../../../src/gateway/client.js"; +import { GatewayClient } from "@openclaw/gateway-client"; import { EventHub } from "./event-hub.js"; import type { ConnectableOpenClawTransport, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7d73131f4e1..d952a19619b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1788,11 +1788,21 @@ importers: packages/memory-host-sdk: {} + packages/net-policy: + dependencies: + ipaddr.js: + specifier: 2.4.0 + version: 2.4.0 + packages/plugin-package-contract: {} packages/plugin-sdk: {} - packages/sdk: {} + packages/sdk: + dependencies: + '@openclaw/gateway-client': + specifier: workspace:* + version: link:../gateway-client packages/speech-core: dependencies: diff --git a/scripts/run-node-watch-paths.mjs b/scripts/run-node-watch-paths.mjs index 916afdedbeb0..596d24c7e6b3 100644 --- a/scripts/run-node-watch-paths.mjs +++ b/scripts/run-node-watch-paths.mjs @@ -5,10 +5,12 @@ import { } from "./lib/bundled-plugin-paths.mjs"; const RUN_NODE_PACKAGE_SOURCE_ROOTS = [ - // Gateway runtime code now lives in package sources, but pnpm dev/watch still - // runs the root dist entrypoint. Treat these package roots like src/. + // Root runtime code imports these package sources through tsconfig aliases, + // while pnpm dev/watch still runs the root dist entrypoint. Treat them like + // src/ so edits restart the same process that consumes them. "packages/gateway-client/src", "packages/gateway-protocol/src", + "packages/net-policy/src", ]; export const runNodeSourceRoots = [ diff --git a/src/agents/agent-bundle-mcp-runtime.ts b/src/agents/agent-bundle-mcp-runtime.ts index 872584208f0f..c15fb586f110 100644 --- a/src/agents/agent-bundle-mcp-runtime.ts +++ b/src/agents/agent-bundle-mcp-runtime.ts @@ -9,6 +9,7 @@ import type { JsonSchemaValidator, jsonSchemaValidator, } from "@modelcontextprotocol/sdk/validation/types.js"; +import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { Compile } from "typebox/compile"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; @@ -17,7 +18,6 @@ import { findJsonSchemaShapeError, normalizeJsonSchemaForTypeBox, } from "../shared/json-schema-defaults.js"; -import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { sanitizeServerName } from "./agent-bundle-mcp-names.js"; import type { diff --git a/src/agents/mcp-http.ts b/src/agents/mcp-http.ts index 594338a7e0fb..4d203bb2ad03 100644 --- a/src/agents/mcp-http.ts +++ b/src/agents/mcp-http.ts @@ -1,7 +1,7 @@ import { redactSensitiveUrl, redactSensitiveUrlLikeString, -} from "../shared/net/redact-sensitive-url.js"; +} from "@openclaw/net-policy/redact-sensitive-url"; import { isMcpConfigRecord, toMcpStringRecord } from "./mcp-config-shared.js"; export type HttpMcpTransportType = "sse" | "streamable-http"; diff --git a/src/agents/provider-transport-fetch.ts b/src/agents/provider-transport-fetch.ts index 519571d92ec6..2993816c1afc 100644 --- a/src/agents/provider-transport-fetch.ts +++ b/src/agents/provider-transport-fetch.ts @@ -1,3 +1,8 @@ +import { + isCloudMetadataIpAddress, + isLinkLocalIpAddress, + parseCanonicalIpAddress, +} from "@openclaw/net-policy/ip"; import { fetchWithSsrFGuard, withTrustedEnvProxyGuardedFetchMode, @@ -12,11 +17,6 @@ import { import type { Model } from "../llm/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveDebugProxySettings } from "../proxy-capture/env.js"; -import { - isCloudMetadataIpAddress, - isLinkLocalIpAddress, - parseCanonicalIpAddress, -} from "../shared/net/ip.js"; import { emitModelTransportDebug } from "./model-transport-debug.js"; import { formatModelTransportDebugUrl } from "./model-transport-url.js"; import { diff --git a/src/agents/skills-source-install.ts b/src/agents/skills-source-install.ts index 001895687e1b..1327a266b15a 100644 --- a/src/agents/skills-source-install.ts +++ b/src/agents/skills-source-install.ts @@ -1,11 +1,11 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { sanitizeHostExecEnv } from "../infra/host-env-security.js"; import { withTempDir } from "../infra/install-source-utils.js"; import { writeJson } from "../infra/json-files.js"; import { parseGitPluginSpec } from "../plugins/git-install.js"; import { runCommandWithTimeout } from "../process/exec.js"; -import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { sanitizeForLog } from "../terminal/ansi.js"; import { resolveUserPath } from "../utils.js"; diff --git a/src/channels/account-snapshot-fields.ts b/src/channels/account-snapshot-fields.ts index ca342c7ffb4d..e89ca531e9f9 100644 --- a/src/channels/account-snapshot-fields.ts +++ b/src/channels/account-snapshot-fields.ts @@ -1,4 +1,4 @@ -import { stripUrlUserInfo } from "../shared/net/url-userinfo.js"; +import { stripUrlUserInfo } from "@openclaw/net-policy/url-userinfo"; import { asFiniteNumber } from "../shared/number-coercion.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index 9495b40f5094..907e302949d8 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -1,3 +1,4 @@ +import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { normalizeChannelId } from "../../channels/plugins/index.js"; import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js"; import { formatCliCommand } from "../../cli/command-format.js"; @@ -11,7 +12,6 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; import { listConfiguredChannelIdsForReadOnlyScope } from "../../plugins/channel-plugin-ids.js"; import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; -import { redactSensitiveUrlLikeString } from "../../shared/net/redact-sensitive-url.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; import { diff --git a/src/commands/configure.gateway.ts b/src/commands/configure.gateway.ts index 914e054db4f2..beea897bd751 100644 --- a/src/commands/configure.gateway.ts +++ b/src/commands/configure.gateway.ts @@ -1,3 +1,4 @@ +import { validateIPv4AddressInput } from "@openclaw/net-policy/ipv4"; import { formatPortRangeHint } from "../cli/error-format.js"; import { parsePort } from "../cli/shared/parse-port.js"; import { resolveGatewayPort } from "../config/config.js"; @@ -12,7 +13,6 @@ import { import { findTailscaleBinary } from "../infra/tailscale.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; -import { validateIPv4AddressInput } from "../shared/net/ipv4.js"; import { normalizeOptionalString, readStringValue } from "../shared/string-coerce.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; import { note } from "../terminal/note.js"; diff --git a/src/commands/status.scan.shared.ts b/src/commands/status.scan.shared.ts index 8b484a15c3ed..381cea23a32f 100644 --- a/src/commands/status.scan.shared.ts +++ b/src/commands/status.scan.shared.ts @@ -1,4 +1,5 @@ import { existsSync } from "node:fs"; +import { isLoopbackIpAddress } from "@openclaw/net-policy/ip"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, @@ -11,7 +12,6 @@ import type { GatewayProbeResult, probeGateway as probeGatewayFn } from "../gate import type { MemoryProviderStatus } from "../memory-host-sdk/engine-storage.js"; import { defaultSlotIdForKey } from "../plugins/slots.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; -import { isLoopbackIpAddress } from "../shared/net/ip.js"; import { normalizeOptionalLowercaseString, normalizeOptionalString, diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index b461daed7641..522e4515065d 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -1,10 +1,10 @@ -import { createSubsystemLogger } from "../logging/subsystem.js"; -import { type ConfigUiHints } from "../shared/config-ui-hints-types.js"; import { hasSensitiveUrlHintTag, isSensitiveUrlConfigPath, redactSensitiveUrlLikeString, -} from "../shared/net/redact-sensitive-url.js"; +} from "@openclaw/net-policy/redact-sensitive-url"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { type ConfigUiHints } from "../shared/config-ui-hints-types.js"; import { isRecord as isObjectRecord } from "../shared/record-coerce.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { diff --git a/src/config/schema-base.ts b/src/config/schema-base.ts index e985a23bb901..ebfdf2b09d1b 100644 --- a/src/config/schema-base.ts +++ b/src/config/schema-base.ts @@ -1,4 +1,4 @@ -import { isSensitiveUrlConfigPath } from "../shared/net/redact-sensitive-url.js"; +import { isSensitiveUrlConfigPath } from "@openclaw/net-policy/redact-sensitive-url"; import { VERSION } from "../version.js"; import { FIELD_HELP } from "./schema.help.js"; import type { ConfigUiHints } from "./schema.hints.js"; diff --git a/src/config/schema.base.generated.test.ts b/src/config/schema.base.generated.test.ts index ecd97342b756..c1e4ee194287 100644 --- a/src/config/schema.base.generated.test.ts +++ b/src/config/schema.base.generated.test.ts @@ -1,5 +1,5 @@ +import { SENSITIVE_URL_HINT_TAG } from "@openclaw/net-policy/redact-sensitive-url"; import { describe, expect, it } from "vitest"; -import { SENSITIVE_URL_HINT_TAG } from "../shared/net/redact-sensitive-url.js"; import { computeBaseConfigSchemaResponse } from "./schema-base.js"; type TestJsonSchema = { diff --git a/src/config/schema.hints.test.ts b/src/config/schema.hints.test.ts index 6363bb9773b3..03a9fea39cf2 100644 --- a/src/config/schema.hints.test.ts +++ b/src/config/schema.hints.test.ts @@ -1,7 +1,7 @@ +import { isSensitiveUrlConfigPath } from "@openclaw/net-policy/redact-sensitive-url"; import { describe, expect, it } from "vitest"; import { z } from "zod"; import { buildSecretInputSchema } from "../plugin-sdk/secret-input-schema.js"; -import { isSensitiveUrlConfigPath } from "../shared/net/redact-sensitive-url.js"; import { FIELD_HELP } from "./schema.help.js"; import { testApi, isPluginOwnedChannelHintPath, isSensitiveConfigPath } from "./schema.hints.js"; import { FIELD_LABELS } from "./schema.labels.js"; diff --git a/src/config/schema.hints.ts b/src/config/schema.hints.ts index 957bb2ceac48..c21a5276550f 100644 --- a/src/config/schema.hints.ts +++ b/src/config/schema.hints.ts @@ -1,10 +1,10 @@ -import { z } from "zod"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import type { ConfigUiHints } from "../shared/config-ui-hints-types.js"; import { isSensitiveUrlConfigPath, SENSITIVE_URL_HINT_TAG, -} from "../shared/net/redact-sensitive-url.js"; +} from "@openclaw/net-policy/redact-sensitive-url"; +import { z } from "zod"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import type { ConfigUiHints } from "../shared/config-ui-hints-types.js"; import { FIELD_HELP } from "./schema.help.js"; import { FIELD_LABELS } from "./schema.labels.js"; import { applyDerivedTags } from "./schema.tags.js"; diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 4ffc0b79d5ab..7a8c1419a5ce 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -1,5 +1,5 @@ +import { SENSITIVE_URL_HINT_TAG } from "@openclaw/net-policy/redact-sensitive-url"; import { beforeAll, describe, expect, it } from "vitest"; -import { SENSITIVE_URL_HINT_TAG } from "../shared/net/redact-sensitive-url.js"; import { buildConfigSchema, lookupConfigSchema } from "./schema.js"; import { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js"; import { ToolsSchema } from "./zod-schema.agent-runtime.js"; diff --git a/src/config/validation.ts b/src/config/validation.ts index 23debbbbe44a..7b48e9925362 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "@openclaw/net-policy/ip"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { isPathInside } from "../infra/path-guards.js"; import { planManifestModelCatalogSuppressions } from "../model-catalog/index.js"; @@ -34,7 +35,6 @@ import { formatUnsafeGatewayTailscaleNoAuthMessage, isUnsafeGatewayTailscaleNoAuth, } from "../shared/gateway-tailscale-auth-policy.js"; -import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "../shared/net/ip.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { isRecord, resolveUserPath } from "../utils.js"; import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js"; diff --git a/src/gateway/call.ts b/src/gateway/call.ts index d12d73fbfbc6..13a15d9caf81 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -1,4 +1,6 @@ import { randomUUID } from "node:crypto"; +import { isLoopbackIpAddress } from "@openclaw/net-policy/ip"; +import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION, @@ -13,8 +15,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loadDeviceAuthToken } from "../infra/device-auth-store.js"; import { loadOrCreateDeviceIdentity, type DeviceIdentity } from "../infra/device-identity.js"; import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js"; -import { isLoopbackIpAddress } from "../shared/net/ip.js"; -import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { GATEWAY_CLIENT_MODES, diff --git a/src/gateway/connection-details.ts b/src/gateway/connection-details.ts index b403b2bd320c..f6be61524e57 100644 --- a/src/gateway/connection-details.ts +++ b/src/gateway/connection-details.ts @@ -1,6 +1,6 @@ +import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { resolveConfigPath, resolveGatewayPort } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.js"; -import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { isSecureWebSocketUrl } from "./net.js"; diff --git a/src/gateway/gateway-config-prompts.shared.ts b/src/gateway/gateway-config-prompts.shared.ts index 954bdb37ce1d..e63c4d8a486a 100644 --- a/src/gateway/gateway-config-prompts.shared.ts +++ b/src/gateway/gateway-config-prompts.shared.ts @@ -1,6 +1,6 @@ +import { isIpv6Address, parseCanonicalIpAddress } from "@openclaw/net-policy/ip"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getTailnetHostname } from "../infra/tailscale.js"; -import { isIpv6Address, parseCanonicalIpAddress } from "../shared/net/ip.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; export const TAILSCALE_EXPOSURE_OPTIONS = [ diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 4db2766eb7df..7aabe2c0c235 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -1,5 +1,12 @@ import type { IncomingMessage } from "node:http"; import net from "node:net"; +import { + isCanonicalDottedDecimalIPv4, + isIpInCidr, + isLoopbackIpAddress, + isPrivateOrLoopbackIpAddress, + normalizeIpAddress, +} from "@openclaw/net-policy/ip"; import type { GatewayBindMode } from "../config/types.gateway.js"; import { resetContainerEnvironmentCacheForTest, @@ -12,13 +19,6 @@ import { type NetworkInterfacesSnapshot, } from "../infra/network-interfaces.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; -import { - isCanonicalDottedDecimalIPv4, - isIpInCidr, - isLoopbackIpAddress, - isPrivateOrLoopbackIpAddress, - normalizeIpAddress, -} from "../shared/net/ip.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; /** diff --git a/src/gateway/operator-approvals-client.ts b/src/gateway/operator-approvals-client.ts index da005144e73a..381965029043 100644 --- a/src/gateway/operator-approvals-client.ts +++ b/src/gateway/operator-approvals-client.ts @@ -1,9 +1,9 @@ +import { isLoopbackIpAddress } from "@openclaw/net-policy/ip"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, } from "../../packages/gateway-protocol/src/client-info.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { isLoopbackIpAddress } from "../shared/net/ip.js"; import { resolveGatewayClientBootstrap } from "./client-bootstrap.js"; import { startGatewayClientWhenEventLoopReady } from "./client-start-readiness.js"; import { GatewayClient, type GatewayClientOptions } from "./client.js"; diff --git a/src/gateway/origin-check.ts b/src/gateway/origin-check.ts index 6108f8502bd9..d09cd7c9f009 100644 --- a/src/gateway/origin-check.ts +++ b/src/gateway/origin-check.ts @@ -1,5 +1,5 @@ import net from "node:net"; -import { isPrivateOrLoopbackIpAddress } from "../shared/net/ip.js"; +import { isPrivateOrLoopbackIpAddress } from "@openclaw/net-policy/ip"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, diff --git a/src/infra/net/configured-local-origin-bypass.ts b/src/infra/net/configured-local-origin-bypass.ts index 58987e7755b4..6f6cbf5e3e8c 100644 --- a/src/infra/net/configured-local-origin-bypass.ts +++ b/src/infra/net/configured-local-origin-bypass.ts @@ -1,4 +1,4 @@ -import { isLoopbackIpAddress } from "../../shared/net/ip.js"; +import { isLoopbackIpAddress } from "@openclaw/net-policy/ip"; import { getActiveManagedProxyLoopbackMode } from "./proxy/active-proxy-state.js"; import { SsrFBlockedError } from "./ssrf.js"; diff --git a/src/infra/net/proxy/proxy-lifecycle.ts b/src/infra/net/proxy/proxy-lifecycle.ts index 66e828380456..4895a7e2f15a 100644 --- a/src/infra/net/proxy/proxy-lifecycle.ts +++ b/src/infra/net/proxy/proxy-lifecycle.ts @@ -15,8 +15,8 @@ import { import type { ProxyConfig } from "../../../config/zod-schema.proxy.js"; export type ProxyLoopbackMode = NonNullable["loopbackMode"]>; +import { isLoopbackIpAddress } from "@openclaw/net-policy/ip"; import { logInfo, logWarn } from "../../../logger.js"; -import { isLoopbackIpAddress } from "../../../shared/net/ip.js"; import { forceResetGlobalDispatcher } from "../undici-global-dispatcher.js"; import { getActiveManagedProxyLoopbackMode, diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 2a25f1cdac6b..476387f0968c 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { blockedIpv6MulticastLiterals } from "../../shared/net/ip-test-fixtures.js"; +import { blockedIpv6MulticastLiterals } from "../../../packages/net-policy/src/ip-test-fixtures.js"; import { assertHostnameAllowedWithPolicy, isBlockedHostnameOrIp, diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 0cd8082df01f..24a88616d69e 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -1,6 +1,5 @@ import { lookup as dnsLookupCb, type LookupAddress } from "node:dns"; import { lookup as dnsLookup } from "node:dns/promises"; -import type { Dispatcher } from "undici"; import { extractEmbeddedIpv4FromIpv6, isCloudMetadataIpAddress, @@ -14,7 +13,8 @@ import { isLegacyIpv4Literal, parseCanonicalIpAddress, parseLooseIpAddress, -} from "../../shared/net/ip.js"; +} from "@openclaw/net-policy/ip"; +import type { Dispatcher } from "undici"; import { normalizeUniqueStringEntries } from "../../shared/string-normalization.js"; import { normalizeHostname } from "./hostname.js"; import { diff --git a/src/infra/tailnet.ts b/src/infra/tailnet.ts index d7a13c60ab1d..d0eb0c5ad4c3 100644 --- a/src/infra/tailnet.ts +++ b/src/infra/tailnet.ts @@ -1,4 +1,4 @@ -import { isIpInCidr } from "../shared/net/ip.js"; +import { isIpInCidr } from "@openclaw/net-policy/ip"; import { uniqueStrings } from "../shared/string-normalization.js"; import { listExternalInterfaceAddresses, readNetworkInterfaces } from "./network-interfaces.js"; diff --git a/src/infra/watch-node.test.ts b/src/infra/watch-node.test.ts index 06644c98604b..cf4685297341 100644 --- a/src/infra/watch-node.test.ts +++ b/src/infra/watch-node.test.ts @@ -132,6 +132,7 @@ describe("watch-node script", () => { expect(watchPaths).toContain("extensions"); expect(watchPaths).toContain("packages/gateway-client/src"); expect(watchPaths).toContain("packages/gateway-protocol/src"); + expect(watchPaths).toContain("packages/net-policy/src"); expect(watchPaths).toContain("tsdown.config.ts"); expect(watchOptions.ignoreInitial).toBe(true); expect(watchOptions.ignored("src")).toBe(false); @@ -139,6 +140,8 @@ describe("watch-node script", () => { expect(watchOptions.ignored("packages/gateway-client/src/client.ts")).toBe(false); expect(watchOptions.ignored("packages/gateway-client/src/client.test.ts")).toBe(true); expect(watchOptions.ignored("packages/gateway-protocol/src/schema/cron.ts")).toBe(false); + expect(watchOptions.ignored("packages/net-policy/src/ip.ts")).toBe(false); + expect(watchOptions.ignored("packages/net-policy/src/ip.test.ts")).toBe(true); expect(watchOptions.ignored("extensions")).toBe(false); expect(watchOptions.ignored("extensions/voice-call")).toBe(false); expect(watchOptions.ignored("extensions/voice-call/dist")).toBe(true); diff --git a/src/logging/diagnostic-support-redaction.ts b/src/logging/diagnostic-support-redaction.ts index 272411332cc8..59df01e0bf58 100644 --- a/src/logging/diagnostic-support-redaction.ts +++ b/src/logging/diagnostic-support-redaction.ts @@ -1,7 +1,7 @@ import path from "node:path"; +import { isSensitiveUrlQueryParamName } from "@openclaw/net-policy/redact-sensitive-url"; import { isSecretRefShape } from "../config/redact-snapshot.secret-ref.js"; import { isBlockedObjectKey } from "../infra/prototype-keys.js"; -import { isSensitiveUrlQueryParamName } from "../shared/net/redact-sensitive-url.js"; import { asOptionalRecord } from "../shared/record-coerce.js"; import { redactSensitiveText } from "./redact.js"; diff --git a/src/media/parse.ts b/src/media/parse.ts index c3f8a02fdf9b..d803b4408cc1 100644 --- a/src/media/parse.ts +++ b/src/media/parse.ts @@ -1,6 +1,5 @@ // Shared helpers for parsing MEDIA tokens from command/stdout text. -import { parseFenceSpans } from "../markdown/fences.js"; import { extractEmbeddedIpv4FromIpv6, isBlockedSpecialUseIpv4Address, @@ -10,7 +9,8 @@ import { isLegacyIpv4Literal, parseCanonicalIpAddress, parseLooseIpAddress, -} from "../shared/net/ip.js"; +} from "@openclaw/net-policy/ip"; +import { parseFenceSpans } from "../markdown/fences.js"; import { parseAudioTag } from "./audio-tags.js"; // Allow optional wrapping backticks and punctuation after the token; capture the core token. diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index c5542da45528..2a8254f73fb1 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -1,4 +1,12 @@ import os from "node:os"; +import { + isCarrierGradeNatIpv4Address, + isIpv4Address, + isIpv6Address, + isLoopbackIpAddress, + isRfc1918Ipv4Address, + parseCanonicalIpAddress, +} from "@openclaw/net-policy/ip"; import { resolveGatewayPort } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.js"; import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js"; @@ -11,14 +19,6 @@ import { } from "../infra/network-interfaces.js"; import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js"; import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js"; -import { - isCarrierGradeNatIpv4Address, - isIpv4Address, - isIpv6Address, - isLoopbackIpAddress, - isRfc1918Ipv4Address, - parseCanonicalIpAddress, -} from "../shared/net/ip.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, diff --git a/src/plugins/git-install.test.ts b/src/plugins/git-install.test.ts index ce0ca179bbb3..4e75dc4c554d 100644 --- a/src/plugins/git-install.test.ts +++ b/src/plugins/git-install.test.ts @@ -2,8 +2,8 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js"; const runCommandWithTimeoutMock = vi.fn(); const installPluginFromInstalledPackageDirMock = vi.fn(); diff --git a/src/plugins/git-install.ts b/src/plugins/git-install.ts index 098984d71eae..07133ce6d3da 100644 --- a/src/plugins/git-install.ts +++ b/src/plugins/git-install.ts @@ -1,6 +1,7 @@ import "../infra/fs-safe-defaults.js"; import { createHash } from "node:crypto"; import path from "node:path"; +import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { withTempDir } from "../infra/install-source-utils.js"; import { replaceDirectoryAtomic } from "../infra/replace-file.js"; import { @@ -8,7 +9,6 @@ import { createSafeNpmInstallEnv, } from "../infra/safe-package-install.js"; import { runCommandWithTimeout } from "../process/exec.js"; -import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { sanitizeForLog } from "../terminal/ansi.js"; import { resolveUserPath } from "../utils.js"; diff --git a/src/plugins/marketplace.ts b/src/plugins/marketplace.ts index 92c8a108720b..2c3952bf75c0 100644 --- a/src/plugins/marketplace.ts +++ b/src/plugins/marketplace.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { resolveArchiveKind } from "../infra/archive.js"; import { formatErrorMessage } from "../infra/errors.js"; import { pathExists } from "../infra/fs-safe.js"; @@ -9,7 +10,6 @@ import { tryReadJson } from "../infra/json-files.js"; import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import { isPathInside } from "../infra/path-guards.js"; import { runCommandWithTimeout } from "../process/exec.js"; -import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { sanitizeForLog } from "../terminal/ansi.js"; import { resolveUserPath } from "../utils.js"; diff --git a/src/wizard/setup.gateway-config.ts b/src/wizard/setup.gateway-config.ts index ba07c906d660..325ae06867df 100644 --- a/src/wizard/setup.gateway-config.ts +++ b/src/wizard/setup.gateway-config.ts @@ -1,3 +1,4 @@ +import { validateIPv4AddressInput } from "@openclaw/net-policy/ipv4"; import { formatPortRangeHint } from "../cli/error-format.js"; import { normalizeGatewayTokenInput, @@ -21,7 +22,6 @@ import { findTailscaleBinary } from "../infra/tailscale.js"; import { resolveSecretInputModeForEnvSelection } from "../plugins/provider-auth-mode.js"; import { promptSecretRefForSetup } from "../plugins/provider-auth-ref.js"; import type { RuntimeEnv } from "../runtime.js"; -import { validateIPv4AddressInput } from "../shared/net/ipv4.js"; import { maskApiKey } from "../utils/mask-api-key.js"; import { t } from "./i18n/index.js"; import type { WizardPrompter } from "./prompts.js"; diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index 8f14b7b7eb2b..620c11d98c9e 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -209,6 +209,32 @@ export const sharedVitestConfig = { find: "@openclaw/gateway-protocol", replacement: path.join(repoRoot, "packages", "gateway-protocol", "src", "index.ts"), }, + { + find: "@openclaw/net-policy/ip", + replacement: path.join(repoRoot, "packages", "net-policy", "src", "ip.ts"), + }, + { + find: "@openclaw/net-policy/ipv4", + replacement: path.join(repoRoot, "packages", "net-policy", "src", "ipv4.ts"), + }, + { + find: "@openclaw/net-policy/redact-sensitive-url", + replacement: path.join( + repoRoot, + "packages", + "net-policy", + "src", + "redact-sensitive-url.ts", + ), + }, + { + find: "@openclaw/net-policy/url-userinfo", + replacement: path.join(repoRoot, "packages", "net-policy", "src", "url-userinfo.ts"), + }, + { + find: "@openclaw/net-policy", + replacement: path.join(repoRoot, "packages", "net-policy", "src", "index.ts"), + }, ...sourcePluginSdkSubpaths.map((subpath) => ({ find: `openclaw/plugin-sdk/${subpath}`, replacement: path.join(repoRoot, "src", "plugin-sdk", `${subpath}.ts`), diff --git a/tsconfig.json b/tsconfig.json index f4c9a5388fa6..8a09a07a8b99 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -44,6 +44,14 @@ ], "@openclaw/gateway-protocol/version": ["./packages/gateway-protocol/src/version.ts"], "@openclaw/gateway-protocol/*": ["./packages/gateway-protocol/src/*"], + "@openclaw/net-policy": ["./packages/net-policy/src/index.ts"], + "@openclaw/net-policy/ip": ["./packages/net-policy/src/ip.ts"], + "@openclaw/net-policy/ipv4": ["./packages/net-policy/src/ipv4.ts"], + "@openclaw/net-policy/redact-sensitive-url": [ + "./packages/net-policy/src/redact-sensitive-url.ts" + ], + "@openclaw/net-policy/url-userinfo": ["./packages/net-policy/src/url-userinfo.ts"], + "@openclaw/net-policy/*": ["./packages/net-policy/src/*"], "@openclaw/speech-core": ["./packages/speech-core/runtime-api.ts"], "@openclaw/speech-core/api": ["./packages/speech-core/api.ts"], "@openclaw/speech-core/runtime-api": ["./packages/speech-core/runtime-api.ts"], diff --git a/tsdown.config.ts b/tsdown.config.ts index bd502d0a261a..575700acddc8 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -371,6 +371,18 @@ function buildGatewayClientDistEntries(): Record { }; } +function buildNetPolicyDistEntries(): Record { + return { + // These subpaths are imported by root runtime code and exported by the + // package. Keep the build list adjacent to package.json exports. + index: "packages/net-policy/src/index.ts", + ip: "packages/net-policy/src/ip.ts", + ipv4: "packages/net-policy/src/ipv4.ts", + "redact-sensitive-url": "packages/net-policy/src/redact-sensitive-url.ts", + "url-userinfo": "packages/net-policy/src/url-userinfo.ts", + }; +} + function buildSpeechCoreDistEntries(): Record { return { api: "packages/speech-core/api.ts", @@ -405,6 +417,10 @@ function shouldExternalizeGatewayClientDependency(id: string): boolean { ); } +function shouldExternalizeNetPolicyDependency(id: string): boolean { + return id === "ipaddr.js" || id.startsWith("ipaddr.js/"); +} + function shouldExternalizeSpeechCoreDependency(id: string): boolean { return id === "openclaw" || id.startsWith("openclaw/"); } @@ -469,6 +485,15 @@ export default defineConfig([ neverBundle: shouldExternalizeGatewayClientDependency, }, }), + nodeWorkspacePackageBuildConfig({ + clean: true, + dts: RUN_NODE_SKIP_DTS_BUILD ? false : undefined, + entry: buildNetPolicyDistEntries(), + outDir: "packages/net-policy/dist", + deps: { + neverBundle: shouldExternalizeNetPolicyDependency, + }, + }), nodeWorkspacePackageBuildConfig({ clean: true, dts: RUN_NODE_SKIP_DTS_BUILD ? false : undefined,