@miphamai/cli 0.2.3
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/README.md +76 -0
- package/assets/icon.icns +0 -0
- package/assets/icon.jpg +0 -0
- package/bin/mipham +51 -0
- package/bin/mipham.ts +8 -0
- package/package.json +59 -0
- package/skills/mipham/om-model-optimize.mipham-skill.md +20 -0
- package/skills/mipham/om-security.mipham-skill.md +21 -0
- package/skills/standard/code-review.SKILL.md +116 -0
- package/skills/standard/compassionate-communication.SKILL.md +80 -0
- package/skills/standard/doc-generator.SKILL.md +21 -0
- package/skills/standard/github-ops.SKILL.md +22 -0
- package/skills/standard/memory.SKILL.md +22 -0
- package/skills/standard/mipham-code-setup.SKILL.md +113 -0
- package/skills/standard/security-review.SKILL.md +122 -0
- package/skills/standard/self-review.SKILL.md +20 -0
- package/skills/standard/superpower.SKILL.md +19 -0
- package/skills/standard/tdd.SKILL.md +20 -0
- package/skills/standard/web-search.SKILL.md +23 -0
- package/src/agent/sub-agent.ts +122 -0
- package/src/config/defaults.ts +10 -0
- package/src/config/loader.ts +30 -0
- package/src/core/context.ts +135 -0
- package/src/core/engine.ts +193 -0
- package/src/core/hooks.ts +116 -0
- package/src/core/instructions.ts +126 -0
- package/src/core/permission.ts +54 -0
- package/src/core/session-store.ts +136 -0
- package/src/index.tsx +93 -0
- package/src/mcp/client.ts +170 -0
- package/src/mcp/protocol.ts +104 -0
- package/src/mcp/transport.ts +169 -0
- package/src/mcp/types.ts +112 -0
- package/src/providers/anthropic.ts +286 -0
- package/src/providers/bootstrap.ts +28 -0
- package/src/providers/openai-compat.ts +158 -0
- package/src/providers/registry.ts +70 -0
- package/src/security/path.ts +90 -0
- package/src/security/url.ts +96 -0
- package/src/shared/constants.ts +368 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/types.ts +161 -0
- package/src/skills/loader.ts +109 -0
- package/src/skills/mipham/runtime.ts +63 -0
- package/src/skills/standard/runtime.ts +59 -0
- package/src/tools/agent/agent.ts +51 -0
- package/src/tools/agent/memory.ts +51 -0
- package/src/tools/agent/plan.ts +87 -0
- package/src/tools/agent/skill.ts +58 -0
- package/src/tools/exec/bash.ts +111 -0
- package/src/tools/exec/git.ts +63 -0
- package/src/tools/exec/task.ts +69 -0
- package/src/tools/file/edit.ts +57 -0
- package/src/tools/file/glob.ts +29 -0
- package/src/tools/file/grep.ts +52 -0
- package/src/tools/file/read.ts +38 -0
- package/src/tools/file/write.ts +27 -0
- package/src/tools/index.ts +126 -0
- package/src/tools/network/web-fetch.ts +61 -0
- package/src/tools/network/web-search.ts +32 -0
- package/src/tools/system/config.ts +68 -0
- package/src/tools/system/mcp.ts +75 -0
- package/src/ui/app.tsx +317 -0
- package/src/ui/chat.tsx +76 -0
- package/src/ui/commands.ts +2232 -0
- package/src/ui/input.tsx +83 -0
- package/src/ui/picker.tsx +209 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProviderConfig,
|
|
3
|
+
ModelInfo,
|
|
4
|
+
Message,
|
|
5
|
+
StreamChunk,
|
|
6
|
+
ContentBlock,
|
|
7
|
+
} from '../shared/index.ts'
|
|
8
|
+
import type { ProviderInstance, ChatRequest } from './registry'
|
|
9
|
+
|
|
10
|
+
export class OpenAICompatProvider implements ProviderInstance {
|
|
11
|
+
constructor(public config: ProviderConfig) {}
|
|
12
|
+
|
|
13
|
+
async *chat(req: ChatRequest): AsyncGenerator<StreamChunk> {
|
|
14
|
+
const baseUrl = this.config.baseUrl?.replace(/\/+$/, '') || 'https://api.openai.com/v1'
|
|
15
|
+
const apiKey = this.resolveApiKey(this.config.apiKey)
|
|
16
|
+
|
|
17
|
+
const body = {
|
|
18
|
+
model: req.model,
|
|
19
|
+
messages: this.convertMessages(req.messages, req.systemPrompt),
|
|
20
|
+
stream: true,
|
|
21
|
+
max_tokens: req.maxTokens,
|
|
22
|
+
temperature: req.temperature,
|
|
23
|
+
tools: req.tools?.map((t) => ({ type: 'function', function: t })),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: {
|
|
29
|
+
'Content-Type': 'application/json',
|
|
30
|
+
Authorization: `Bearer ${apiKey}`,
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify(body),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
const errText = await response.text()
|
|
37
|
+
yield { type: 'error', error: `OpenAI API error ${response.status}: ${errText}` }
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!response.body) {
|
|
42
|
+
yield { type: 'error', error: 'No response body' }
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const reader = response.body.getReader()
|
|
47
|
+
const decoder = new TextDecoder()
|
|
48
|
+
let buffer = ''
|
|
49
|
+
|
|
50
|
+
while (true) {
|
|
51
|
+
const { done, value } = await reader.read()
|
|
52
|
+
if (done) break
|
|
53
|
+
|
|
54
|
+
buffer += decoder.decode(value, { stream: true })
|
|
55
|
+
const lines = buffer.split('\n')
|
|
56
|
+
buffer = lines.pop() || ''
|
|
57
|
+
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
const trimmed = line.trim()
|
|
60
|
+
if (!trimmed || !trimmed.startsWith('data: ')) continue
|
|
61
|
+
const data = trimmed.slice(6)
|
|
62
|
+
if (data === '[DONE]') {
|
|
63
|
+
yield { type: 'stop' }
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(data)
|
|
69
|
+
const choice = parsed.choices?.[0]
|
|
70
|
+
if (!choice) continue
|
|
71
|
+
|
|
72
|
+
const delta = choice.delta
|
|
73
|
+
|
|
74
|
+
if (delta?.tool_calls) {
|
|
75
|
+
for (const tc of delta.tool_calls) {
|
|
76
|
+
yield {
|
|
77
|
+
type: 'tool_use',
|
|
78
|
+
toolUse: {
|
|
79
|
+
type: 'tool_use',
|
|
80
|
+
id: tc.id || `call_${Date.now()}`,
|
|
81
|
+
name: tc.function?.name || '',
|
|
82
|
+
input: tc.function?.arguments ? JSON.parse(tc.function.arguments) : {},
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (delta?.content) {
|
|
90
|
+
yield { type: 'text', content: delta.content }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (choice.finish_reason === 'stop') {
|
|
94
|
+
yield { type: 'stop' }
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// skip unparseable chunks
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
yield { type: 'stop' }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async listModels(): Promise<ModelInfo[]> {
|
|
106
|
+
return this.config.models.filter((m) => m.status === 'active')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async healthCheck(): Promise<boolean> {
|
|
110
|
+
try {
|
|
111
|
+
const baseUrl = this.config.baseUrl?.replace(/\/+$/, '') || 'https://api.openai.com/v1'
|
|
112
|
+
const apiKey = this.resolveApiKey(this.config.apiKey)
|
|
113
|
+
const res = await fetch(`${baseUrl}/models`, {
|
|
114
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
115
|
+
})
|
|
116
|
+
return res.ok
|
|
117
|
+
} catch {
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private convertMessages(
|
|
123
|
+
messages: Message[],
|
|
124
|
+
systemPrompt?: string,
|
|
125
|
+
): { role: string; content: string | unknown[] }[] {
|
|
126
|
+
const result: { role: string; content: string | unknown[] }[] = []
|
|
127
|
+
|
|
128
|
+
if (systemPrompt) {
|
|
129
|
+
result.push({ role: 'system', content: systemPrompt })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const msg of messages) {
|
|
133
|
+
if (typeof msg.content === 'string') {
|
|
134
|
+
result.push({ role: msg.role, content: msg.content })
|
|
135
|
+
} else {
|
|
136
|
+
const parts: unknown[] = []
|
|
137
|
+
for (const block of msg.content) {
|
|
138
|
+
if (block.type === 'text') {
|
|
139
|
+
parts.push({ type: 'text', text: block.text })
|
|
140
|
+
} else if (block.type === 'image_url') {
|
|
141
|
+
parts.push({ type: 'image_url', image_url: block.image_url })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
result.push({ role: msg.role, content: parts })
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return result
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private resolveApiKey(keyTemplate: string): string {
|
|
152
|
+
const match = keyTemplate.match(/^\$\{(.+)\}$/)
|
|
153
|
+
if (match?.[1]) {
|
|
154
|
+
return process.env[match[1]] || ''
|
|
155
|
+
}
|
|
156
|
+
return keyTemplate
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ProviderConfig, ModelInfo, Message, StreamChunk } from '../shared/index.ts'
|
|
2
|
+
|
|
3
|
+
export interface ProviderInstance {
|
|
4
|
+
config: ProviderConfig
|
|
5
|
+
chat(req: ChatRequest): AsyncGenerator<StreamChunk>
|
|
6
|
+
listModels(): Promise<ModelInfo[]>
|
|
7
|
+
healthCheck(): Promise<boolean>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ChatRequest {
|
|
11
|
+
model: string
|
|
12
|
+
messages: Message[]
|
|
13
|
+
systemPrompt?: string
|
|
14
|
+
tools?: Record<string, unknown>[]
|
|
15
|
+
maxTokens?: number
|
|
16
|
+
temperature?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ProviderRegistry {
|
|
20
|
+
private providers = new Map<string, ProviderInstance>()
|
|
21
|
+
private activeProviderId: string
|
|
22
|
+
private activeModelId: string
|
|
23
|
+
|
|
24
|
+
constructor(providers: ProviderConfig[], defaultProvider: string, defaultModel: string) {
|
|
25
|
+
this.activeProviderId = defaultProvider
|
|
26
|
+
this.activeModelId = defaultModel
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
register(id: string, instance: ProviderInstance): void {
|
|
30
|
+
this.providers.set(id, instance)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get(id: string): ProviderInstance | undefined {
|
|
34
|
+
return this.providers.get(id)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getActive(): ProviderInstance {
|
|
38
|
+
const p = this.providers.get(this.activeProviderId)
|
|
39
|
+
if (!p) throw new Error(`Provider "${this.activeProviderId}" not registered`)
|
|
40
|
+
return p
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getActiveModel(): string {
|
|
44
|
+
return this.activeModelId
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
switchProvider(providerId: string, modelId?: string): void {
|
|
48
|
+
if (!this.providers.has(providerId)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Provider "${providerId}" not registered. Available: ${this.listIds().join(', ')}`,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
this.activeProviderId = providerId
|
|
54
|
+
if (modelId) this.activeModelId = modelId
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
listIds(): string[] {
|
|
58
|
+
return Array.from(this.providers.keys())
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
listModels(): ModelInfo[] {
|
|
62
|
+
const provider = this.getActive()
|
|
63
|
+
return provider.config.models.filter((m) => m.status === 'active')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async *chat(req: ChatRequest): AsyncGenerator<StreamChunk> {
|
|
67
|
+
const provider = this.getActive()
|
|
68
|
+
yield* provider.chat({ ...req, model: req.model || this.activeModelId })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { resolve, normalize } from 'node:path'
|
|
2
|
+
import { realpathSync, existsSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sensitive system paths that tools should never access.
|
|
6
|
+
* Relative to filesystem root; checked after resolving symlinks.
|
|
7
|
+
*/
|
|
8
|
+
const BLOCKED_PATHS = ['/etc', '/proc', '/sys', '/dev', '/boot', '/root']
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a user-supplied path relative to cwd, with sandbox enforcement.
|
|
12
|
+
*
|
|
13
|
+
* Rules:
|
|
14
|
+
* - The resolved canonical path MUST be within `cwd` (or a subdirectory).
|
|
15
|
+
* - Symlinks are resolved via realpathSync so attackers can't use
|
|
16
|
+
* `ln -s /etc project/foo` to escape the sandbox.
|
|
17
|
+
* - Sensitive system directories are always rejected.
|
|
18
|
+
* - If the path doesn't exist yet (e.g. for Write), we resolve its parent
|
|
19
|
+
* directory and check that the target is still within cwd.
|
|
20
|
+
*
|
|
21
|
+
* @returns The safe canonical path.
|
|
22
|
+
* @throws {Error} with a human-readable message if the path is blocked.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveSafe(cwd: string, inputPath: string): string {
|
|
25
|
+
const raw = resolve(cwd, inputPath)
|
|
26
|
+
const normalized = normalize(raw)
|
|
27
|
+
|
|
28
|
+
// Resolve symlinks if the path (or its parent) already exists on disk
|
|
29
|
+
let canonical: string
|
|
30
|
+
if (existsSync(normalized)) {
|
|
31
|
+
canonical = realpathSync(normalized)
|
|
32
|
+
} else {
|
|
33
|
+
// For paths that don't exist yet (e.g. Write tool creating a new file),
|
|
34
|
+
// walk up to find the first existing parent, resolve that, then
|
|
35
|
+
// reconstruct the full path.
|
|
36
|
+
const existingParent = findExistingParent(normalized)
|
|
37
|
+
if (!existingParent) {
|
|
38
|
+
throw new Error(`Path rejected: no existing parent directory found for "${inputPath}"`)
|
|
39
|
+
}
|
|
40
|
+
const realParent = realpathSync(existingParent)
|
|
41
|
+
const relative = normalized.slice(existingParent.length)
|
|
42
|
+
canonical = realParent + relative
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Normalize cwd as well (it may contain symlinks)
|
|
46
|
+
const realCwd = realpathSync(cwd)
|
|
47
|
+
|
|
48
|
+
// Check 1: must be within cwd
|
|
49
|
+
if (!isWithin(canonical, realCwd)) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Path rejected: "${inputPath}" is outside the project workspace.\n` +
|
|
52
|
+
`Resolved: ${canonical}\nWorkspace: ${realCwd}`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check 2: must not target sensitive system directories
|
|
57
|
+
for (const blocked of BLOCKED_PATHS) {
|
|
58
|
+
if (canonical === blocked || canonical.startsWith(blocked + '/')) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Path rejected: "${inputPath}" resolves to a protected system directory (${blocked}).`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return canonical
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Find the first existing parent directory of a path.
|
|
70
|
+
* Returns undefined if no parent exists (shouldn't happen for valid paths).
|
|
71
|
+
*/
|
|
72
|
+
function findExistingParent(p: string): string | undefined {
|
|
73
|
+
let current = p
|
|
74
|
+
while (current.length > 1) {
|
|
75
|
+
const parent = current.slice(0, current.lastIndexOf('/')) || '/'
|
|
76
|
+
if (existsSync(parent)) return parent
|
|
77
|
+
current = parent
|
|
78
|
+
}
|
|
79
|
+
return undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if `child` is within `parent` (or equal to it).
|
|
84
|
+
*/
|
|
85
|
+
function isWithin(child: string, parent: string): boolean {
|
|
86
|
+
// Normalize trailing slashes for comparison
|
|
87
|
+
const c = child.endsWith('/') ? child.slice(0, -1) : child
|
|
88
|
+
const p = parent.endsWith('/') ? parent.slice(0, -1) : parent
|
|
89
|
+
return c === p || c.startsWith(p + '/')
|
|
90
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import dns from 'node:dns'
|
|
2
|
+
const { lookupSync } = dns as unknown as {
|
|
3
|
+
lookupSync: (
|
|
4
|
+
hostname: string,
|
|
5
|
+
options?: { all?: boolean },
|
|
6
|
+
) => Array<{ address: string; family: number }>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// ── Protocol allow-list ──
|
|
10
|
+
const ALLOWED_PROTOCOLS = ['http:', 'https:']
|
|
11
|
+
|
|
12
|
+
// ── IPv4 private / reserved ranges ──
|
|
13
|
+
const IPV4_BLOCKED = [
|
|
14
|
+
{ prefix: 0x7f000000, mask: 0xff000000 }, // 127.0.0.0/8 loopback
|
|
15
|
+
{ prefix: 0x0a000000, mask: 0xff000000 }, // 10.0.0.0/8 private
|
|
16
|
+
{ prefix: 0xac100000, mask: 0xfff00000 }, // 172.16.0.0/12 private
|
|
17
|
+
{ prefix: 0xc0a80000, mask: 0xffff0000 }, // 192.168.0.0/16 private
|
|
18
|
+
{ prefix: 0x00000000, mask: 0xff000000 }, // 0.0.0.0/8 "this network"
|
|
19
|
+
{ prefix: 0xa9fe0000, mask: 0xffff0000 }, // 169.254.0.0/16 link-local
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
/** Validate a URL for SSRF safety. Returns null if safe, or an error string. */
|
|
23
|
+
export function validateUrl(urlStr: string): string | null {
|
|
24
|
+
let parsed: URL
|
|
25
|
+
try {
|
|
26
|
+
parsed = new URL(urlStr)
|
|
27
|
+
} catch {
|
|
28
|
+
return `Invalid URL: "${urlStr}"`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 1. Protocol allow-list
|
|
32
|
+
if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
|
|
33
|
+
return `URL protocol "${parsed.protocol}" is not allowed. Only http: and https: are permitted.`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Hostname may include brackets for IPv6 (e.g. "[::1]") — strip them for checks
|
|
37
|
+
const hostname = parsed.hostname
|
|
38
|
+
|
|
39
|
+
// 2. Check for raw IPv4 in hostname
|
|
40
|
+
if (isBlockedIPv4(hostname)) {
|
|
41
|
+
return `URL hostname "${hostname}" resolves to a blocked IP range (private / loopback / link-local).`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 3. Check for raw IPv6 loopback / private
|
|
45
|
+
// Strip brackets from IPv6 hostnames (Bun's URL parser keeps them)
|
|
46
|
+
const ipv6Host = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname
|
|
47
|
+
if (isBlockedIPv6(ipv6Host)) {
|
|
48
|
+
return `URL hostname "${hostname}" resolves to a blocked IPv6 address.`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 4. DNS rebinding protection: resolve hostname and check resolved IPs
|
|
52
|
+
// Use the bracket-less version for DNS lookups
|
|
53
|
+
const dnsHostname = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname
|
|
54
|
+
try {
|
|
55
|
+
const addresses = lookupSync(dnsHostname, { all: true })
|
|
56
|
+
for (const addr of addresses) {
|
|
57
|
+
if (isBlockedIPv4(addr.address)) {
|
|
58
|
+
return `URL hostname "${hostname}" resolved to ${addr.address} (blocked IP range).`
|
|
59
|
+
}
|
|
60
|
+
if (isBlockedIPv6(addr.address)) {
|
|
61
|
+
return `URL hostname "${hostname}" resolved to ${addr.address} (blocked IPv6 address).`
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// DNS resolution failed — that's OK for validation; the fetch will also fail
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return null // safe
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isBlockedIPv4(ip: string): boolean {
|
|
72
|
+
// Skip if it's not an IPv4 string
|
|
73
|
+
const parts = ip.split('.')
|
|
74
|
+
if (parts.length !== 4) return false
|
|
75
|
+
|
|
76
|
+
const nums = parts.map((p) => parseInt(p, 10))
|
|
77
|
+
if (nums.some((n) => isNaN(n) || n < 0 || n > 255)) return false
|
|
78
|
+
|
|
79
|
+
// Use >>> 0 to avoid signed 32-bit overflow for octets > 127
|
|
80
|
+
const asInt = ((nums[0]! << 24) | (nums[1]! << 16) | (nums[2]! << 8) | nums[3]!) >>> 0
|
|
81
|
+
|
|
82
|
+
return IPV4_BLOCKED.some(({ prefix, mask }) => (asInt & mask) >>> 0 === prefix >>> 0)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isBlockedIPv6(ip: string): boolean {
|
|
86
|
+
// IPv6 loopback
|
|
87
|
+
if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') return true
|
|
88
|
+
// IPv6 link-local (fe80::/10)
|
|
89
|
+
if (ip.startsWith('fe8') || ip.startsWith('fe9') || ip.startsWith('fea') || ip.startsWith('feb'))
|
|
90
|
+
return true
|
|
91
|
+
// IPv6 unique local (fc00::/7)
|
|
92
|
+
if (ip.startsWith('fc') || ip.startsWith('fd')) return true
|
|
93
|
+
// IPv6 unspecified
|
|
94
|
+
if (ip === '::' || ip === '0:0:0:0:0:0:0:0') return true
|
|
95
|
+
return false
|
|
96
|
+
}
|