@xiaobuyu/nodesign 0.0.1
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/LICENSE +661 -0
- package/README.md +140 -0
- package/bin/nodesign.js +176 -0
- package/package.json +104 -0
- package/server/api/README.md +45 -0
- package/server/api/_guard.js +75 -0
- package/server/api/admin.js +187 -0
- package/server/api/assets/docx-page.js +103 -0
- package/server/api/assets/helpers.js +22 -0
- package/server/api/assets/notes.js +144 -0
- package/server/api/assets.js +896 -0
- package/server/api/board.js +57 -0
- package/server/api/browse.js +138 -0
- package/server/api/canvas.js +444 -0
- package/server/api/chatai.js +324 -0
- package/server/api/exports/build-standalone.js +980 -0
- package/server/api/exports/cards.js +118 -0
- package/server/api/exports/docx-pdf.js +46 -0
- package/server/api/exports/export-page.js +142 -0
- package/server/api/exports/handoff.js +147 -0
- package/server/api/exports.js +690 -0
- package/server/api/instruction.js +75 -0
- package/server/api/local.js +115 -0
- package/server/api/me.js +116 -0
- package/server/api/memory.js +185 -0
- package/server/api/pending-changes.js +366 -0
- package/server/api/plugins.js +164 -0
- package/server/api/projects.js +220 -0
- package/server/api/publish.js +64 -0
- package/server/api/recent.js +73 -0
- package/server/api/sessions.js +589 -0
- package/server/api/skills.js +77 -0
- package/server/api/standalone-fit.js +168 -0
- package/server/api/turn-compose.js +180 -0
- package/server/api/turn-inflight.js +44 -0
- package/server/api/turn.js +669 -0
- package/server/auth/README.md +17 -0
- package/server/auth/middleware.js +138 -0
- package/server/auth/origin-guard.js +71 -0
- package/server/auth/session.js +108 -0
- package/server/auth/tier.js +113 -0
- package/server/auth/users-store.js +348 -0
- package/server/edit/README.md +29 -0
- package/server/engine/README.md +113 -0
- package/server/engine/agent/agent-shared.js +550 -0
- package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -0
- package/server/engine/agent/auto-mode-rules.js +78 -0
- package/server/engine/agent/context.js +418 -0
- package/server/engine/agent/events.js +464 -0
- package/server/engine/agent/hooks/canvas-validate.js +199 -0
- package/server/engine/agent/hooks/failure.js +120 -0
- package/server/engine/agent/hooks/file-events.js +75 -0
- package/server/engine/agent/hooks/lifecycle.js +233 -0
- package/server/engine/agent/hooks/post-canvas-focus.js +80 -0
- package/server/engine/agent/hooks/post-guidance.js +206 -0
- package/server/engine/agent/hooks/post-subagent-report.js +104 -0
- package/server/engine/agent/hooks/post-trim.js +58 -0
- package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -0
- package/server/engine/agent/hooks/pre-defaults.js +84 -0
- package/server/engine/agent/hooks/pre-injectors.js +277 -0
- package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -0
- package/server/engine/agent/hooks/pre-starter-files.js +61 -0
- package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -0
- package/server/engine/agent/hooks/site-validate.js +94 -0
- package/server/engine/agent/hooks/tool-prompts.js +31 -0
- package/server/engine/agent/hooks/turn-state-memory.js +56 -0
- package/server/engine/agent/hooks/user-prompt-submit.js +215 -0
- package/server/engine/agent/hooks.js +354 -0
- package/server/engine/agent/init-contract.js +69 -0
- package/server/engine/agent/isolation.js +159 -0
- package/server/engine/agent/model-context.js +359 -0
- package/server/engine/agent/model-table.js +292 -0
- package/server/engine/agent/plugin-loader.js +266 -0
- package/server/engine/agent/prompts/nodesign-prelude.md +336 -0
- package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -0
- package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -0
- package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -0
- package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -0
- package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -0
- package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -0
- package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -0
- package/server/engine/agent/prompts/tools/site-reference.md +106 -0
- package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -0
- package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -0
- package/server/engine/agent/session-loop.js +1024 -0
- package/server/engine/agent/session-model.js +164 -0
- package/server/engine/agent/skill.js +204 -0
- package/server/engine/agent/system-prompts.js +86 -0
- package/server/engine/agent/task-events.js +78 -0
- package/server/engine/agents/ds-extractor.md +132 -0
- package/server/engine/agents/explorer.md +190 -0
- package/server/engine/agents/index.js +230 -0
- package/server/engine/agents/schemas/design-system.json +156 -0
- package/server/engine/agents/schemas/tweak-schema.json +113 -0
- package/server/engine/agents/tweak-proposer.md +127 -0
- package/server/engine/agents/vision-checker.md +254 -0
- package/server/engine/browse/capture.js +422 -0
- package/server/engine/browse/card.js +137 -0
- package/server/engine/browse/handover.js +59 -0
- package/server/engine/browse/page-digest.js +119 -0
- package/server/engine/browse/refs.js +196 -0
- package/server/engine/browse/registry.js +307 -0
- package/server/engine/browse/screencast.js +170 -0
- package/server/engine/browse/state.js +121 -0
- package/server/engine/chatai/chat-log.js +79 -0
- package/server/engine/chatai/index.js +193 -0
- package/server/engine/chatai/openai-compat.js +152 -0
- package/server/engine/chatai/orchestrate.js +309 -0
- package/server/engine/chatai/perform.js +40 -0
- package/server/engine/chatai/summarize.js +79 -0
- package/server/engine/mcp/capability-gate.js +52 -0
- package/server/engine/mcp/index.js +339 -0
- package/server/engine/mcp/param-sanitizer.js +142 -0
- package/server/engine/mcp/tools/arrange-on-board.js +124 -0
- package/server/engine/mcp/tools/artifact-session.js +278 -0
- package/server/engine/mcp/tools/browse-computer.js +421 -0
- package/server/engine/mcp/tools/browse-find-batch.js +183 -0
- package/server/engine/mcp/tools/browse-screenshot.js +157 -0
- package/server/engine/mcp/tools/browse.js +480 -0
- package/server/engine/mcp/tools/build-docx.js +101 -0
- package/server/engine/mcp/tools/clear-pending-changes.js +99 -0
- package/server/engine/mcp/tools/create-on-board.js +155 -0
- package/server/engine/mcp/tools/crystallize-skill.js +168 -0
- package/server/engine/mcp/tools/deliver-files.js +0 -0
- package/server/engine/mcp/tools/explain-style.js +237 -0
- package/server/engine/mcp/tools/export-handoff.js +133 -0
- package/server/engine/mcp/tools/expose-tweaks.js +149 -0
- package/server/engine/mcp/tools/generate-image.js +842 -0
- package/server/engine/mcp/tools/get-computed-styles.js +164 -0
- package/server/engine/mcp/tools/get-pending-changes.js +205 -0
- package/server/engine/mcp/tools/h3box-ssh.js +84 -0
- package/server/engine/mcp/tools/helpers/acquire-page.js +82 -0
- package/server/engine/mcp/tools/helpers/motion-lab.js +554 -0
- package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -0
- package/server/engine/mcp/tools/helpers/perception-page.js +215 -0
- package/server/engine/mcp/tools/helpers/reference-download.js +103 -0
- package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -0
- package/server/engine/mcp/tools/helpers/rembg.js +273 -0
- package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -0
- package/server/engine/mcp/tools/highlight.js +68 -0
- package/server/engine/mcp/tools/list-pages.js +222 -0
- package/server/engine/mcp/tools/lookup-tags.js +59 -0
- package/server/engine/mcp/tools/navigate-to-page.js +60 -0
- package/server/engine/mcp/tools/organize-board.js +64 -0
- package/server/engine/mcp/tools/paint-still.js +434 -0
- package/server/engine/mcp/tools/pin-to-board.js +130 -0
- package/server/engine/mcp/tools/preview-deck.js +130 -0
- package/server/engine/mcp/tools/profile-scroll.js +305 -0
- package/server/engine/mcp/tools/publish-site.js +102 -0
- package/server/engine/mcp/tools/query-elements.js +195 -0
- package/server/engine/mcp/tools/read-board.js +103 -0
- package/server/engine/mcp/tools/read-document.js +93 -0
- package/server/engine/mcp/tools/read-page.js +264 -0
- package/server/engine/mcp/tools/read-tavern-json.js +143 -0
- package/server/engine/mcp/tools/record-decision.js +140 -0
- package/server/engine/mcp/tools/relate-on-board.js +110 -0
- package/server/engine/mcp/tools/remove-background.js +391 -0
- package/server/engine/mcp/tools/report-issue.js +115 -0
- package/server/engine/mcp/tools/roll-film.js +266 -0
- package/server/engine/mcp/tools/screenshot-docx.js +138 -0
- package/server/engine/mcp/tools/screenshot-url.js +195 -0
- package/server/engine/mcp/tools/screenshot.js +583 -0
- package/server/engine/mcp/tools/tier-gate.js +96 -0
- package/server/engine/mcp/tools/trace-motion.js +215 -0
- package/server/engine/mcp/tools/web-search.js +548 -0
- package/server/engine/motion/inventory.js +349 -0
- package/server/engine/perception/session.js +235 -0
- package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -0
- package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -0
- package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -0
- package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -0
- package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -0
- package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -0
- package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -0
- package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -0
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -0
- package/server/engine/runs/active-runs.js +698 -0
- package/server/engine/runs/live-turn.js +252 -0
- package/server/engine/runs/store.js +364 -0
- package/server/engine/runs/turn-relay.js +217 -0
- package/server/engine/runtime/workspace.js +164 -0
- package/server/index.js +235 -0
- package/server/lib/artifact-file-path.js +80 -0
- package/server/lib/artifact-target.js +284 -0
- package/server/lib/asset-refs.js +239 -0
- package/server/lib/async-queue.js +104 -0
- package/server/lib/auto-relations.js +130 -0
- package/server/lib/binding-types.js +60 -0
- package/server/lib/board-kind-sizes.js +62 -0
- package/server/lib/board-relations.js +133 -0
- package/server/lib/browse-proxy.js +180 -0
- package/server/lib/canvas-id.js +36 -0
- package/server/lib/cover.js +227 -0
- package/server/lib/danbooru-tags.js +291 -0
- package/server/lib/doc-extract.js +156 -0
- package/server/lib/docx/build-from-source.js +260 -0
- package/server/lib/docx/build.js +490 -0
- package/server/lib/docx/dump-styles.js +287 -0
- package/server/lib/docx/fonts/nodesign-cjk.conf +164 -0
- package/server/lib/docx/merge-runs.js +129 -0
- package/server/lib/docx/numbering.js +218 -0
- package/server/lib/docx/order.js +152 -0
- package/server/lib/docx/rawzip.js +172 -0
- package/server/lib/docx/render.js +92 -0
- package/server/lib/docx/text-lint.js +225 -0
- package/server/lib/docx/tokens.js +335 -0
- package/server/lib/docx/units.js +29 -0
- package/server/lib/docx/xml.js +291 -0
- package/server/lib/docx-pages.js +187 -0
- package/server/lib/export-collect.js +289 -0
- package/server/lib/export-package.js +210 -0
- package/server/lib/html-srcset.js +145 -0
- package/server/lib/image-variant.js +460 -0
- package/server/lib/ingress/forward-openai-chat.js +305 -0
- package/server/lib/ingress/openai-chat.js +491 -0
- package/server/lib/ingress/session-notice.js +66 -0
- package/server/lib/ingress/session-routes.js +68 -0
- package/server/lib/ingress/slot-probe.js +130 -0
- package/server/lib/ingress/upstream-billing.js +52 -0
- package/server/lib/ingress/upstream-fail-streak.js +72 -0
- package/server/lib/ingress/upstream-truncation.js +48 -0
- package/server/lib/issues-store.js +182 -0
- package/server/lib/kinds/deck.js +72 -0
- package/server/lib/kinds/docx.js +283 -0
- package/server/lib/kinds/file-kinds.js +113 -0
- package/server/lib/kinds/index.js +202 -0
- package/server/lib/kinds/site.js +161 -0
- package/server/lib/model-ingress.js +543 -0
- package/server/lib/moderation.js +255 -0
- package/server/lib/notice-store.js +103 -0
- package/server/lib/plugin-install.js +104 -0
- package/server/lib/plugin-validator.js +721 -0
- package/server/lib/publish-store.js +115 -0
- package/server/lib/quick-summary.js +52 -0
- package/server/lib/quota.js +270 -0
- package/server/lib/rate-window.js +29 -0
- package/server/lib/reference-assets.js +92 -0
- package/server/lib/region-shot.js +135 -0
- package/server/lib/safe-path.js +73 -0
- package/server/lib/sdk-session.js +33 -0
- package/server/lib/showcase-store.js +92 -0
- package/server/lib/site-publish.js +465 -0
- package/server/lib/ssrf-guard.js +432 -0
- package/server/lib/task-scan.js +158 -0
- package/server/lib/tavern-json.js +154 -0
- package/server/lib/video-variant.js +237 -0
- package/server/lib/workspace-path.js +33 -0
- package/server/ops/fix-sdk-musl.mjs +40 -0
- package/server/ops/install-macos-fonts.sh +114 -0
- package/server/ops/macos-fonts.conf +336 -0
- package/server/ops/sandbox-shim/bwrap +57 -0
- package/server/projects/assets-summary.js +119 -0
- package/server/projects/auto-name.js +53 -0
- package/server/projects/board-store.js +640 -0
- package/server/projects/move-entry.js +103 -0
- package/server/projects/store.js +270 -0
- package/server/projects/ui-config.js +54 -0
- package/server/projects/workspace-templates.js +53 -0
- package/server/projects/workspace.js +1025 -0
- package/server/runtime/capabilities.js +153 -0
- package/server/runtime/local-config.js +177 -0
- package/server/runtime/local-env.js +88 -0
- package/server/runtime/platform.js +342 -0
- package/server/runtime/profile.js +72 -0
- package/server/services/rembg-launcher.js +313 -0
- package/server/services/rembg-service.py +334 -0
- package/server/shared/README.md +25 -0
- package/server/shared/deck.js +54 -0
- package/server/shared/time.js +49 -0
- package/server/style-pipeline/README.md +53 -0
- package/server/ws/broker.js +36 -0
- package/server/ws/browse-channel.js +177 -0
- package/server/ws/index.js +386 -0
- package/web/README.md +89 -0
- package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +11 -0
- package/web/dist/assets/DeckWindow-4G0xX7P3.js +16 -0
- package/web/dist/assets/DocxWindow-BXQsoPkh.js +16 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/dist/assets/SiteWindow-D6eS-2gM.js +18 -0
- package/web/dist/assets/again-D8tfgKKO.webp +0 -0
- package/web/dist/assets/caveat-nd-CP6HlsNg.woff2 +0 -0
- package/web/dist/assets/clock-xJhvfYpS.webp +0 -0
- package/web/dist/assets/film-cut-BBKMwH7q.webp +0 -0
- package/web/dist/assets/film-still-BZ_DL9Tt.webp +0 -0
- package/web/dist/assets/index-C0KUf6Wd.css +1 -0
- package/web/dist/assets/index-Cbl3ATvp.js +2125 -0
- package/web/dist/assets/ink-desk-BRbxeDsR.webp +0 -0
- package/web/dist/assets/ink-night-Bz4BT3XH.webp +0 -0
- package/web/dist/assets/ink-portrait-C0DM4V8Q.webp +0 -0
- package/web/dist/assets/longcang-regular-C0uvTuKa.woff2 +0 -0
- package/web/dist/assets/lxgw-nd-bold-DbRfiTgo.woff2 +0 -0
- package/web/dist/assets/lxgw-nd-regular-CpMw9Ehg.woff2 +0 -0
- package/web/dist/assets/pending-edit-apply-DTjo2geW.js +31 -0
- package/web/dist/assets/question-8H4HSqxh.webp +0 -0
- package/web/dist/assets/reject-BaPJqElj.webp +0 -0
- package/web/dist/assets/rotate-cw-LMT3x1Zj.js +11 -0
- package/web/dist/assets/rp-portrait-CbrGpew2.webp +0 -0
- package/web/dist/assets/rp-street-C4jVKjxx.webp +0 -0
- package/web/dist/assets/square-dashed-mouse-pointer-CBoAkfhi.js +11 -0
- package/web/dist/assets/tangle-DIxHVNuT.webp +0 -0
- package/web/dist/assets/thisone-BylZKglV.webp +0 -0
- package/web/dist/assets/thumb-DO1wfK1I.webp +0 -0
- package/web/dist/index.html +18 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/lib/ssrf-guard.js — 出网地址闸(2026-08-18)
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么要有这一层,以及为什么它必须在工具的实现体里
|
|
5
|
+
*
|
|
6
|
+
* agent 能自由指定 URL 的地方(截外部站、驱动浏览器)就是一台 SSRF 机器:它跑在
|
|
7
|
+
* 服务端的网络命名空间里,够得到我们自己的 API、exp 实例、本机各种监听端口、
|
|
8
|
+
* 云元数据地址。
|
|
9
|
+
*
|
|
10
|
+
* ⛔ **沙盒帮不上忙**:MCP 工具的 handler 全在 server 主进程里,而生产
|
|
11
|
+
* `NODESIGN_SANDBOX` 根本没开;即便开了,bwrap 和 `permissions.deny` 管的是 SDK 的
|
|
12
|
+
* Read/Write/Edit/Bash,**管不到 HTTP 路由和 MCP handler 的实现体**。所以这道闸
|
|
13
|
+
* 只能长在工具自己的代码里 —— 好处是 agent 关不掉它(它连沙盒都不碰)。
|
|
14
|
+
*
|
|
15
|
+
* ## 三条判据(缺一条都能被绕过)
|
|
16
|
+
*
|
|
17
|
+
* 1. **按解析出来的 IP 判,不按主机名判。** 词法检查挡得住 `127.0.0.1` 字面量,
|
|
18
|
+
* 挡不住一个 DNS 解析到 127.0.0.1 的公网域名。
|
|
19
|
+
* (`screenshot-url.js:30-49` 就是纯词法的那一档,这个模块是来替换它的。)
|
|
20
|
+
* 2. **每一个请求都要过,不只是第一个。** 302 的下一跳、iframe、`<img>`、fetch/XHR
|
|
21
|
+
* 的子资源各自都是请求。只查首个 URL 等于只锁前门。
|
|
22
|
+
* ⛔ **实测推翻过一个想当然的做法**:playwright 的 `context.route('**\/*')`
|
|
23
|
+
* **不拦跳转的下一跳** —— 本机 302 到 `https://example.com/`,浏览器实际到达了
|
|
24
|
+
* (`page.url()` 就是它),而 route 只被调用了一次(第一跳)。照那个做法,
|
|
25
|
+
* `https://evil/` → `http://127.0.0.1:4001/` 会直接穿过去。
|
|
26
|
+
* 改用 **CDP `Fetch.requestPaused`**,实测它拦得到:导航的跳转目标、
|
|
27
|
+
* **子资源的跳转目标**(`<img>` 302 到元数据)、以及全部子资源。
|
|
28
|
+
* 附带好处:响应体不绕道 node(route.fetch 那条路要代理全部流量,1 vCPU 付不起)。
|
|
29
|
+
* 3. ⭐ **本机自有 IP 也要禁。** 我们的 API 监听的是 `*:4001`(绑所有网卡,`ss` 实查),
|
|
30
|
+
* 所以只禁 `127.0.0.1` 挡不住"绕本机的 LAN/公网 IP 打自己"。
|
|
31
|
+
*
|
|
32
|
+
* ## 已知残余风险(诚实标注)
|
|
33
|
+
*
|
|
34
|
+
* **DNS 重绑定是缓解不是根除。** 我们在 route 层解析一次,playwright 真连的时候
|
|
35
|
+
* 会再解析一次,两次之间存在 TOCTOU 窗口。第二道防线是连上之后看 CDP 报的
|
|
36
|
+
* `remoteIPAddress`,但那已经是"数据可能开始返回"之后的补救。要根除得自己解析→
|
|
37
|
+
* pin 住 IP→带 Host 头连,playwright 的 route 模型做不干净。
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import os from 'node:os';
|
|
41
|
+
import dns from 'node:dns/promises';
|
|
42
|
+
import net from 'node:net';
|
|
43
|
+
|
|
44
|
+
/** IPv4 段(CIDR),含各类保留段 —— 不只私网,元数据和 CGNAT 也在里面 */
|
|
45
|
+
const V4_BLOCKS = [
|
|
46
|
+
['0.0.0.0', 8], // 本网络
|
|
47
|
+
['10.0.0.0', 8], // RFC1918
|
|
48
|
+
['100.64.0.0', 10], // CGNAT
|
|
49
|
+
['127.0.0.0', 8], // loopback
|
|
50
|
+
['169.254.0.0', 16], // link-local,含 GCP/AWS 元数据 169.254.169.254
|
|
51
|
+
['172.16.0.0', 12], // RFC1918
|
|
52
|
+
['192.0.0.0', 24], // IETF 协议分配
|
|
53
|
+
['192.0.2.0', 24], // TEST-NET-1
|
|
54
|
+
['192.88.99.0', 24], // 6to4 relay(已弃用)
|
|
55
|
+
['192.168.0.0', 16], // RFC1918
|
|
56
|
+
['198.18.0.0', 15], // 基准测试
|
|
57
|
+
['198.51.100.0', 24], // TEST-NET-2
|
|
58
|
+
['203.0.113.0', 24], // TEST-NET-3
|
|
59
|
+
['224.0.0.0', 4], // 组播
|
|
60
|
+
['240.0.0.0', 4], // 保留(含 255.255.255.255)
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/** IPv6 前缀(小写、去零压缩前的字面前缀匹配即可覆盖) */
|
|
64
|
+
const V6_PREFIXES = ['::1', '::', 'fc', 'fd', 'fe8', 'fe9', 'fea', 'feb', 'ff'];
|
|
65
|
+
|
|
66
|
+
const v4ToInt = (ip) => ip.split('.').reduce((a, o) => (a << 8 >>> 0) + (Number(o) & 255), 0) >>> 0;
|
|
67
|
+
|
|
68
|
+
function v4Blocked(ip) {
|
|
69
|
+
const n = v4ToInt(ip);
|
|
70
|
+
for (const [base, bits] of V4_BLOCKS) {
|
|
71
|
+
const mask = bits === 0 ? 0 : (0xFFFFFFFF << (32 - bits)) >>> 0;
|
|
72
|
+
if ((n & mask) === (v4ToInt(base) & mask)) return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* IPv6 字面量 → 16 字节。**认不出就返 null,调用方要 fail closed。**
|
|
79
|
+
*
|
|
80
|
+
* ⚠️ 为什么不再靠字符串前缀 + 几个正则:那套只认它见过的写法。审查实测三种写法
|
|
81
|
+
* 从第二道闸下直接走过去:
|
|
82
|
+
* `0:0:0:0:0:0:0:1`(::1 的展开形)、`::7f00:1`(IPv4-compatible 的十六进制形)、
|
|
83
|
+
* `0177.0.0.1`(八进制的 127.0.0.1)。
|
|
84
|
+
* 第一道闸(checkUrl)因为走 WHATWG URL 规范化不受影响,但第二道闸拿的是
|
|
85
|
+
* CDP 的 `remoteIPAddress`,判据自己得会规范化。**先规范化再判,别列写法。**
|
|
86
|
+
*/
|
|
87
|
+
function v6Bytes(ip) {
|
|
88
|
+
if (!net.isIPv6(ip)) return null;
|
|
89
|
+
let head = ip, tail = '';
|
|
90
|
+
const dbl = ip.indexOf('::');
|
|
91
|
+
if (dbl !== -1) { head = ip.slice(0, dbl); tail = ip.slice(dbl + 2); }
|
|
92
|
+
const toGroups = (part) => {
|
|
93
|
+
if (!part) return [];
|
|
94
|
+
const gs = part.split(':');
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const g of gs) {
|
|
97
|
+
if (g.includes('.')) { // 尾部内嵌的点分四段
|
|
98
|
+
const o = g.split('.').map(Number);
|
|
99
|
+
if (o.length !== 4 || o.some(n => !Number.isInteger(n) || n < 0 || n > 255)) return null;
|
|
100
|
+
out.push((o[0] << 8) | o[1], (o[2] << 8) | o[3]);
|
|
101
|
+
} else {
|
|
102
|
+
if (!/^[0-9a-f]{1,4}$/i.test(g)) return null;
|
|
103
|
+
out.push(parseInt(g, 16));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
};
|
|
108
|
+
const h = toGroups(head), t = toGroups(tail);
|
|
109
|
+
if (h === null || t === null) return null;
|
|
110
|
+
const fill = 8 - h.length - t.length;
|
|
111
|
+
if (dbl === -1 ? fill !== 0 : fill < 0) return null;
|
|
112
|
+
const groups = [...h, ...new Array(dbl === -1 ? 0 : fill).fill(0), ...t];
|
|
113
|
+
if (groups.length !== 8) return null;
|
|
114
|
+
const b = [];
|
|
115
|
+
for (const g of groups) b.push((g >> 8) & 255, g & 255);
|
|
116
|
+
return b;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 严格十进制点分四段(不接受前导零/十六进制/少于四段 —— 那些交给 fail closed) */
|
|
120
|
+
function strictV4(ip) {
|
|
121
|
+
const p = ip.split('.');
|
|
122
|
+
if (p.length !== 4) return null;
|
|
123
|
+
for (const o of p) {
|
|
124
|
+
if (!/^(0|[1-9]\d{0,2})$/.test(o) || Number(o) > 255) return null;
|
|
125
|
+
}
|
|
126
|
+
return ip;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** IPv4-mapped / NAT64 / IPv4-compatible 要**拆开按 v4 判**,否则直接绕过 */
|
|
130
|
+
function unwrapV6(ip) {
|
|
131
|
+
const b = v6Bytes(ip);
|
|
132
|
+
if (!b) {
|
|
133
|
+
// NAT64 的 well-known 前缀写法(64:ff9b::a.b.c.d 已被 v6Bytes 覆盖,
|
|
134
|
+
// 这里只兜住 net.isIPv6 认不出的怪写法 → 交给调用方 fail closed)
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const zeros12 = b.slice(0, 12).every(x => x === 0);
|
|
138
|
+
const mapped = b.slice(0, 10).every(x => x === 0) && b[10] === 0xff && b[11] === 0xff;
|
|
139
|
+
// 64:ff9b::/96 是 NAT64
|
|
140
|
+
const nat64 = b[0] === 0x00 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b
|
|
141
|
+
&& b.slice(4, 12).every(x => x === 0);
|
|
142
|
+
// ⭐ zeros12 覆盖 `::a.b.c.d` 和 `::7f00:1`(IPv4-compatible,废弃但可路由到 v4)
|
|
143
|
+
if (mapped || nat64 || (zeros12 && !(b[12] === 0 && b[13] === 0 && b[14] === 0 && b[15] <= 1))) {
|
|
144
|
+
return `${b[12]}.${b[13]}.${b[14]}.${b[15]}`;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 本机的地址集合。
|
|
151
|
+
*
|
|
152
|
+
* ⛔ **只枚举网卡是不够的**(2026-08-18 审查攻出来的):云上是 1:1 NAT,
|
|
153
|
+
* 实例的**公网 IP 根本不在任何网卡上**。实测这台机器网卡只有
|
|
154
|
+
* `127.0.0.1 / ::1 / 10.128.0.12 / fe80::…`,而它的公网 IP 是 35.209.189.19 ——
|
|
155
|
+
* `blockReason` 对它返回 null(放行),发夹 `curl https://<公网IP>:8443/` 真的 200。
|
|
156
|
+
* 也就是说「本机自有 IP 也要禁」那条判据在原来的实现下**形同没有**,
|
|
157
|
+
* 而给它写的那条单元测试是同义反复(枚举 ownAddresses 再断言它们被禁,
|
|
158
|
+
* 结构上不可能失败)。
|
|
159
|
+
*
|
|
160
|
+
* 所以三个来源并起来:
|
|
161
|
+
* ① 网卡(拿到 loopback 和内网地址)
|
|
162
|
+
* ② 云元数据里的外部 IP(**我们自己进程去读**,不是让浏览器去读 —— 那个地址
|
|
163
|
+
* 对浏览器是禁的)。取不到就算了,别的环境没这个端点。
|
|
164
|
+
* ③ `ND_PUBLIC_IP` 环境变量兜底(自建机器、多 IP、或者元数据端点关掉的情况)
|
|
165
|
+
*
|
|
166
|
+
* ⚠️ ② 是**异步**的,所以 `primeOwnAddresses()` 要在启动时调一次;没调也不会崩,
|
|
167
|
+
* 只是少了那一条(同步路径永远拿得到 ① 和 ③)。
|
|
168
|
+
*/
|
|
169
|
+
/**
|
|
170
|
+
* 网卡枚举**不能永久 memo**:DHCP 续租换地址、加一张网卡、docker0 起来都会变,
|
|
171
|
+
* 而这个进程一跑几周(`enp0s0` 的 `valid_lft` 是几万秒)。缓存久了就是
|
|
172
|
+
* "闸按一份过期的地图判"。30 秒足够摊掉每个子资源都枚举一次的开销。
|
|
173
|
+
* ⚠️ `primed` 单独存:云 NAT 的公网 IP 不在任何网卡上,重新枚举会把它冲掉。
|
|
174
|
+
*/
|
|
175
|
+
const OWN_TTL_MS = 30_000;
|
|
176
|
+
let ownCache = null;
|
|
177
|
+
let ownCacheAt = 0;
|
|
178
|
+
const primed = new Set();
|
|
179
|
+
function baseOwn() {
|
|
180
|
+
const set = new Set(primed);
|
|
181
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
182
|
+
for (const a of list || []) set.add(String(a.address).toLowerCase().replace(/%.*$/, ''));
|
|
183
|
+
}
|
|
184
|
+
for (const extra of String(process.env.ND_PUBLIC_IP || '').split(',')) {
|
|
185
|
+
const v = extra.trim().toLowerCase();
|
|
186
|
+
if (v) set.add(v);
|
|
187
|
+
}
|
|
188
|
+
return set;
|
|
189
|
+
}
|
|
190
|
+
export function ownAddresses() {
|
|
191
|
+
if (!ownCache || Date.now() - ownCacheAt > OWN_TTL_MS) {
|
|
192
|
+
ownCache = baseOwn();
|
|
193
|
+
ownCacheAt = Date.now();
|
|
194
|
+
}
|
|
195
|
+
return ownCache;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 把云元数据里的外部 IP 也加进来。启动时调一次(失败静默 —— 不是所有环境都有)。
|
|
200
|
+
* @returns {Promise<string|null>} 加进来的那个 IP
|
|
201
|
+
*/
|
|
202
|
+
export async function primeOwnAddresses({ timeoutMs = 1500 } = {}) {
|
|
203
|
+
const set = ownAddresses();
|
|
204
|
+
const url = 'http://169.254.169.254/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip';
|
|
205
|
+
try {
|
|
206
|
+
const ac = new AbortController();
|
|
207
|
+
const t = setTimeout(() => ac.abort(), timeoutMs);
|
|
208
|
+
const res = await fetch(url, { headers: { 'Metadata-Flavor': 'Google' }, signal: ac.signal });
|
|
209
|
+
clearTimeout(t);
|
|
210
|
+
if (!res.ok) return null;
|
|
211
|
+
const ip = (await res.text()).trim().toLowerCase();
|
|
212
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) {
|
|
213
|
+
primed.add(ip); // 存进不会被重新枚举冲掉的那一份
|
|
214
|
+
set.add(ip);
|
|
215
|
+
console.log(`[ssrf-guard] 本机公网 IP ${ip} 已加入禁止集(云 NAT 下它不在任何网卡上)`);
|
|
216
|
+
return ip;
|
|
217
|
+
}
|
|
218
|
+
} catch { /* 没这个端点 / 超时:正常 */ }
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 这个 IP 该不该禁。
|
|
224
|
+
* @returns {string|null} 禁的理由;null = 放行
|
|
225
|
+
*/
|
|
226
|
+
export function blockReason(rawIp) {
|
|
227
|
+
if (!rawIp || typeof rawIp !== 'string') return 'empty address';
|
|
228
|
+
const ip = rawIp.toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '');
|
|
229
|
+
|
|
230
|
+
if (ownAddresses().has(ip)) {
|
|
231
|
+
return `${ip} is this machine's own address (our API listens on *:PORT — reaching it via any local NIC is the same as reaching localhost)`;
|
|
232
|
+
}
|
|
233
|
+
if (ip.includes('.') && !ip.includes(':')) {
|
|
234
|
+
// ⭐ **严格十进制才放过判定**。`0177.0.0.1` 是八进制的 127.0.0.1,
|
|
235
|
+
// 老的 `\d+\.\d+\.\d+\.\d+` 正则会把它当合法四段、按 `177` 去算 → 放行。
|
|
236
|
+
const v4 = strictV4(ip);
|
|
237
|
+
if (!v4) return `${rawIp} is not a canonical IPv4 address (leading zeros / hex forms are refused rather than guessed)`;
|
|
238
|
+
return v4Blocked(v4) ? `${ip} is a private/reserved IPv4 address` : null;
|
|
239
|
+
}
|
|
240
|
+
if (ip.includes(':')) {
|
|
241
|
+
const v4 = unwrapV6(ip);
|
|
242
|
+
if (v4) {
|
|
243
|
+
if (ownAddresses().has(v4)) return `${ip} maps to this machine's own address`;
|
|
244
|
+
return v4Blocked(v4) ? `${ip} maps to private IPv4 ${v4}` : null;
|
|
245
|
+
}
|
|
246
|
+
// 规范化成 8 组再判,别拿字面量比前缀 —— `0:0:0:0:0:0:0:1` 就是 `::1`
|
|
247
|
+
const b = v6Bytes(ip);
|
|
248
|
+
if (!b) return `${rawIp} is not a canonical IPv6 address`;
|
|
249
|
+
const canon = [];
|
|
250
|
+
for (let i = 0; i < 16; i += 2) canon.push(((b[i] << 8) | b[i + 1]).toString(16));
|
|
251
|
+
const loopback = b.slice(0, 15).every(x => x === 0) && b[15] === 1;
|
|
252
|
+
const unspecified = b.every(x => x === 0);
|
|
253
|
+
if (loopback || unspecified) return `${ip} is the IPv6 loopback/unspecified address`;
|
|
254
|
+
if (ownAddresses().has(canon.join(':').replace(/(^|:)(0:)+/, '::').replace(/::+/, '::'))) {
|
|
255
|
+
return `${ip} is this machine's own address`;
|
|
256
|
+
}
|
|
257
|
+
const first = b[0];
|
|
258
|
+
// fc00::/7 唯一本地、fe80::/10 链路本地、ff00::/8 组播
|
|
259
|
+
if ((first & 0xfe) === 0xfc) return `${ip} is a unique-local IPv6 address (fc00::/7)`;
|
|
260
|
+
if (first === 0xfe && (b[1] & 0xc0) === 0x80) return `${ip} is a link-local IPv6 address (fe80::/10)`;
|
|
261
|
+
if (first === 0xff) return `${ip} is an IPv6 multicast address`;
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
return `${rawIp} is not an IP address`; // 调用方该先解析
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** 只允许这两个 scheme 发起网络请求;data:/blob: 不出网所以另算 */
|
|
268
|
+
const NET_SCHEMES = new Set(['http:', 'https:']);
|
|
269
|
+
const OFFLINE_SCHEMES = new Set(['data:', 'blob:', 'about:']);
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 一个 URL 能不能让浏览器去。**解析 DNS 后逐个 IP 判**。
|
|
273
|
+
* @returns {Promise<{ok: true, ips: string[]} | {ok: false, reason: string}>}
|
|
274
|
+
*/
|
|
275
|
+
/**
|
|
276
|
+
* 主机名 → 判定结果的短期缓存。**只缓 DENY,不缓 ALLOW。**
|
|
277
|
+
*
|
|
278
|
+
* 一个页面几十个子资源,每个都查一次 DNS 在 1 vCPU 上不划算。但缓存方向不对称:
|
|
279
|
+
*
|
|
280
|
+
* ⛔ 原来两种都缓,注释写的是「TTL 内认一次结果,**缩小**了两次解析之间答案变了
|
|
281
|
+
* 的窗口」。这句话只在「先 DENY 后翻成公网」时成立。反方向 ——
|
|
282
|
+
* **先拿一个公网名骗到 ALLOW,再把解析翻向 127.0.0.1** —— 缓存把窗口从
|
|
283
|
+
* 「两次 DNS 之间」**放大成保证的 30 秒**:这道闸在 30 秒里根本不查 DNS。
|
|
284
|
+
* 审查时确定性复现过。
|
|
285
|
+
* ⭐ 缓 DENY 没有这个方向问题:最坏是把一个刚变干净的主机多拦 30 秒。
|
|
286
|
+
* 拦错了的代价是"看不到一个参考站",放错了的代价是内网。
|
|
287
|
+
*
|
|
288
|
+
* 真正的强制点在 `lib/browse-proxy.js`:它**每个请求**重解析并连**验过的那个 IP**
|
|
289
|
+
* (不给系统第二次解析的机会),所以常驻浏览器那条路对重绑定是免疫的。这里的
|
|
290
|
+
* checkUrl 是预筛 + `screenshot_url` 用(后者也已经走同一个代理)。
|
|
291
|
+
*/
|
|
292
|
+
const HOST_TTL_MS = 30_000;
|
|
293
|
+
const hostCache = new Map(); // host → { at, verdict }
|
|
294
|
+
|
|
295
|
+
export async function checkUrl(raw, { timeoutMs = 4000 } = {}) {
|
|
296
|
+
let u;
|
|
297
|
+
try { u = new URL(String(raw)); } catch { return { ok: false, reason: `not a valid URL: ${raw}` }; }
|
|
298
|
+
|
|
299
|
+
if (OFFLINE_SCHEMES.has(u.protocol)) return { ok: true, ips: [] }; // 不出网
|
|
300
|
+
if (!NET_SCHEMES.has(u.protocol)) {
|
|
301
|
+
return { ok: false, reason: `scheme ${u.protocol} is not allowed (only http/https; file:// would read local files)` };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
305
|
+
// 字面量 IP 不用解析(也别交给 DNS —— 有些解析器会对畸形量做意外的事)
|
|
306
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(host) || host.includes(':')) {
|
|
307
|
+
const why = blockReason(host);
|
|
308
|
+
return why ? { ok: false, reason: why } : { ok: true, ips: [host] };
|
|
309
|
+
}
|
|
310
|
+
// `.local` 是 mDNS,只可能指向局域网
|
|
311
|
+
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) {
|
|
312
|
+
return { ok: false, reason: `${host} resolves inside the local network` };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const cached = hostCache.get(host);
|
|
316
|
+
if (cached && Date.now() - cached.at < HOST_TTL_MS) return cached.verdict;
|
|
317
|
+
|
|
318
|
+
let addrs;
|
|
319
|
+
try {
|
|
320
|
+
addrs = await Promise.race([
|
|
321
|
+
dns.lookup(host, { all: true, verbatim: true }),
|
|
322
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error('dns timeout')), timeoutMs)),
|
|
323
|
+
]);
|
|
324
|
+
} catch (err) {
|
|
325
|
+
// kind:'dns' 给上层分文案用:解析失败不是策略拦截,别让调用方把这两种拒
|
|
326
|
+
// 混着说(agent 上报时曾据「硬边界」措辞推出一整套错误的排障理论)
|
|
327
|
+
return { ok: false, reason: `cannot resolve ${host}: ${err.message}`, kind: 'dns' };
|
|
328
|
+
}
|
|
329
|
+
if (!addrs.length) return { ok: false, reason: `${host} resolved to nothing`, kind: 'dns' };
|
|
330
|
+
|
|
331
|
+
// ⭐ **任一**地址落在禁止段就整个拒。多 A 记录里混一条 127.0.0.1 是标准的
|
|
332
|
+
// DNS-rebinding 起手式,"挑一个能用的"等于自己把门打开。
|
|
333
|
+
let verdict = { ok: true, ips: addrs.map(a => a.address) };
|
|
334
|
+
for (const a of addrs) {
|
|
335
|
+
const why = blockReason(a.address);
|
|
336
|
+
if (why) { verdict = { ok: false, reason: `${host} → ${why}` }; break; }
|
|
337
|
+
}
|
|
338
|
+
if (!verdict.ok) { // 只缓 DENY,见上面
|
|
339
|
+
hostCache.set(host, { at: Date.now(), verdict });
|
|
340
|
+
if (hostCache.size > 500) hostCache.delete(hostCache.keys().next().value);
|
|
341
|
+
}
|
|
342
|
+
return verdict;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 把闸装到一个 playwright BrowserContext 上。两道防线:
|
|
347
|
+
*
|
|
348
|
+
* ① **CDP `Fetch.requestPaused`** —— 每个请求(含每个跳转下一跳、iframe、子资源)
|
|
349
|
+
* 在**发出之前**被暂停,按解析出的 IP 判,不过就 `Fetch.failRequest`。
|
|
350
|
+
* 为什么不是 playwright 的 `context.route`:实测它看不到跳转的下一跳(见文件头)。
|
|
351
|
+
* ② CDP `Network.responseReceived` 的 `remoteIPAddress` —— 真连上之后再看一眼对端,
|
|
352
|
+
* 挡第一道之后被重绑定掉的那种。**这是补救不是预防**:走到这里数据可能已经在返回。
|
|
353
|
+
*
|
|
354
|
+
* ⚠️⚠️ **`armPage` 必须 await 完才能让那个页面导航。**
|
|
355
|
+
* 第一版把装闸挂在 `context.on('page')` 里 fire-and-forget,结果**攻出一个初始化
|
|
356
|
+
* 竞态**:`newPage()` 之后立刻 goto,`Fetch.enable` 还没握手完,第一次导航整个
|
|
357
|
+
* 穿过第一道闸 —— 实测直接到达了 `169.254.169.254`,闸零记账(救回来的是第二道,
|
|
358
|
+
* 而那时数据已经在返回了)。所以现在装闸是**调用方必须显式 await 的动作**。
|
|
359
|
+
* 我们自己造页面,所以这条能保证;**没经过 armPage 的页面(比如 target=_blank
|
|
360
|
+
* 弹窗)一律立刻关掉** —— 一个没装闸的页面就是一个洞,宁可功能少一点。
|
|
361
|
+
*
|
|
362
|
+
* @param {import('playwright').BrowserContext} context
|
|
363
|
+
* @param {(ev: {url: string, reason: string, stage: string}) => void} [onBlocked]
|
|
364
|
+
* @returns {Promise<{ blocked: Array, armPage: (page) => Promise<void> }>}
|
|
365
|
+
*/
|
|
366
|
+
export async function attachSsrfGuard(context, onBlocked, { proxied = false } = {}) {
|
|
367
|
+
const blocked = [];
|
|
368
|
+
const armed = new WeakSet();
|
|
369
|
+
const note = (url, reason, stage) => {
|
|
370
|
+
const rec = { url: String(url).slice(0, 300), reason, stage };
|
|
371
|
+
blocked.push(rec);
|
|
372
|
+
if (blocked.length > 200) blocked.shift();
|
|
373
|
+
try { onBlocked?.(rec); } catch { /* 记账不能变成新故障源 */ }
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
async function armPage(page) {
|
|
377
|
+
if (armed.has(page)) return;
|
|
378
|
+
const cdp = await context.newCDPSession(page); // 抛错就让调用方知道(别静默假装装上了)
|
|
379
|
+
|
|
380
|
+
cdp.on('Fetch.requestPaused', async (ev) => {
|
|
381
|
+
// ⚠️ **每条路径都必须落到 continueRequest 或 failRequest 上**。handler 里抛错
|
|
382
|
+
// 会让这个请求**永远暂停**,浏览器一直等 —— 症状跟"网站很慢"一模一样,
|
|
383
|
+
// 真跑时撞过一次 30 秒超时。
|
|
384
|
+
const id = ev.requestId;
|
|
385
|
+
const url = ev.request?.url || '(unknown)';
|
|
386
|
+
try {
|
|
387
|
+
const verdict = await checkUrl(url);
|
|
388
|
+
if (verdict.ok) return await cdp.send('Fetch.continueRequest', { requestId: id });
|
|
389
|
+
note(url, verdict.reason, 'request');
|
|
390
|
+
return await cdp.send('Fetch.failRequest', { requestId: id, errorReason: 'AccessDenied' });
|
|
391
|
+
} catch (err) {
|
|
392
|
+
if (/closed|Target|detached|Session/i.test(err?.message || '')) return; // 正常竞态
|
|
393
|
+
// 判不出来就**拒**(fail-closed):安全闸不能因为自己出错而放行
|
|
394
|
+
note(url, `guard error, denied by default: ${err?.message || err}`, 'request');
|
|
395
|
+
try { await cdp.send('Fetch.failRequest', { requestId: id, errorReason: 'Failed' }); } catch { /* */ }
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// 第二道只在**没有代理**的通道上有意义。
|
|
400
|
+
// ⚠️ 真跑抓到的:挂了 browse-proxy 之后每个响应的 `remoteIPAddress` 都是代理
|
|
401
|
+
// 自己(127.0.0.1),于是这道兜底把**每个页面**都掐掉了。而它本来防的是
|
|
402
|
+
// 「请求阶段判过之后 DNS 被翻掉」—— 代理会自己解析并**连那个验过的 IP**(pin),
|
|
403
|
+
// 重绑定在那条路上结构性不可能,所以这道检查在代理下是纯冗余。
|
|
404
|
+
if (!proxied) {
|
|
405
|
+
cdp.on('Network.responseReceived', (ev) => {
|
|
406
|
+
const ip = ev?.response?.remoteIPAddress;
|
|
407
|
+
if (!ip) return;
|
|
408
|
+
const why = blockReason(ip);
|
|
409
|
+
if (!why) return;
|
|
410
|
+
note(ev.response.url, `connected to ${why}`, 'connected');
|
|
411
|
+
page.close().catch(() => {}); // 已经连上了,能做的只有立刻掐掉
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
await cdp.send('Network.enable');
|
|
416
|
+
await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
|
|
417
|
+
armed.add(page);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 没经过 armPage 的页面(弹窗)立刻关掉。给 armPage 一个微任务的机会先登记,
|
|
421
|
+
// 免得把我们自己刚造的页面误杀。
|
|
422
|
+
context.on('page', (p) => {
|
|
423
|
+
setTimeout(() => {
|
|
424
|
+
if (armed.has(p) || p.isClosed()) return;
|
|
425
|
+
note(p.url() || '(popup)', 'popup was not armed with the network guard — closed', 'popup');
|
|
426
|
+
p.close().catch(() => {});
|
|
427
|
+
}, 250);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
for (const p of context.pages()) await armPage(p);
|
|
431
|
+
return { blocked, armPage };
|
|
432
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-scan.js — 任务目录的统一扫描规则(2026-07-29)
|
|
3
|
+
*
|
|
4
|
+
* 站点接进真实工程形态(构建步骤 / node_modules / 源目录)后,「扫任务目录」
|
|
5
|
+
* 这件事在五个地方各写各的:页面清单、整站 zip、断链检测、导出勾选、素材墙。
|
|
6
|
+
* 五份跳过规则各自为政的结果是:node_modules 被整站 zip 原样打包、构建缓存
|
|
7
|
+
* 触发刷新记账 —— 而且每一处都是静默的。所以收拢到这一个模块。
|
|
8
|
+
*
|
|
9
|
+
* 两层规则:
|
|
10
|
+
* ① 硬清单 —— 永远不看的目录(依赖 / 构建缓存 / VCS)。不可配置,
|
|
11
|
+
* 因为「不小心把 node_modules 打进 zip」不该是一个能配出来的状态。
|
|
12
|
+
* ② `.ndignore` —— 任务根下的忽略档,gitignore 语法子集,agent 自己写。
|
|
13
|
+
* 支持:# 注释、尾 `/`(只匹配目录)、头 `/`(锚定任务根)、`*` `?` `**`。
|
|
14
|
+
* 不支持 `!` 反选(要精细控制就少写点通配)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import fs from 'node:fs/promises';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 工作区里**不是产物**的那些名字。形态扫描(deck / site)一律跳过。
|
|
22
|
+
*
|
|
23
|
+
* 2026-08-07 扁平化之后才需要这张表:以前形态扫描的根是 `tasks/<任务>/`,
|
|
24
|
+
* 一个只装产物的干净目录;现在根是项目工作区,产物跟基础设施住在一起。
|
|
25
|
+
* 不挡的话有两个实测会发生的假产物:
|
|
26
|
+
* - `canvas.template.html` / `site.template.html`(skill 拷进 cwd 的起手
|
|
27
|
+
* 模板)会各自变成一张 deck 卡 —— deck 认"顶层任意 .html";
|
|
28
|
+
* - `exports/` 里导出过一个站就会多出一张站点卡 —— 站点认"子目录里有
|
|
29
|
+
* index.html",而导出包正好长这样。
|
|
30
|
+
*
|
|
31
|
+
* 点开头的(.claude / .nd / .git)扫描器本来就跳,不用列。
|
|
32
|
+
*/
|
|
33
|
+
// `agent-memory` 是老符号链接时代的残骸目录名(正规位置是 .claude/agent-memory/),
|
|
34
|
+
// 留在这里是为了它万一还在也不会作为一张文件夹卡出现在桌面上。
|
|
35
|
+
export const RESERVED_DIRS = new Set(['assets', 'exports', 'notes', 'node_modules', 'agent-memory']);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 不当产物看的文件:起手模板(拷进来是给 agent 抄/改的,不是成品)。
|
|
39
|
+
* 2026-08-15 加 js/mjs —— RP 的管线模块 `演出.template.js` 走同一条起手文件路,
|
|
40
|
+
* 规则只认 html/css 的话它会当成一张产物卡上墙。
|
|
41
|
+
*/
|
|
42
|
+
export function isReservedFile(name) {
|
|
43
|
+
return /\.template\.(html?|css|jsx?|mjs)$/i.test(name) || RESERVED_FILES.has(name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 基础设施文件:住在工作区里,但**不是用户的东西**,不该在桌面上出现。
|
|
48
|
+
*
|
|
49
|
+
* 2026-08-08 加。在这之前只挡起手模板,于是 `board.json`(画布自己的布局档)
|
|
50
|
+
* 会作为一张 .json 文件卡上墙 —— 画布把自己的存档画在自己身上。
|
|
51
|
+
*/
|
|
52
|
+
export const RESERVED_FILES = new Set([
|
|
53
|
+
'board.json', // 画布布局,画布自己的存档
|
|
54
|
+
'.nd-project.json', // 产物标记(形态兜底)
|
|
55
|
+
'.gitignore',
|
|
56
|
+
'.ndignore',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** 永远不扫的目录名(任何深度命中即剪枝) */
|
|
60
|
+
export const HARD_IGNORE_DIRS = new Set([
|
|
61
|
+
'node_modules', '.git', '.wrangler', '.cache', '.next', '.astro', '.nuxt',
|
|
62
|
+
'.svelte-kit', '.output', '.parcel-cache', '.turbo', '.vite',
|
|
63
|
+
'venv', '.venv', '__pycache__', 'coverage',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/** 展品目录:站点的试作放这里,单独渲染成卡,不算站点页面、不进 zip */
|
|
67
|
+
export const DRAFTS_DIR = '_drafts';
|
|
68
|
+
|
|
69
|
+
export const NDIGNORE_FILE = '.ndignore';
|
|
70
|
+
|
|
71
|
+
/** gitignore 子集 → RegExp。认不出的行按字面量处理(转义后精确匹配)。 */
|
|
72
|
+
function compilePattern(line) {
|
|
73
|
+
let pat = line.trim();
|
|
74
|
+
if (!pat || pat.startsWith('#')) return null;
|
|
75
|
+
const dirOnly = pat.endsWith('/');
|
|
76
|
+
if (dirOnly) pat = pat.slice(0, -1);
|
|
77
|
+
const anchored = pat.startsWith('/');
|
|
78
|
+
if (anchored) pat = pat.slice(1);
|
|
79
|
+
// 转义正则元字符,再还原通配语义(** → 任意层,* → 段内任意,? → 段内单字符)
|
|
80
|
+
let re = pat
|
|
81
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
82
|
+
.replace(/\*\*/g, '\uE000')
|
|
83
|
+
.replace(/\*/g, '[^/]*')
|
|
84
|
+
.replace(/\?/g, '[^/]')
|
|
85
|
+
.replace(/\uE000/g, '.*');
|
|
86
|
+
// 无斜杠的裸模式(`*.bak`)匹配任何一层的条目名;带斜杠 / 锚定的按路径匹配
|
|
87
|
+
const body = (!anchored && !pat.includes('/')) ? `(?:^|/)${re}` : `^${re}`;
|
|
88
|
+
return { re: new RegExp(`${body}(?:/|$)`), dirOnly };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 读任务的 `.ndignore`,返回匹配器 `(rel, isDir) => boolean`。
|
|
93
|
+
* rel 相对任务根、'/' 分隔、不带前导斜杠。没有忽略档 → 只有硬清单生效。
|
|
94
|
+
*/
|
|
95
|
+
export async function loadIgnore(taskDir) {
|
|
96
|
+
let rules = [];
|
|
97
|
+
try {
|
|
98
|
+
const raw = await fs.readFile(path.join(taskDir, NDIGNORE_FILE), 'utf8');
|
|
99
|
+
rules = raw.split('\n').map(compilePattern).filter(Boolean);
|
|
100
|
+
} catch { /* 没有 .ndignore:正常 */ }
|
|
101
|
+
return (rel, isDir = false) => {
|
|
102
|
+
const segs = rel.split('/');
|
|
103
|
+
// 硬清单:路径里任何一段命中都算(父目录被剪枝时子路径根本走不到这,
|
|
104
|
+
// 这里再查一遍是给「直接拿 rel 来问」的调用方兜底)
|
|
105
|
+
if (segs.some(s => HARD_IGNORE_DIRS.has(s))) return true;
|
|
106
|
+
for (const r of rules) {
|
|
107
|
+
if (r.dirOnly && !isDir && !r.re.test(rel + '/')) continue;
|
|
108
|
+
if (r.re.test(rel)) return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 递归列任务目录下的文件(应用硬清单 + .ndignore + 跳 dot 条目)。
|
|
116
|
+
*
|
|
117
|
+
* @param {string} rootDir 绝对路径(任务根或产物根)
|
|
118
|
+
* @param {object} [opts]
|
|
119
|
+
* @param {number} [opts.maxDepth=6]
|
|
120
|
+
* @param {Function} [opts.ignore] loadIgnore 的返回值;不传就现读
|
|
121
|
+
* @param {string} [opts.ignoreBase] ignore 规则的基准目录(缺省 = rootDir)。
|
|
122
|
+
* 产物根是子目录时,.ndignore 在任务根,
|
|
123
|
+
* rel 要先换算回任务根视角再问匹配器。
|
|
124
|
+
* @param {boolean} [opts.includeDrafts=false] 是否进 `_drafts/`
|
|
125
|
+
* @returns {Promise<Array<{rel: string, abs: string, name: string, size: number}>>}
|
|
126
|
+
*/
|
|
127
|
+
export async function walkTaskFiles(rootDir, opts = {}) {
|
|
128
|
+
const { maxDepth = 6, includeDrafts = false } = opts;
|
|
129
|
+
const ignoreBase = opts.ignoreBase || rootDir;
|
|
130
|
+
const ignore = opts.ignore || await loadIgnore(ignoreBase);
|
|
131
|
+
const out = [];
|
|
132
|
+
const toIgnoreRel = (abs) => path.relative(ignoreBase, abs).split(path.sep).join('/');
|
|
133
|
+
|
|
134
|
+
const walk = async (dir, prefix, depth) => {
|
|
135
|
+
if (depth > maxDepth) return;
|
|
136
|
+
let entries;
|
|
137
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
138
|
+
for (const e of entries) {
|
|
139
|
+
if (e.name.startsWith('.')) continue;
|
|
140
|
+
const abs = path.join(dir, e.name);
|
|
141
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
142
|
+
if (e.isDirectory()) {
|
|
143
|
+
if (HARD_IGNORE_DIRS.has(e.name)) continue;
|
|
144
|
+
if (!includeDrafts && e.name === DRAFTS_DIR) continue;
|
|
145
|
+
if (ignore(toIgnoreRel(abs), true)) continue;
|
|
146
|
+
await walk(abs, rel, depth + 1);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!e.isFile()) continue;
|
|
150
|
+
if (ignore(toIgnoreRel(abs), false)) continue;
|
|
151
|
+
let size = 0;
|
|
152
|
+
try { size = (await fs.stat(abs)).size; } catch { continue; }
|
|
153
|
+
out.push({ rel, abs, name: e.name, size });
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
await walk(rootDir, '', 1);
|
|
157
|
+
return out;
|
|
158
|
+
}
|