synabun 2.0.10 → 2.2.0
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/CHANGELOG.md +207 -0
- package/README.md +25 -2
- package/THIRD-PARTY-LICENSES.md +22 -0
- package/bin/synabun.js +71 -24
- package/docs/whatsapp.md +13 -13
- package/hooks/claude-code/pre-websearch.mjs +12 -1
- package/hooks/claude-code/shared.mjs +5 -0
- package/hooks/claude-code/stop.mjs +1 -1
- package/lib/claude-hooks.js +172 -18
- package/lib/client-config-repair.js +30 -17
- package/lib/desktop-app.js +7 -1
- package/lib/external-tools.js +7 -6
- package/lib/first-launch-protection.js +57 -12
- package/lib/mcp-dist.js +176 -0
- package/lib/release-npm-ci.mjs +138 -0
- package/lib/release-publish.mjs +91 -0
- package/lib/release-scan.mjs +50 -0
- package/lib/server-crash-record.js +123 -0
- package/lib/server-exit.js +318 -0
- package/lib/server-supervisor.js +508 -0
- package/lib/start-launcher.js +37 -4
- package/lib/tools-cli.js +45 -0
- package/lib/update-snapshot.js +302 -0
- package/lib/virustotal.mjs +203 -0
- package/lib/windows-crash-event.js +269 -0
- package/mcp-server/dist/http.d.ts +20 -1
- package/mcp-server/dist/http.d.ts.map +1 -1
- package/mcp-server/dist/http.js +59 -12
- package/mcp-server/dist/http.js.map +1 -1
- package/mcp-server/dist/index.d.ts +33 -27
- package/mcp-server/dist/index.d.ts.map +1 -1
- package/mcp-server/dist/index.js +128 -143
- package/mcp-server/dist/index.js.map +1 -1
- package/mcp-server/dist/preload.js +1 -1
- package/mcp-server/dist/preload.js.map +1 -1
- package/mcp-server/dist/services/budget-judgments.d.ts +1 -1
- package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -1
- package/mcp-server/dist/services/budget-judgments.js +5 -3
- package/mcp-server/dist/services/budget-judgments.js.map +1 -1
- package/mcp-server/dist/services/codex-config-heal.d.ts +6 -2
- package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
- package/mcp-server/dist/services/codex-config-heal.js +81 -28
- package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
- package/mcp-server/dist/services/identity.d.ts +2 -2
- package/mcp-server/dist/services/identity.js +2 -2
- package/mcp-server/dist/services/memory-judge-backfill.d.ts +6 -0
- package/mcp-server/dist/services/memory-judge-backfill.d.ts.map +1 -1
- package/mcp-server/dist/services/memory-judge-backfill.js +7 -1
- package/mcp-server/dist/services/memory-judge-backfill.js.map +1 -1
- package/mcp-server/dist/services/memory-sessions.d.ts +1 -1
- package/mcp-server/dist/services/memory-sessions.d.ts.map +1 -1
- package/mcp-server/dist/services/memory-sessions.js +28 -2
- package/mcp-server/dist/services/memory-sessions.js.map +1 -1
- package/mcp-server/dist/services/neural-interface.d.ts +18 -11
- package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
- package/mcp-server/dist/services/neural-interface.js +67 -11
- package/mcp-server/dist/services/neural-interface.js.map +1 -1
- package/mcp-server/dist/services/server-instructions.d.ts +7 -2
- package/mcp-server/dist/services/server-instructions.d.ts.map +1 -1
- package/mcp-server/dist/services/server-instructions.js +22 -63
- package/mcp-server/dist/services/server-instructions.js.map +1 -1
- package/mcp-server/dist/services/tool-catalog.d.ts +120 -0
- package/mcp-server/dist/services/tool-catalog.d.ts.map +1 -0
- package/mcp-server/dist/services/tool-catalog.js +339 -0
- package/mcp-server/dist/services/tool-catalog.js.map +1 -0
- package/mcp-server/dist/services/tool-list-refresh-barrier.d.ts +12 -0
- package/mcp-server/dist/services/tool-list-refresh-barrier.d.ts.map +1 -0
- package/mcp-server/dist/services/tool-list-refresh-barrier.js +40 -0
- package/mcp-server/dist/services/tool-list-refresh-barrier.js.map +1 -0
- package/mcp-server/dist/services/tool-list.d.ts +108 -0
- package/mcp-server/dist/services/tool-list.d.ts.map +1 -0
- package/mcp-server/dist/services/tool-list.js +334 -0
- package/mcp-server/dist/services/tool-list.js.map +1 -0
- package/mcp-server/dist/services/tool-runtime.d.ts +161 -0
- package/mcp-server/dist/services/tool-runtime.d.ts.map +1 -0
- package/mcp-server/dist/services/tool-runtime.js +400 -0
- package/mcp-server/dist/services/tool-runtime.js.map +1 -0
- package/mcp-server/dist/services/typesafe-config.js +1 -1
- package/mcp-server/dist/services/typesafe-config.js.map +1 -1
- package/mcp-server/dist/services/whiteboard-geometry.d.ts +26 -2
- package/mcp-server/dist/services/whiteboard-geometry.d.ts.map +1 -1
- package/mcp-server/dist/services/whiteboard-geometry.js +91 -8
- package/mcp-server/dist/services/whiteboard-geometry.js.map +1 -1
- package/mcp-server/dist/tools/agents.d.ts +14 -14
- package/mcp-server/dist/tools/agents.d.ts.map +1 -1
- package/mcp-server/dist/tools/agents.js +29 -19
- package/mcp-server/dist/tools/agents.js.map +1 -1
- package/mcp-server/dist/tools/browser-batch.d.ts +3 -3
- package/mcp-server/dist/tools/choice.d.ts +6 -0
- package/mcp-server/dist/tools/choice.d.ts.map +1 -1
- package/mcp-server/dist/tools/choice.js +18 -13
- package/mcp-server/dist/tools/choice.js.map +1 -1
- package/mcp-server/dist/tools/git-tools.js +2 -2
- package/mcp-server/dist/tools/git-tools.js.map +1 -1
- package/mcp-server/dist/tools/image-tools.d.ts +1 -1
- package/mcp-server/dist/tools/image-tools.js +1 -1
- package/mcp-server/dist/tools/morelogin.d.ts.map +1 -1
- package/mcp-server/dist/tools/morelogin.js +9 -6
- package/mcp-server/dist/tools/morelogin.js.map +1 -1
- package/mcp-server/dist/tools/skill-tools.d.ts +505 -0
- package/mcp-server/dist/tools/skill-tools.d.ts.map +1 -0
- package/mcp-server/dist/tools/skill-tools.js +642 -0
- package/mcp-server/dist/tools/skill-tools.js.map +1 -0
- package/mcp-server/dist/tools/skill.d.ts +7 -0
- package/mcp-server/dist/tools/skill.d.ts.map +1 -0
- package/mcp-server/dist/tools/skill.js +11 -0
- package/mcp-server/dist/tools/skill.js.map +1 -0
- package/mcp-server/dist/tools/tool-groups.d.ts +68 -0
- package/mcp-server/dist/tools/tool-groups.d.ts.map +1 -0
- package/mcp-server/dist/tools/tool-groups.js +147 -0
- package/mcp-server/dist/tools/tool-groups.js.map +1 -0
- package/mcp-server/dist/tools/whiteboard-format.d.ts.map +1 -1
- package/mcp-server/dist/tools/whiteboard-format.js +7 -4
- package/mcp-server/dist/tools/whiteboard-format.js.map +1 -1
- package/mcp-server/dist/tools/whiteboard-tools.d.ts +64 -13
- package/mcp-server/dist/tools/whiteboard-tools.d.ts.map +1 -1
- package/mcp-server/dist/tools/whiteboard-tools.js +30 -5
- package/mcp-server/dist/tools/whiteboard-tools.js.map +1 -1
- package/mcp-server/package-lock.json +2 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/src/http.ts +75 -12
- package/mcp-server/src/index.ts +147 -154
- package/mcp-server/src/preload.ts +12 -12
- package/mcp-server/src/services/budget-judgments.ts +5 -3
- package/mcp-server/src/services/codex-config-heal.ts +80 -29
- package/mcp-server/src/services/identity.ts +2 -2
- package/mcp-server/src/services/memory-judge-backfill.ts +7 -1
- package/mcp-server/src/services/memory-sessions.ts +24 -3
- package/mcp-server/src/services/neural-interface.ts +81 -21
- package/mcp-server/src/services/server-instructions.ts +29 -64
- package/mcp-server/src/services/tool-catalog.ts +403 -0
- package/mcp-server/src/services/tool-list-refresh-barrier.ts +33 -0
- package/mcp-server/src/services/tool-list.ts +410 -0
- package/mcp-server/src/services/tool-runtime.ts +457 -0
- package/mcp-server/src/services/typesafe-config.ts +1 -1
- package/mcp-server/src/services/whiteboard-geometry.ts +94 -9
- package/mcp-server/src/tools/agents.ts +29 -20
- package/mcp-server/src/tools/choice.ts +17 -12
- package/mcp-server/src/tools/git-tools.ts +2 -2
- package/mcp-server/src/tools/image-tools.ts +1 -1
- package/mcp-server/src/tools/morelogin.ts +6 -4
- package/mcp-server/src/tools/skill-tools.ts +592 -0
- package/mcp-server/src/tools/skill.ts +13 -0
- package/mcp-server/src/tools/tool-groups.ts +164 -0
- package/mcp-server/src/tools/whiteboard-format.ts +9 -3
- package/mcp-server/src/tools/whiteboard-tools.ts +39 -5
- package/neural-interface/i18n/en.json +3953 -228
- package/neural-interface/i18n/pt-BR.json +3901 -179
- package/neural-interface/lib/agent-sandbox-elevation.js +6 -0
- package/neural-interface/lib/antigravity/acp-client-host.js +554 -0
- package/neural-interface/lib/antigravity/acp-connection.js +299 -0
- package/neural-interface/lib/antigravity/bypass.js +100 -0
- package/neural-interface/lib/antigravity/catalog.js +273 -0
- package/neural-interface/lib/antigravity/home.js +260 -0
- package/neural-interface/lib/antigravity/migrate-gemini.js +139 -0
- package/neural-interface/lib/antigravity/panel-bridge.js +1058 -0
- package/neural-interface/lib/antigravity/policy-hook.mjs +78 -0
- package/neural-interface/lib/antigravity/policy.js +74 -0
- package/neural-interface/lib/antigravity/tool-call.js +178 -0
- package/neural-interface/lib/antigravity/transcripts.js +252 -0
- package/neural-interface/lib/antigravity/usage.js +43 -0
- package/neural-interface/lib/assistant-api.js +128 -28
- package/neural-interface/lib/assistant-brains/antigravity.js +423 -0
- package/neural-interface/lib/assistant-brains/claude.js +4 -3
- package/neural-interface/lib/assistant-brains/codex-browser-policy-hook.mjs +12 -2
- package/neural-interface/lib/assistant-brains/codex-route-gate-hook.mjs +32 -9
- package/neural-interface/lib/assistant-brains/codex.js +9 -5
- package/neural-interface/lib/assistant-brains/opencode.js +120 -54
- package/neural-interface/lib/assistant-brains/opencode2-browser-policy/index.js +28 -13
- package/neural-interface/lib/assistant-brains/opencode2-route-gate/index.js +59 -19
- package/neural-interface/lib/assistant-budget-planner.js +7 -3
- package/neural-interface/lib/assistant-budget-split.js +42 -15
- package/neural-interface/lib/assistant-catalog.js +81 -15
- package/neural-interface/lib/assistant-config.js +6 -3
- package/neural-interface/lib/assistant-dispatch.js +1073 -200
- package/neural-interface/lib/assistant-envelope.js +156 -2
- package/neural-interface/lib/assistant-evidence.js +80 -0
- package/neural-interface/lib/assistant-hidden-models.js +7 -5
- package/neural-interface/lib/assistant-persona.js +93 -19
- package/neural-interface/lib/assistant-plan-permissions.js +25 -9
- package/neural-interface/lib/assistant-route-gate-codex.js +3 -0
- package/neural-interface/lib/assistant-route-gate.js +6 -4
- package/neural-interface/lib/assistant-router.js +245 -38
- package/neural-interface/lib/assistant-runtime.js +214 -30
- package/neural-interface/lib/assistant-task-prompt.js +87 -21
- package/neural-interface/lib/assistant-tools.js +51 -0
- package/neural-interface/lib/assistant-usage.js +29 -0
- package/neural-interface/lib/backup-service.js +626 -4
- package/neural-interface/lib/backup-zip-reader.js +10 -5
- package/neural-interface/lib/backup-zip-writer.js +257 -0
- package/neural-interface/lib/browser-execution.js +10 -2
- package/neural-interface/lib/browser-guard-cdp.js +84 -0
- package/neural-interface/lib/browser-network-guard.js +476 -0
- package/neural-interface/lib/browser-policy-roots.js +64 -0
- package/neural-interface/lib/browser-tool-policy.js +198 -38
- package/neural-interface/lib/claude-agent-bridge.js +11 -2
- package/neural-interface/lib/claude-history-cache.js +210 -0
- package/neural-interface/lib/claude-hooks-status.js +103 -0
- package/neural-interface/lib/claude-model-catalog.js +5 -1
- package/neural-interface/lib/claude-temporary.js +5 -11
- package/neural-interface/lib/cli-install-api.js +1 -1
- package/neural-interface/lib/cli-install.js +163 -154
- package/neural-interface/lib/cli-resolver.js +13 -3
- package/neural-interface/lib/cli-update-planner.js +52 -41
- package/neural-interface/lib/cloudflared/tunnel.js +42 -2
- package/neural-interface/lib/codex-mcp-approvals.js +11 -0
- package/neural-interface/lib/codex-rollout-reader.js +52 -0
- package/neural-interface/lib/codex-worker-app-server.js +191 -0
- package/neural-interface/lib/confined-fs.js +30 -3
- package/neural-interface/lib/crash-notice-api.js +98 -0
- package/neural-interface/lib/editor-completion-opencode.js +68 -68
- package/neural-interface/lib/editor-git-api.js +3 -20
- package/neural-interface/lib/exec-loop-command.js +13 -10
- package/neural-interface/lib/file-explorer-api.js +74 -40
- package/neural-interface/lib/file-explorer-watch.js +9 -3
- package/neural-interface/lib/git-bash/install.js +52 -6
- package/neural-interface/lib/git-bash/manager.js +59 -17
- package/neural-interface/lib/git-bash/pins.js +12 -0
- package/neural-interface/lib/git-read.js +2 -2
- package/neural-interface/lib/git-status-runner.js +390 -0
- package/neural-interface/lib/git-status-watchdog.js +40 -0
- package/neural-interface/lib/greeting/budgets.js +1 -0
- package/neural-interface/lib/greeting/engine.js +2 -2
- package/neural-interface/lib/greeting/sources.js +10 -12
- package/neural-interface/lib/greeting/writer.js +1 -1
- package/neural-interface/lib/http-guards.js +8 -2
- package/neural-interface/lib/install-details.js +4 -1
- package/neural-interface/lib/mcp-agent-servers.js +67 -0
- package/neural-interface/lib/mcp-client-config.js +35 -33
- package/neural-interface/lib/mcp-external-servers.js +332 -0
- package/neural-interface/lib/mcp-installer.js +2 -1
- package/neural-interface/lib/{mcp-profile-refresh-coordinator.js → mcp-tools-refresh-coordinator.js} +4 -4
- package/neural-interface/lib/memory-session-capture.js +17 -3
- package/neural-interface/lib/morelogin.js +177 -25
- package/neural-interface/lib/native-loop-providers.js +965 -66
- package/neural-interface/lib/native-loop-runtime.js +290 -42
- package/neural-interface/lib/opencode-config.js +557 -0
- package/neural-interface/lib/opencode-db-schema.js +17 -34
- package/neural-interface/lib/opencode-history.js +48 -218
- package/neural-interface/lib/opencode-mcp-ready.js +108 -0
- package/neural-interface/lib/opencode-native-boundary.js +53 -0
- package/neural-interface/lib/opencode-protocol.js +249 -112
- package/neural-interface/lib/opencode-runtime-config.js +32 -22
- package/neural-interface/lib/opencode-serve-records.js +39 -21
- package/neural-interface/lib/opencode-v2-client.js +48 -153
- package/neural-interface/lib/opencode-v2-ws-requests.js +36 -210
- package/neural-interface/lib/opencode-worker-data.js +27 -0
- package/neural-interface/lib/opencode2-client.js +73 -37
- package/neural-interface/lib/opencode2-proxy.js +23 -63
- package/neural-interface/lib/opencode2-translate.js +51 -2
- package/neural-interface/lib/owner-authority.js +249 -0
- package/neural-interface/lib/page-session.js +275 -0
- package/neural-interface/lib/path-confine.js +12 -9
- package/neural-interface/lib/peer-identity.js +793 -0
- package/neural-interface/lib/pty-host/core.js +28 -1
- package/neural-interface/lib/pty-host/lifetime.js +12 -0
- package/neural-interface/lib/pty-host/manager.js +12 -1
- package/neural-interface/lib/remote-policy.js +1 -1
- package/neural-interface/lib/request-gate.js +20 -2
- package/neural-interface/lib/rulesets/api.js +7 -6
- package/neural-interface/lib/rulesets/fs-safe.js +1 -1
- package/neural-interface/lib/rulesets/installer.js +180 -36
- package/neural-interface/lib/rulesets/json-edit.js +1 -1
- package/neural-interface/lib/rulesets/render.js +1 -1
- package/neural-interface/lib/session-cache-builder.js +81 -31
- package/neural-interface/lib/session-title-generator.js +58 -36
- package/neural-interface/lib/skills/api-v2.js +495 -0
- package/neural-interface/lib/skills/bundle.js +418 -0
- package/neural-interface/lib/skills/catalog.js +298 -0
- package/neural-interface/lib/skills/creation-api.js +191 -0
- package/neural-interface/lib/skills/creation-brief.js +247 -0
- package/neural-interface/lib/skills/creation-sources.js +285 -0
- package/neural-interface/lib/skills/creation.js +843 -0
- package/neural-interface/lib/skills/deploy.js +569 -0
- package/neural-interface/lib/skills/digest.js +119 -0
- package/neural-interface/lib/skills/discover.js +111 -0
- package/neural-interface/lib/skills/draft-confinement.js +216 -0
- package/neural-interface/lib/skills/eval-confinement.js +399 -0
- package/neural-interface/lib/skills/eval-sandbox.js +280 -0
- package/neural-interface/lib/skills/eval-trace.js +135 -0
- package/neural-interface/lib/skills/evals-api.js +212 -0
- package/neural-interface/lib/skills/evals.js +1573 -0
- package/neural-interface/lib/skills/export.js +203 -0
- package/neural-interface/lib/skills/frontmatter.js +64 -0
- package/neural-interface/lib/skills/health.js +75 -0
- package/neural-interface/lib/skills/json-edit.js +165 -0
- package/neural-interface/lib/skills/known-bridge-digests.json +441 -0
- package/neural-interface/lib/skills/known-bridges.js +96 -0
- package/neural-interface/lib/skills/library.js +823 -0
- package/neural-interface/lib/skills/local-store.js +21 -0
- package/neural-interface/lib/skills/migration.js +433 -0
- package/neural-interface/lib/skills/operations.js +45 -0
- package/neural-interface/lib/skills/ownership.js +427 -0
- package/neural-interface/lib/skills/packages.js +88 -0
- package/neural-interface/lib/skills/reviews.js +212 -0
- package/neural-interface/lib/skills/revision.js +80 -0
- package/neural-interface/lib/skills/roots.js +269 -0
- package/neural-interface/lib/skills/runtime-checks.js +358 -0
- package/neural-interface/lib/skills/scan-assets.js +252 -0
- package/neural-interface/lib/skills/scan-context.js +26 -0
- package/neural-interface/lib/skills/scan-shell.js +324 -0
- package/neural-interface/lib/skills/scan.js +364 -0
- package/neural-interface/lib/skills/scanners.js +54 -0
- package/neural-interface/lib/skills/service.js +530 -0
- package/neural-interface/lib/skills/telemetry.js +119 -0
- package/neural-interface/lib/sqlite-integrity-worker.js +22 -0
- package/neural-interface/lib/synabun-version.js +1 -1
- package/neural-interface/lib/system-activity.js +1 -1
- package/neural-interface/lib/tool-lists.js +124 -0
- package/neural-interface/lib/whatsapp/brain.js +7 -3
- package/neural-interface/lib/whatsapp/bridge.js +6 -3
- package/neural-interface/lib/whatsapp/cards.js +22 -9
- package/neural-interface/lib/whatsapp/config.js +1 -1
- package/neural-interface/lib/worker-confinement.js +1268 -0
- package/neural-interface/lib/worker-guard-setting.js +56 -0
- package/neural-interface/lib/worker-mcp-channel.js +175 -0
- package/neural-interface/lib/worker-question.js +74 -0
- package/neural-interface/lib/worker-relay-ports.js +227 -0
- package/neural-interface/lib/worker-resume.js +238 -0
- package/neural-interface/lib/worker-sandbox.js +329 -0
- package/neural-interface/package-lock.json +34 -12
- package/neural-interface/package.json +3 -2
- package/neural-interface/public/index.html +3 -1
- package/neural-interface/public/index2d.html +19 -11
- package/neural-interface/public/onboarding.html +3 -3
- package/neural-interface/public/shared/account-menu.js +8 -5
- package/neural-interface/public/shared/agent-runtime-options.js +125 -31
- package/neural-interface/public/shared/agy/agy-bypass.js +47 -0
- package/neural-interface/public/shared/agy/agy-client.js +266 -0
- package/neural-interface/public/shared/agy/agy-commands.js +120 -0
- package/neural-interface/public/shared/agy/agy-copy.js +326 -0
- package/neural-interface/public/shared/agy/agy-diff.js +161 -0
- package/neural-interface/public/shared/agy/agy-events.js +469 -0
- package/neural-interface/public/shared/agy/agy-greeting.js +26 -0
- package/neural-interface/public/shared/agy/agy-options.js +232 -0
- package/neural-interface/public/shared/agy/agy-panel.js +2543 -0
- package/neural-interface/public/shared/agy/agy-render.js +392 -0
- package/neural-interface/public/shared/agy/agy-setup.js +149 -0
- package/neural-interface/public/shared/agy/agy-styles.js +604 -0
- package/neural-interface/public/shared/agy/agy-tabs-logic.js +181 -0
- package/neural-interface/public/shared/api.js +1527 -1336
- package/neural-interface/public/shared/app-frame.js +192 -0
- package/neural-interface/public/shared/assistant/asst-agents-dock.js +36 -6
- package/neural-interface/public/shared/assistant/asst-api.js +1 -1
- package/neural-interface/public/shared/assistant/asst-ask-styles.js +270 -0
- package/neural-interface/public/shared/assistant/asst-ask-tail.js +118 -0
- package/neural-interface/public/shared/assistant/asst-ask.js +397 -0
- package/neural-interface/public/shared/assistant/asst-brain-picker.js +105 -44
- package/neural-interface/public/shared/assistant/asst-budget.js +5 -4
- package/neural-interface/public/shared/assistant/asst-control.js +205 -26
- package/neural-interface/public/shared/assistant/asst-followups-card.js +383 -0
- package/neural-interface/public/shared/assistant/asst-followups.js +417 -0
- package/neural-interface/public/shared/assistant/asst-hosts.js +16 -7
- package/neural-interface/public/shared/assistant/asst-limit-dock.js +45 -0
- package/neural-interface/public/shared/assistant/asst-limit-wait.js +327 -0
- package/neural-interface/public/shared/assistant/asst-limit.js +168 -0
- package/neural-interface/public/shared/assistant/asst-mascot.js +49 -12
- package/neural-interface/public/shared/assistant/asst-menu.js +7 -6
- package/neural-interface/public/shared/assistant/asst-panel.js +151 -19
- package/neural-interface/public/shared/assistant/asst-rack-styles.js +66 -49
- package/neural-interface/public/shared/assistant/asst-render.js +445 -82
- package/neural-interface/public/shared/assistant/asst-route.js +304 -53
- package/neural-interface/public/shared/assistant/asst-routes-editor.js +9 -8
- package/neural-interface/public/shared/assistant/asst-scenes.js +56 -1
- package/neural-interface/public/shared/assistant/asst-sidepanel.js +117 -10
- package/neural-interface/public/shared/assistant/asst-slash.js +17 -9
- package/neural-interface/public/shared/assistant/asst-state.js +87 -24
- package/neural-interface/public/shared/assistant/asst-stop.js +127 -0
- package/neural-interface/public/shared/assistant/asst-styles.js +93 -21
- package/neural-interface/public/shared/assistant/asst-tool-kinds.js +190 -23
- package/neural-interface/public/shared/assistant/asst-usage.js +2 -1
- package/neural-interface/public/shared/automation-context.js +5 -2
- package/neural-interface/public/shared/cc-dropdown.js +89 -19
- package/neural-interface/public/shared/cdx/cdx-context-menu.js +10 -9
- package/neural-interface/public/shared/cdx/cdx-copy.js +16 -0
- package/neural-interface/public/shared/cdx/cdx-output-model.js +22 -1
- package/neural-interface/public/shared/cdx/cdx-output.js +34 -10
- package/neural-interface/public/shared/cdx/cdx-panel.js +59 -151
- package/neural-interface/public/shared/cdx/cdx-preview.js +12 -0
- package/neural-interface/public/shared/cdx/cdx-protocol.js +39 -11
- package/neural-interface/public/shared/cdx/cdx-render.js +57 -38
- package/neural-interface/public/shared/cdx/cdx-requests.js +14 -4
- package/neural-interface/public/shared/cdx/cdx-skill-hints.js +54 -0
- package/neural-interface/public/shared/cdx/cdx-styles.js +15 -3
- package/neural-interface/public/shared/cdx/cdx-tabs.js +127 -44
- package/neural-interface/public/shared/cdx/cdx-ws.js +0 -1
- package/neural-interface/public/shared/cli-install-controller.js +218 -0
- package/neural-interface/public/shared/cli-install-view.js +11 -12
- package/neural-interface/public/shared/cli-status.js +11 -10
- package/neural-interface/public/shared/constants.js +5 -4
- package/neural-interface/public/shared/context-menu-areas.js +34 -0
- package/neural-interface/public/shared/context-menu-logic.js +243 -0
- package/neural-interface/public/shared/context-menu-model.js +367 -0
- package/neural-interface/public/shared/context-menu.js +372 -0
- package/neural-interface/public/shared/cp/cp-context-menu.js +10 -9
- package/neural-interface/public/shared/cp/cp-restore.js +162 -0
- package/neural-interface/public/shared/cp/cp-tool-cards.js +10 -3
- package/neural-interface/public/shared/crash-notice-view.js +168 -0
- package/neural-interface/public/shared/desktop-bridge.js +17 -0
- package/neural-interface/public/shared/desktop-titlebar-view.js +12 -0
- package/neural-interface/public/shared/desktop-titlebar.js +13 -4
- package/neural-interface/public/shared/desktop-update-view.js +21 -0
- package/neural-interface/public/shared/editor/editor-panel-logic.js +2 -2
- package/neural-interface/public/shared/explorer/fx-api.js +4 -4
- package/neural-interface/public/shared/explorer/fx-links.js +3 -3
- package/neural-interface/public/shared/explorer/fx-logic.js +1 -1
- package/neural-interface/public/shared/explorer/fx-main.js +48 -16
- package/neural-interface/public/shared/explorer/fx-tree.js +1 -0
- package/neural-interface/public/shared/greeting/greeting-card.js +2 -2
- package/neural-interface/public/shared/greeting/greeting-view.js +1 -0
- package/neural-interface/public/shared/html-shell.js +30 -15
- package/neural-interface/public/shared/i18n.js +2 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-approvals.js +7 -7
- package/neural-interface/public/shared/ocp-v2/ocp-v2-binding.js +4 -4
- package/neural-interface/public/shared/ocp-v2/ocp-v2-changes-logic.js +3 -101
- package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +2 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-compact-button.js +1 -3
- package/neural-interface/public/shared/ocp-v2/ocp-v2-composer-logic.js +2 -6
- package/neural-interface/public/shared/ocp-v2/ocp-v2-context-menu.js +11 -11
- package/neural-interface/public/shared/ocp-v2/ocp-v2-context-model.js +0 -3
- package/neural-interface/public/shared/ocp-v2/ocp-v2-events.js +8 -4
- package/neural-interface/public/shared/ocp-v2/ocp-v2-manager.js +7 -7
- package/neural-interface/public/shared/ocp-v2/ocp-v2-outcome-notifications.js +16 -0
- package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +74 -259
- package/neural-interface/public/shared/ocp-v2/ocp-v2-plan.js +1 -2
- package/neural-interface/public/shared/ocp-v2/ocp-v2-projectbar.js +95 -202
- package/neural-interface/public/shared/ocp-v2/ocp-v2-rehydrate.js +13 -4
- package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +13 -22
- package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +4 -4
- package/neural-interface/public/shared/ocp-v2/ocp-v2-session-actions.js +8 -61
- package/neural-interface/public/shared/ocp-v2/ocp-v2-sessions-logic.js +2 -91
- package/neural-interface/public/shared/ocp-v2/ocp-v2-slash-hints.js +16 -15
- package/neural-interface/public/shared/ocp-v2/ocp-v2-state.js +8 -21
- package/neural-interface/public/shared/ocp-v2/ocp-v2-status-logic.js +1 -99
- package/neural-interface/public/shared/ocp-v2/ocp-v2-status.js +5 -35
- package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +17 -21
- package/neural-interface/public/shared/ocp-v2/{ocp-v2-profile-inheritance.js → ocp-v2-tools-inheritance.js} +16 -16
- package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +60 -50
- package/neural-interface/public/shared/page-session.js +96 -0
- package/neural-interface/public/shared/provider-icons.js +4 -2
- package/neural-interface/public/shared/registry.js +5 -3
- package/neural-interface/public/shared/retired-2d.js +3 -0
- package/neural-interface/public/shared/schedules-view.js +195 -61
- package/neural-interface/public/shared/settings/settings-claude-hooks.js +135 -0
- package/neural-interface/public/shared/settings/settings-data.js +3 -1
- package/neural-interface/public/shared/settings/settings-ia.js +6 -6
- package/neural-interface/public/shared/settings/settings-opencode.js +89 -0
- package/neural-interface/public/shared/settings/settings-rules.js +2 -2
- package/neural-interface/public/shared/settings/settings-skill-folders-pane.js +265 -0
- package/neural-interface/public/shared/settings/settings-skill-folders.js +130 -0
- package/neural-interface/public/shared/settings/settings-worker-guard.js +72 -0
- package/neural-interface/public/shared/showcase-privacy.js +6 -1
- package/neural-interface/public/shared/sidepanel-session-status.js +3 -3
- package/neural-interface/public/shared/skills/ss-args.js +96 -0
- package/neural-interface/public/shared/skills/ss-creation.js +222 -0
- package/neural-interface/public/shared/skills/ss-dialog.js +133 -0
- package/neural-interface/public/shared/skills/ss-doc.js +173 -0
- package/neural-interface/public/shared/skills/ss-eval-adapters.js +269 -0
- package/neural-interface/public/shared/skills/ss-eval-grade.js +359 -0
- package/neural-interface/public/shared/skills/ss-eval-optimise.js +153 -0
- package/neural-interface/public/shared/skills/ss-eval-plan.js +315 -0
- package/neural-interface/public/shared/skills/ss-evals.js +417 -0
- package/neural-interface/public/shared/skills/ss-form.js +159 -0
- package/neural-interface/public/shared/skills/ss-frontmatter.js +376 -0
- package/neural-interface/public/shared/skills/ss-ids.js +32 -0
- package/neural-interface/public/shared/skills/ss-lint.js +149 -0
- package/neural-interface/public/shared/skills/ss-presets.js +117 -0
- package/neural-interface/public/shared/skills/ss-profiles.js +513 -0
- package/neural-interface/public/shared/skills/ss-resources.js +165 -0
- package/neural-interface/public/shared/skills/ss-review-view.js +190 -0
- package/neural-interface/public/shared/skills/ss-tabs.js +79 -0
- package/neural-interface/public/shared/skills/ss-unicode.js +135 -0
- package/neural-interface/public/shared/skills/studio/sx-actions.js +222 -0
- package/neural-interface/public/shared/skills/studio/sx-cm.js +101 -0
- package/neural-interface/public/shared/skills/studio/sx-create-ai-view.js +749 -0
- package/neural-interface/public/shared/skills/studio/sx-create-ai.js +237 -0
- package/neural-interface/public/shared/skills/studio/sx-create-view.js +254 -0
- package/neural-interface/public/shared/skills/studio/sx-create.js +126 -0
- package/neural-interface/public/shared/skills/studio/sx-diff-view.js +43 -0
- package/neural-interface/public/shared/skills/studio/sx-diff.js +133 -0
- package/neural-interface/public/shared/skills/studio/sx-docs.js +480 -0
- package/neural-interface/public/shared/skills/studio/sx-dom.js +230 -0
- package/neural-interface/public/shared/skills/studio/sx-edit-view.js +848 -0
- package/neural-interface/public/shared/skills/studio/sx-editor-model.js +193 -0
- package/neural-interface/public/shared/skills/studio/sx-errors.js +22 -0
- package/neural-interface/public/shared/skills/studio/sx-fixes-view.js +359 -0
- package/neural-interface/public/shared/skills/studio/sx-fixes.js +327 -0
- package/neural-interface/public/shared/skills/studio/sx-geometry.js +82 -0
- package/neural-interface/public/shared/skills/studio/sx-import.js +262 -0
- package/neural-interface/public/shared/skills/studio/sx-inspector.js +308 -0
- package/neural-interface/public/shared/skills/studio/sx-install-flow.js +282 -0
- package/neural-interface/public/shared/skills/studio/sx-install.js +313 -0
- package/neural-interface/public/shared/skills/studio/sx-library-view.js +748 -0
- package/neural-interface/public/shared/skills/studio/sx-library.js +479 -0
- package/neural-interface/public/shared/skills/studio/sx-main.js +690 -0
- package/neural-interface/public/shared/skills/studio/sx-operations-view.js +98 -0
- package/neural-interface/public/shared/skills/studio/sx-operations.js +11 -0
- package/neural-interface/public/shared/skills/studio/sx-plan.js +158 -0
- package/neural-interface/public/shared/skills/studio/sx-publish-view.js +621 -0
- package/neural-interface/public/shared/skills/studio/sx-review-view.js +369 -0
- package/neural-interface/public/shared/skills/studio/sx-review.js +148 -0
- package/neural-interface/public/shared/skills/studio/sx-scan.js +35 -0
- package/neural-interface/public/shared/skills/studio/sx-snippets.js +96 -0
- package/neural-interface/public/shared/skills/studio/sx-subject.js +249 -0
- package/neural-interface/public/shared/skills/studio/sx-test-view.js +994 -0
- package/neural-interface/public/shared/skills/studio/sx-test.js +328 -0
- package/neural-interface/public/shared/skills/studio/sx-versions-view.js +241 -0
- package/neural-interface/public/shared/skills/studio/sx-workspace-view.js +426 -0
- package/neural-interface/public/shared/state.js +1 -1
- package/neural-interface/public/shared/streaming-markdown.js +58 -0
- package/neural-interface/public/shared/styleguide/sg-panel.js +4 -2
- package/neural-interface/public/shared/styles.css +1822 -1756
- package/neural-interface/public/shared/synabun-mascot.js +1115 -9
- package/neural-interface/public/shared/text-cache.js +29 -0
- package/neural-interface/public/shared/tool-list-picker.js +55 -0
- package/neural-interface/public/shared/tool-list-storage.js +24 -0
- package/neural-interface/public/shared/tool-list-view.js +112 -0
- package/neural-interface/public/shared/topright-overflow-logic.js +54 -0
- package/neural-interface/public/shared/tour/tour-actor.js +268 -0
- package/neural-interface/public/shared/tour/tour-beats.js +336 -0
- package/neural-interface/public/shared/tour/tour-engine.js +884 -0
- package/neural-interface/public/shared/tour/tour-ink.js +390 -0
- package/neural-interface/public/shared/tour/tour-stage.js +440 -0
- package/neural-interface/public/shared/tour/tour-state.js +40 -0
- package/neural-interface/public/shared/tour/tour-tips.js +99 -0
- package/neural-interface/public/shared/tour/tour.css +144 -0
- package/neural-interface/public/shared/ui-antigravity-panel.js +19 -0
- package/neural-interface/public/shared/ui-assistant.js +2 -0
- package/neural-interface/public/shared/ui-automation-studio.js +111 -140
- package/neural-interface/public/shared/ui-claude-panel.js +376 -82
- package/neural-interface/public/shared/ui-cli-banner.js +23 -167
- package/neural-interface/public/shared/ui-command-runner.js +6 -3
- package/neural-interface/public/shared/ui-context-menu.js +295 -0
- package/neural-interface/public/shared/ui-cost-widget.js +57 -59
- package/neural-interface/public/shared/ui-crash-notice.js +187 -0
- package/neural-interface/public/shared/ui-detail.js +15 -3
- package/neural-interface/public/shared/ui-file-explorer.js +48 -6
- package/neural-interface/public/shared/ui-file-links.js +2 -2
- package/neural-interface/public/shared/ui-games.js +2 -2
- package/neural-interface/public/shared/ui-image-gallery.js +50 -47
- package/neural-interface/public/shared/ui-judgments.js +1 -1
- package/neural-interface/public/shared/ui-keybinds.js +33 -2
- package/neural-interface/public/shared/ui-link.js +3 -3
- package/neural-interface/public/shared/ui-menubar.js +10 -20
- package/neural-interface/public/shared/ui-native-loop-router-state.js +5 -0
- package/neural-interface/public/shared/ui-native-loop-router.js +11 -1
- package/neural-interface/public/shared/ui-navbar.js +49 -24
- package/neural-interface/public/shared/ui-notifications.js +1 -0
- package/neural-interface/public/shared/ui-panels.js +96 -65
- package/neural-interface/public/shared/ui-resume.js +9 -7
- package/neural-interface/public/shared/ui-schedules-studio.js +546 -431
- package/neural-interface/public/shared/ui-scroll-follow.js +16 -5
- package/neural-interface/public/shared/ui-sessions.js +27 -15
- package/neural-interface/public/shared/ui-settings.js +683 -526
- package/neural-interface/public/shared/ui-sidepanel-session-entry.js +9 -2
- package/neural-interface/public/shared/ui-sidepanel-sessions.js +4 -4
- package/neural-interface/public/shared/ui-sidepanel-state.js +23 -12
- package/neural-interface/public/shared/ui-sidepanel-tray.js +48 -7
- package/neural-interface/public/shared/ui-sidepanel-windows.js +18 -13
- package/neural-interface/public/shared/ui-skills.js +33 -2830
- package/neural-interface/public/shared/ui-styleguide.js +5 -2
- package/neural-interface/public/shared/ui-sync.js +15 -5
- package/neural-interface/public/shared/ui-terminal.js +270 -190
- package/neural-interface/public/shared/ui-tool-updates.js +6 -4
- package/neural-interface/public/shared/ui-tooltip.js +81 -51
- package/neural-interface/public/shared/ui-topright-overflow.js +233 -0
- package/neural-interface/public/shared/ui-trash.js +6 -4
- package/neural-interface/public/shared/ui-tutorial-layout.js +3 -117
- package/neural-interface/public/shared/ui-tutorial-preview.js +103 -0
- package/neural-interface/public/shared/ui-tutorial-steps.js +3 -65
- package/neural-interface/public/shared/ui-tutorial.css +47 -100
- package/neural-interface/public/shared/ui-tutorial.js +285 -509
- package/neural-interface/public/shared/ui-update.js +38 -2
- package/neural-interface/public/shared/ui-whiteboard.js +312 -59
- package/neural-interface/public/shared/ui-worker-end.js +5 -1
- package/neural-interface/public/shared/ui-workspaces.js +3 -2
- package/neural-interface/public/shared/view-variant.js +66 -0
- package/neural-interface/public/shared/whatsapp/wa-view.js +3 -3
- package/neural-interface/public/shared/whiteboard-list.js +172 -0
- package/neural-interface/public/shared/worker-end.js +10 -3
- package/neural-interface/public/shared/worker-questions.js +34 -0
- package/neural-interface/public/shared/worker-restart.js +12 -0
- package/neural-interface/public/sidepanel-session.html +2 -2
- package/neural-interface/public/variant/2d/background.js +2 -1
- package/neural-interface/public/variant/2d/context-menu.js +8 -2
- package/neural-interface/public/variant/2d/graph.js +42 -20
- package/neural-interface/public/variant/2d/hulls.js +14 -9
- package/neural-interface/public/variant/2d/lasso.js +11 -7
- package/neural-interface/public/variant/2d/main.js +32 -1
- package/neural-interface/public/variant/2d/minimap.js +11 -13
- package/neural-interface/public/variant/3d/main.js +69 -6
- package/neural-interface/public/variant/3d/map-controls.js +24 -9
- package/neural-interface/public/vendor/caveat/OFL.txt +93 -0
- package/neural-interface/public/vendor/caveat/caveat-latin-400-normal.woff2 +0 -0
- package/neural-interface/public/vendor/caveat/caveat-latin-700-normal.woff2 +0 -0
- package/neural-interface/public/vendor/caveat/caveat.css +17 -0
- package/neural-interface/public/vendor/caveat/manifest.json +12 -0
- package/neural-interface/public/vendor/yaml/LICENSE +13 -0
- package/neural-interface/public/vendor/yaml/manifest.json +4 -0
- package/neural-interface/public/vendor/yaml/yaml-2.9.1.min.js +137 -0
- package/neural-interface/scripts/build-vendor.mjs +32 -0
- package/neural-interface/server.js +4553 -2855
- package/neural-interface/templates/rulesets/README.md +1 -1
- package/neural-interface/templates/rulesets/core.md +1 -1
- package/neural-interface/templates/rulesets/legacy-hashes.json +23 -5
- package/neural-interface/templates/rulesets/manifest.json +8 -8
- package/npm-shrinkwrap.json +34 -12
- package/package.json +17 -3
- package/postinstall.js +9 -4
- package/preuninstall.js +1 -1
- package/setup.js +97 -322
- package/skills/skill-forge/SKILL.md +177 -0
- package/skills/skill-forge/evals/evals.json +54 -0
- package/skills/skill-forge/references/package.md +105 -0
- package/skills/skill-forge/references/review-and-recovery.md +63 -0
- package/skills/skill-forge/references/runtime-profiles.md +20 -0
- package/skills/skill-forge/references/sources-and-capabilities.md +51 -0
- package/skills/synabun/SKILL.md +10 -7
- package/skills/synabun/modules/audit.md +1 -5
- package/skills/synabun/modules/changelog.md +1 -5
- package/skills/synabun/modules/idea.md +1 -1
- package/skills/synabun/modules/memorize.md +6 -9
- package/skills/synabun/modules/schedule.md +6 -6
- package/mcp-server/dist/services/profiles.d.ts +0 -120
- package/mcp-server/dist/services/profiles.d.ts.map +0 -1
- package/mcp-server/dist/services/profiles.js +0 -387
- package/mcp-server/dist/services/profiles.js.map +0 -1
- package/mcp-server/dist/tools/leonardo-browser-tools.d.ts +0 -97
- package/mcp-server/dist/tools/leonardo-browser-tools.d.ts.map +0 -1
- package/mcp-server/dist/tools/leonardo-browser-tools.js +0 -228
- package/mcp-server/dist/tools/leonardo-browser-tools.js.map +0 -1
- package/mcp-server/dist/tools/leonardo.d.ts +0 -13
- package/mcp-server/dist/tools/leonardo.d.ts.map +0 -1
- package/mcp-server/dist/tools/leonardo.js +0 -21
- package/mcp-server/dist/tools/leonardo.js.map +0 -1
- package/mcp-server/dist/tools/profile.d.ts +0 -17
- package/mcp-server/dist/tools/profile.d.ts.map +0 -1
- package/mcp-server/dist/tools/profile.js +0 -114
- package/mcp-server/dist/tools/profile.js.map +0 -1
- package/mcp-server/src/services/profiles.ts +0 -428
- package/mcp-server/src/tools/leonardo-browser-tools.ts +0 -291
- package/mcp-server/src/tools/leonardo.ts +0 -30
- package/mcp-server/src/tools/profile.ts +0 -122
- package/neural-interface/lib/assistant-brains/opencode-browser-policy.js +0 -47
- package/neural-interface/lib/assistant-brains/opencode-route-gate.js +0 -62
- package/neural-interface/lib/mcp-profile-servers.js +0 -120
- package/neural-interface/public/shared/ui-tutorial-draw.js +0 -311
- package/skills/leonardo/SKILL.md +0 -168
- package/skills/leonardo/icon.svg +0 -1
- package/skills/leonardo/modules/browser-guide.md +0 -378
- package/skills/leonardo/modules/image-prompter.md +0 -283
- package/skills/leonardo/modules/model-advisor.md +0 -100
- package/skills/leonardo/modules/prompt-library.md +0 -110
- package/skills/leonardo/modules/style-guide.md +0 -186
- package/skills/leonardo/modules/video-prompter.md +0 -317
- /package/neural-interface/templates/rulesets/{gemini.md → antigravity.md} +0 -0
|
@@ -0,0 +1,1268 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════
|
|
2
|
+
// SynaBun — Worker confinement: what a dispatched worker's capability means, on every provider (pure)
|
|
3
|
+
// ═══════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// The Assistant's dispatcher (lib/assistant-dispatch.js) starts a worker with a capability:
|
|
6
|
+
//
|
|
7
|
+
// full the worker may touch anything the user can (unchanged: no guard, the explicit escape hatch)
|
|
8
|
+
// workspace file writes inside the run's working directory, the OS temp folder and the run's own scratch
|
|
9
|
+
// folder only; never into ~/.claude, ~/.codex, ~/.agents, ~/.config/opencode, ~/.synabun, another
|
|
10
|
+
// project or a dotfile outside the working directory. The shell stays usable but guarded.
|
|
11
|
+
// read-only no file writes at all, and a shell limited to read-only commands.
|
|
12
|
+
//
|
|
13
|
+
// Until now only Codex enforced this (its sandbox). This module is the one description of the rule, and each
|
|
14
|
+
// provider's adapter (lib/native-loop-providers.js) enforces it the way that provider can:
|
|
15
|
+
//
|
|
16
|
+
// Claude Code a PreToolUse hook on every tool call (subagents' included), which Claude Code runs before any
|
|
17
|
+
// permission mode: Write / Edit / MultiEdit / NotebookEdit by the path they name, Bash by reading
|
|
18
|
+
// the command line (workerShellDecision), memory and browser tools untouched. The run keeps
|
|
19
|
+
// bypassPermissions, so nothing waits for an answer: a refused call fails with the reason.
|
|
20
|
+
// Codex its sandbox: read-only, or workspace-write with the working directory (and Codex's own temp
|
|
21
|
+
// folders) as the writable roots and no parent folder. The shell is sandboxed by the OS.
|
|
22
|
+
// OpenCode permission rules in the run's own config (edit denied under read-only; bash and work outside the
|
|
23
|
+
// working directory asked) and an adapter that answers each ask by this policy, never "always".
|
|
24
|
+
//
|
|
25
|
+
// The shell heuristic reads the command line only. It sees redirections, the writing programs it knows (cp, mv,
|
|
26
|
+
// rm, ln, tee, sed -i, git -C, a global npm install…) and the paths they name; it resolves `cd`, `~`, $HOME and
|
|
27
|
+
// $TMPDIR, and follows a link to where it really points. It does not see what a program does on its own (a
|
|
28
|
+
// script, `node x.js`, `make`, a test runner), a path hidden in a variable it cannot read, or a write inside an
|
|
29
|
+
// awk / python / perl program: those run under workspace and are refused under read-only, where only a known
|
|
30
|
+
// read-only program runs. Codex's sandbox and OpenCode's rules are the stronger layer where they apply.
|
|
31
|
+
//
|
|
32
|
+
// The setting (lib/worker-guard-setting.js; Settings → Automations → Automation behavior → Protect workers):
|
|
33
|
+
// enforce (default) a call outside the capability is refused, with the reason in the run's transcript
|
|
34
|
+
// warn the same calls are allowed, each noted in the transcript as a warning
|
|
35
|
+
// off today's behaviour before this guard: Claude's read-only tool list, Codex's sandbox with the parent
|
|
36
|
+
// folder, OpenCode answering "always"
|
|
37
|
+
// Capability "full" is never guarded whatever the setting.
|
|
38
|
+
|
|
39
|
+
import { existsSync, lstatSync, realpathSync } from 'node:fs';
|
|
40
|
+
import { homedir, tmpdir } from 'node:os';
|
|
41
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
42
|
+
import { acpCall, acpToolPaths } from './antigravity/tool-call.js';
|
|
43
|
+
|
|
44
|
+
export const WORKER_GUARD_MODES = Object.freeze(['enforce', 'warn', 'off']);
|
|
45
|
+
export const WORKER_GUARD_DEFAULT = 'enforce';
|
|
46
|
+
export const WORKER_CONFINEMENT = 'worker';
|
|
47
|
+
/** The capabilities the guard applies to (never 'full'). */
|
|
48
|
+
export const GUARDED_CAPABILITIES = Object.freeze(['read-only', 'workspace']);
|
|
49
|
+
/** Folders under the home a worker must never write into (documented; the rule itself is "outside the writable roots"). */
|
|
50
|
+
export const PROTECTED_HOME_DIRS = Object.freeze(['.claude', '.codex', '.agents', '.config/opencode', '.synabun', '.local/share/opencode', '.gemini', '.ssh', '.gnupg', '.aws', '.config']);
|
|
51
|
+
|
|
52
|
+
export function normalizeWorkerGuardMode(value) {
|
|
53
|
+
const text = String(value ?? '').trim().toLowerCase();
|
|
54
|
+
return WORKER_GUARD_MODES.includes(text) ? text : WORKER_GUARD_DEFAULT;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CLAUDE_WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
|
|
58
|
+
// A third-party MCP tool whose name says it changes something (under read-only).
|
|
59
|
+
const MCP_WRITE_VERB = /(?:^|[_-])(?:write|edit|create|delete|remove|move|rename|upload|insert|update|execute|exec|run|apply|push|deploy|send|post|put|patch|set|add|append|truncate|drop|commit|publish|install|uninstall|kill|restart|start|stop)(?:[_-]|$)|^(?:write|edit|create|delete|remove|move|rename|upload|insert|update|execute|apply|push|deploy|send|post|put|patch|set|add|append|truncate|drop|commit|publish|install|uninstall|kill|restart|start|stop)[A-Z]/i;
|
|
60
|
+
|
|
61
|
+
// ── Paths ───────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** `root` holds `target` (both absolute, normalized); allowRoot: the root itself counts. */
|
|
64
|
+
function inside(root, target, allowRoot = true) {
|
|
65
|
+
if (!root || !target) return false;
|
|
66
|
+
if (target === root) return allowRoot;
|
|
67
|
+
const base = root.endsWith(sep) ? root : root + sep;
|
|
68
|
+
return target.startsWith(base);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Where a path really is: the deepest existing ancestor resolved through its links, the rest as named. */
|
|
72
|
+
export function realPlace(abs) {
|
|
73
|
+
let probe = abs;
|
|
74
|
+
const rest = [];
|
|
75
|
+
for (let i = 0; i < 512; i++) {
|
|
76
|
+
try {
|
|
77
|
+
const real = realpathSync(probe);
|
|
78
|
+
return rest.length ? join(real, ...rest.reverse()) : real;
|
|
79
|
+
} catch {
|
|
80
|
+
const parent = dirname(probe);
|
|
81
|
+
if (parent === probe) return abs;
|
|
82
|
+
rest.push(probe.slice(parent.length).replace(/^[\\/]+/, ''));
|
|
83
|
+
probe = parent;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return abs;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A glob tail (`build/*`, `logs/**`) is checked by its fixed head. */
|
|
90
|
+
function fixedHead(text) {
|
|
91
|
+
const parts = String(text).split(/[\\/]+/);
|
|
92
|
+
const kept = [];
|
|
93
|
+
for (const part of parts) {
|
|
94
|
+
if (/[*?[\]]/.test(part)) break;
|
|
95
|
+
kept.push(part);
|
|
96
|
+
}
|
|
97
|
+
if (kept.length === parts.length) return text;
|
|
98
|
+
return kept.length ? kept.join('/') : '.';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A placeholder directory name standing in for the fresh folder `mktemp -d` would make, under the real temp root. */
|
|
102
|
+
const MKTEMP_DIR = '.synabun-mktemp';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The variables the guard can resolve with confidence: the well-known environment ones (the shell always has them,
|
|
106
|
+
* and the guard sees the same user and machine as the worker). An arbitrary environment variable is deliberately
|
|
107
|
+
* left out — the guard sees the server's environment, not the worker's exact one, so its value is not guaranteed;
|
|
108
|
+
* such a `$VAR` stays unknown and is refused, as before. The line's own assignments (tracked by shellSegments) are
|
|
109
|
+
* resolved separately and layered on top, because they come from the command itself and are therefore certain.
|
|
110
|
+
*/
|
|
111
|
+
function knownEnv(cwd, home, env) {
|
|
112
|
+
const e = env || {};
|
|
113
|
+
const temp = e.TMPDIR || e.TMP || e.TEMP || tmpdir();
|
|
114
|
+
const known = { HOME: home, PWD: cwd, TMPDIR: e.TMPDIR || temp, TMP: e.TMP || temp, TEMP: e.TEMP || temp };
|
|
115
|
+
if (e.USER) known.USER = e.USER;
|
|
116
|
+
if (e.LOGNAME) known.LOGNAME = e.LOGNAME;
|
|
117
|
+
return known;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Expand `$VAR` / `${VAR}` and a leading `~` in a value, from `known` (the well-known env) with `vars` (what the
|
|
122
|
+
* line assigned) layered on top. Returns the expanded string, or null when a `$…` is left unresolved (an unknown
|
|
123
|
+
* variable, an arithmetic `$((…))`, a `$1`, or a command substitution the caller did not resolve) or a `~user`.
|
|
124
|
+
*/
|
|
125
|
+
function expandText(text, known, vars, home) {
|
|
126
|
+
if (typeof text !== 'string') return null;
|
|
127
|
+
let unknown = false;
|
|
128
|
+
const out = text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (m, a, b) => {
|
|
129
|
+
const name = a || b;
|
|
130
|
+
if (vars && Object.prototype.hasOwnProperty.call(vars, name)) return vars[name];
|
|
131
|
+
if (Object.prototype.hasOwnProperty.call(known, name)) return known[name];
|
|
132
|
+
unknown = true;
|
|
133
|
+
return m;
|
|
134
|
+
});
|
|
135
|
+
if (unknown || /\$/.test(out)) return null; // a `$` left over (another substitution, arithmetic, a positional)
|
|
136
|
+
if (out === '~' || out.startsWith('~/') || out.startsWith('~\\')) return home + out.slice(1);
|
|
137
|
+
if (out.startsWith('~')) return null; // ~user: not resolved (conservative; refused as unknown)
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The directory `$(mktemp …)` / `` `mktemp …` `` would create a fresh entry in, as an absolute path under the real
|
|
143
|
+
* temp root (resolved through its links), with a placeholder final segment standing in for the random name. null
|
|
144
|
+
* for anything that is not a plain `mktemp` call. The base is the temp root, except `-p DIR` / `--tmpdir=DIR` or a
|
|
145
|
+
* template operand with a directory part, which the real mktemp would honour (and which the allowed-roots check
|
|
146
|
+
* then validates: a base outside the writable roots becomes a refusal, never a pass).
|
|
147
|
+
*/
|
|
148
|
+
function mktempBase(innerCmd, { cwd, home, env, vars }) {
|
|
149
|
+
const toks = String(innerCmd).trim().split(/\s+/).filter(Boolean).map((w) => w.replace(/^['"]|['"]$/g, ''));
|
|
150
|
+
if (!toks.length || toks[0].replace(/^.*\//, '') !== 'mktemp') return null;
|
|
151
|
+
const known = knownEnv(cwd, home, env);
|
|
152
|
+
const temp = known.TMPDIR;
|
|
153
|
+
let base = temp;
|
|
154
|
+
for (let i = 1; i < toks.length; i++) {
|
|
155
|
+
const w = toks[i];
|
|
156
|
+
if (w === '-p' || w === '--tmpdir') { const d = toks[i + 1]; if (d && !d.startsWith('-')) { base = d; i++; } else base = temp; }
|
|
157
|
+
else if (w.startsWith('--tmpdir=')) base = w.slice('--tmpdir='.length) || temp;
|
|
158
|
+
else if (w === '-t') { i++; base = temp; } // a prefix template (not a directory): mktemp uses TMPDIR
|
|
159
|
+
else if (w.startsWith('-')) { /* -d, -u, -q, -V and the like: no directory */ }
|
|
160
|
+
else base = w.includes('/') ? dirname(w) : cwd; // a template operand: mktemp makes it relative to cwd unless --tmpdir
|
|
161
|
+
}
|
|
162
|
+
const expanded = expandText(base, known, vars, home);
|
|
163
|
+
if (expanded === null) return null;
|
|
164
|
+
const abs = isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
|
|
165
|
+
return join(realPlace(abs), MKTEMP_DIR);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The value a `NAME=value` assignment resolves to (a string a later `$NAME` expands to), or null when it cannot be
|
|
170
|
+
* known. A literal (with `$VAR` / `~` of what is already known) resolves; `NAME=$(mktemp …)` / backticks resolve to
|
|
171
|
+
* a fresh folder under the temp root; every other command substitution, arithmetic or unknown variable stays null.
|
|
172
|
+
*/
|
|
173
|
+
function assignValue(afterEq, subs, { cwd, home, env, vars }) {
|
|
174
|
+
if (!subs || !subs.length) return expandText(afterEq, knownEnv(cwd, home, env), vars, home);
|
|
175
|
+
if (afterEq === '' && subs.length === 1) return mktempBase(subs[0], { cwd, home, env, vars });
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A path a worker named, as an absolute location, or null when it cannot be read as one (a control character, an
|
|
181
|
+
* unknown $variable, a command substitution). `~`, the well-known environment variables and the variables the
|
|
182
|
+
* command line assigned before this point (`vars`, from shellSegments) are expanded.
|
|
183
|
+
*/
|
|
184
|
+
export function placeOf(raw, { cwd, home = homedir(), env = process.env, vars = null } = {}) {
|
|
185
|
+
if (typeof raw !== 'string' || !raw || raw.length > 4096 || /[\u0000-\u001f\u007f]/.test(raw)) return null;
|
|
186
|
+
let text = expandText(raw, knownEnv(cwd, home, env), vars, home);
|
|
187
|
+
if (text === null) return null;
|
|
188
|
+
text = fixedHead(text);
|
|
189
|
+
const abs = isAbsolute(text) ? resolve(text) : resolve(cwd, text);
|
|
190
|
+
return realPlace(abs);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── The confinement ─────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* What a run's capability means for it: null when nothing is guarded (capability full, or the guard off).
|
|
197
|
+
* Throws when the working directory cannot be resolved (the run must not start on a guess).
|
|
198
|
+
* @param {object} o
|
|
199
|
+
* @param {string} o.capability 'read-only' | 'workspace' | 'full'
|
|
200
|
+
* @param {string} o.cwd the run's working directory
|
|
201
|
+
* @param {string} [o.mode] the setting: 'enforce' (default) | 'warn' | 'off'
|
|
202
|
+
* @param {string} [o.scratchDir] the run's own scratch folder, writable too
|
|
203
|
+
* @param {string[]} [o.tmpDirs] the OS temp folders (os.tmpdir() and /tmp by default)
|
|
204
|
+
* @param {number} [o.serverPort] SynaBun's own port
|
|
205
|
+
* @param {number[]} [o.localPorts] other local ports of SynaBun's the shell may not call (the run's own OpenCode serve)
|
|
206
|
+
* @param {{ from:number, to:number }} [o.localPortRange] the ports SynaBun keeps for its OpenCode serves (4096-4400),
|
|
207
|
+
* the same band the macOS sandbox denies (lib/worker-sandbox.js)
|
|
208
|
+
*/
|
|
209
|
+
export function resolveWorkerConfinement({ capability, cwd, mode = WORKER_GUARD_DEFAULT, scratchDir = null, tmpDirs = null, home = homedir(), serverPort = null, localPorts = null, localPortRange = null } = {}) {
|
|
210
|
+
const cap = String(capability || 'full');
|
|
211
|
+
const guard = normalizeWorkerGuardMode(mode);
|
|
212
|
+
if (!GUARDED_CAPABILITIES.includes(cap) || guard === 'off') return null;
|
|
213
|
+
// A guarded run is rooted where the dispatcher put it, never where the server happens to run: without a working
|
|
214
|
+
// directory its scratch folder is the only writable root, and without either it does not start.
|
|
215
|
+
const rootDir = cwd && typeof cwd === 'string' ? cwd : (scratchDir && typeof scratchDir === 'string' ? scratchDir : null);
|
|
216
|
+
if (!rootDir) throw new Error('A guarded worker needs a working directory (or a scratch folder of its own): the run did not start.');
|
|
217
|
+
// The working directory where it really is; one that is not there yet (a stand-in in a test, a folder made
|
|
218
|
+
// later) is kept as named: the rule still holds by its name, and realPlace() resolves what exists at each call.
|
|
219
|
+
let root;
|
|
220
|
+
try { root = realpathSync(rootDir); } catch { root = resolve(rootDir); }
|
|
221
|
+
const temps = Array.isArray(tmpDirs) ? tmpDirs : [tmpdir(), '/tmp'];
|
|
222
|
+
const writable = [root];
|
|
223
|
+
for (const dir of [...temps, scratchDir].filter(Boolean)) {
|
|
224
|
+
try { const real = realpathSync(dir); if (!writable.includes(real)) writable.push(real); } catch { /* a folder that is not there is not writable */ }
|
|
225
|
+
}
|
|
226
|
+
return Object.freeze({
|
|
227
|
+
kind: WORKER_CONFINEMENT, capability: cap, mode: guard, enforced: guard === 'enforce',
|
|
228
|
+
cwd: root, writable: Object.freeze(writable), home, serverPort: Number(serverPort) || null,
|
|
229
|
+
localPorts: Object.freeze([...new Set((Array.isArray(localPorts) ? localPorts : []).map(Number).filter(validPort))]),
|
|
230
|
+
localPortRange: portRange(localPortRange),
|
|
231
|
+
protectedDirs: Object.freeze(PROTECTED_HOME_DIRS.map((d) => join(home, d))),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const validPort = (p) => Number.isInteger(p) && p > 0 && p < 65536;
|
|
236
|
+
function portRange(range) {
|
|
237
|
+
const from = Number(range?.from);
|
|
238
|
+
const to = Number(range?.to);
|
|
239
|
+
return validPort(from) && validPort(to) && from <= to ? Object.freeze({ from, to }) : null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Which of SynaBun's own local services a loopback port is, under this confinement: 'server' (SynaBun's own port),
|
|
244
|
+
* 'serve' (the run's own OpenCode serve), 'reserved' (the band SynaBun keeps for its OpenCode serves), else null.
|
|
245
|
+
*/
|
|
246
|
+
export function controlPortOf(port, confinement) {
|
|
247
|
+
const p = Number(port);
|
|
248
|
+
if (!confinement || !validPort(p)) return null;
|
|
249
|
+
if (p === Number(confinement.serverPort)) return 'server';
|
|
250
|
+
if ((confinement.localPorts || []).includes(p)) return 'serve';
|
|
251
|
+
const range = confinement.localPortRange;
|
|
252
|
+
if (range && p >= range.from && p <= range.to) return 'reserved';
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function controlPortText(kind, host, port, confinement) {
|
|
257
|
+
if (kind === 'server') return `${host}:${port} is SynaBun's own server`;
|
|
258
|
+
if (kind === 'serve') return `${host}:${port} is this run's own OpenCode server`;
|
|
259
|
+
const r = confinement.localPortRange;
|
|
260
|
+
return `${host}:${port} is in the ports SynaBun keeps for its own OpenCode servers (${r.from}-${r.to})`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const DEVICE_PATHS = new Set(['/dev/null', '/dev/stdout', '/dev/stderr', '/dev/tty', '/dev/fd/1', '/dev/fd/2']);
|
|
264
|
+
|
|
265
|
+
/** Whether a resolved location may be written under this confinement (a device such as /dev/null always may). */
|
|
266
|
+
export function mayWrite(place, confinement) {
|
|
267
|
+
if (!place || !confinement) return false;
|
|
268
|
+
if (DEVICE_PATHS.has(place)) return true;
|
|
269
|
+
return confinement.writable.some((root) => inside(root, place, true));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const rootsText = (c) => c.writable.map((r) => r === c.cwd ? `${r} (your working directory)` : r).join(', ');
|
|
273
|
+
|
|
274
|
+
// What the worker can do to make an unresolvable write target pass: say it plainly in the refusal so the run does
|
|
275
|
+
// not keep retrying the same shape. Variables the guard knows (a literal path, `$TMPDIR`, `$HOME`, `$PWD`, or a
|
|
276
|
+
// `D=$(mktemp -d)` the line set) resolve; anything else (another command substitution, an unknown variable) does not.
|
|
277
|
+
const passHint = (c) => `Write to a literal path, or under a variable the command sets itself (a literal, $TMPDIR, $HOME, $PWD, or D=$(mktemp -d)); this run writes inside ${rootsText(c)} only.`;
|
|
278
|
+
|
|
279
|
+
/** A refusal: { reason, rule, target? }. */
|
|
280
|
+
function refusal(rule, reason, target = null) {
|
|
281
|
+
return target ? { rule, reason, target } : { rule, reason };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Whether a worker may make this (non-shell) tool call: null when it may, else a refusal the worker reads.
|
|
286
|
+
* Claude Code's built-in tools by name; MCP tools pass except a commit under read-only.
|
|
287
|
+
*/
|
|
288
|
+
export function workerToolDecision(toolName, input, confinement) {
|
|
289
|
+
if (!confinement) return null;
|
|
290
|
+
try {
|
|
291
|
+
const name = String(toolName || '');
|
|
292
|
+
const args = input && typeof input === 'object' ? input : {};
|
|
293
|
+
if (name === 'Bash' || name === 'PowerShell') return workerShellDecision(args.command, confinement);
|
|
294
|
+
if (CLAUDE_WRITE_TOOLS.has(name)) {
|
|
295
|
+
if (confinement.capability === 'read-only') return refusal('read-only', `This run has capability read-only: ${name} is not available. Report what you found instead of changing files; a run that must edit needs capability workspace.`);
|
|
296
|
+
const raw = typeof args.file_path === 'string' ? args.file_path : typeof args.notebook_path === 'string' ? args.notebook_path : null;
|
|
297
|
+
const place = raw ? placeOf(raw, { cwd: confinement.cwd, home: confinement.home }) : null;
|
|
298
|
+
if (!place) return refusal('unknown-path', `${name} needs a plain path: SynaBun could not read "${String(raw || '').slice(0, 200)}". This run writes inside ${rootsText(confinement)} only.`);
|
|
299
|
+
if (!mayWrite(place, confinement)) return refusal('write-outside', `${name} to ${place} is outside this run's workspace. This run (capability workspace) writes inside ${rootsText(confinement)} only; nothing is written under the home folder, another project or a tool's configuration.`, place);
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
if (/^mcp__SynaBun__git$/.test(name) && confinement.capability === 'read-only' && String(args.action || '') === 'commit') {
|
|
303
|
+
return refusal('read-only', 'This run has capability read-only: the git tool\'s commit action is not available.');
|
|
304
|
+
}
|
|
305
|
+
// Another MCP server's tool under read-only: one whose name says it writes is refused (SynaBun's own tools go
|
|
306
|
+
// through SynaBun's own policies and stay; a third-party server's writes are its own and cannot be confined).
|
|
307
|
+
const mcp = /^mcp__([^_].*?)__(.+)$/.exec(name);
|
|
308
|
+
if (mcp && confinement.capability === 'read-only' && mcp[1] !== 'SynaBun' && MCP_WRITE_VERB.test(mcp[2])) {
|
|
309
|
+
return refusal('read-only', `This run has capability read-only: ${name} (a tool of the ${mcp[1]} server whose name says it writes) is not available. Read with that server's read tools, or report what should change.`);
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
} catch (err) {
|
|
313
|
+
return refusal('check-failed', `The call could not be checked (${err?.message || err}), so it is refused.`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── The shell heuristic ─────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
// Programs that only read (with the options that would make them write or run something: refused). The
|
|
320
|
+
// read-only plan-mode classifier's table (lib/assistant-plan-permissions.js) widened for a worker's inspection.
|
|
321
|
+
const READ_PROGRAMS = new Map([
|
|
322
|
+
['ls', []], ['pwd', []], ['cat', []], ['head', []], ['tail', []], ['wc', []], ['stat', []], ['du', []], ['df', []],
|
|
323
|
+
['tree', ['-o', '-R']], ['file', []], ['date', []], ['uname', []], ['id', []], ['whoami', []], ['hostname', []], ['uptime', []],
|
|
324
|
+
['env', []], ['printenv', []], ['echo', []], ['printf', []], ['true', []], ['false', []], ['test', []], ['[', []], ['type', []], ['command', []],
|
|
325
|
+
['grep', []], ['egrep', []], ['fgrep', []], ['rg', ['--pre', '--pre-glob', '--hostname-bin']], ['ag', []], ['ugrep', []],
|
|
326
|
+
['fd', ['-x', '--exec', '-X', '--exec-batch']], ['fdfind', ['-x', '--exec', '-X', '--exec-batch']],
|
|
327
|
+
['find', ['-exec', '-execdir', '-ok', '-okdir', '-delete', '-fprint', '-fprint0', '-fprintf', '-fls']],
|
|
328
|
+
['which', []], ['whereis', []], ['basename', []], ['dirname', []], ['realpath', []], ['readlink', []],
|
|
329
|
+
['diff', []], ['cmp', []], ['comm', []], ['sort', ['-o', '--output', '-T', '--temporary-directory', '--compress-program']], ['uniq', []],
|
|
330
|
+
['cut', []], ['tr', []], ['tac', []], ['nl', []], ['column', []], ['fold', []], ['paste', []], ['rev', []], ['expand', []],
|
|
331
|
+
['jq', []], ['yq', ['-i', '--inplace']], ['xxd', ['-r']], ['od', []], ['hexdump', []], ['strings', []],
|
|
332
|
+
['md5', []], ['md5sum', []], ['shasum', []], ['sha1sum', []], ['sha256sum', []], ['cksum', []],
|
|
333
|
+
['sed', ['-i', '-I', '--in-place', '-f', '--file']], ['awk', ['-f', '--file']], ['gawk', ['-f', '--file']], ['mawk', ['-f']],
|
|
334
|
+
['less', []], ['more', []], ['ps', []], ['sleep', []], ['seq', []], ['bc', []], ['expr', []],
|
|
335
|
+
['curl', ['-o', '--output', '-O', '--remote-name', '--remote-name-all', '-J', '--remote-header-name', '--create-dirs', '--output-dir', '-D', '--dump-header', '-c', '--cookie-jar', '--trace', '--trace-ascii', '--trace-config', '-K', '--config', '--libcurl', '--etag-save', '--hsts', '--alt-svc', '--stderr', '-T', '--upload-file']],
|
|
336
|
+
['python', ['-m', '-c']], ['python3', ['-m', '-c']], ['node', ['-e', '--eval', '-p', '--print', '-r', '--require', '--import', '-i']],
|
|
337
|
+
['npm', []], ['npx', []], ['pnpm', []], ['yarn', []], ['vitest', []],
|
|
338
|
+
]);
|
|
339
|
+
// A program above whose arguments must match one of these shapes to count as a read (`cwd`: the run's working
|
|
340
|
+
// directory; `inside(path)` tells whether a path argument is under it).
|
|
341
|
+
const READ_SHAPES = new Map([
|
|
342
|
+
['python', (args) => args[0] === '--version' || args[0] === '-V'],
|
|
343
|
+
['python3', (args) => args[0] === '--version' || args[0] === '-V'],
|
|
344
|
+
// node: its version, a syntax check, or the project's own tests / scripts under cwd (item 10 below).
|
|
345
|
+
['node', (args, { inside }) => args[0] === '--version' || args[0] === '-v'
|
|
346
|
+
|| ((args[0] === '--check' || args[0] === '-c') && args.length === 2)
|
|
347
|
+
|| (args[0] === '--test' && args.slice(1).filter((w) => !isOption(w)).every((w) => inside(w)))
|
|
348
|
+
|| (args.length >= 1 && !isOption(args[0]) && /\.(?:m?js|cjs)$/.test(args[0]) && /(^|\/)(?:scripts|tests?|test)\//.test(args[0]) && inside(args[0]))],
|
|
349
|
+
['npm', (args) => ['ls', 'list', 'view', 'info', 'show', 'outdated', 'audit', 'why', 'explain', '--version', '-v', 'help', 'ping', 'search', 'test', 't'].includes(args[0]) && !args.includes('fix')
|
|
350
|
+
|| (args[0] === 'run' && /^test(?::|$)/.test(String(args[1] || '')))],
|
|
351
|
+
['npx', (args) => args[0] === 'vitest' && !args.slice(1).some((w) => /^(?:-u|--update|--coverage|--reporter=(?:json|junit|html)|--outputFile)/.test(w))],
|
|
352
|
+
['vitest', (args) => !args.some((w) => /^(?:-u|--update|--coverage|--outputFile)/.test(w))],
|
|
353
|
+
['pnpm', (args) => ['ls', 'list', 'why', 'outdated', 'audit', '--version', '-v', 'test', 't'].includes(args[0]) && !args.includes('fix')],
|
|
354
|
+
['yarn', (args) => ['list', 'why', 'info', '--version', '-v', 'outdated', 'audit', 'test'].includes(args[0])],
|
|
355
|
+
['uniq', (args) => args.filter((w) => !isOption(w)).length <= 1],
|
|
356
|
+
['sed', (args) => !args.filter((w) => !isOption(w)).some((w) => /(^|[;\n{}\s])\s*[wWe](\s|$)|\/[gpImMx0-9]*[wWe][gpImMx0-9]*(\s|;|$)/.test(w))],
|
|
357
|
+
['awk', (args) => !args.some((w) => /[>|]|system\s*\(|getline|fflush\s*\(\s*"/.test(w))],
|
|
358
|
+
['gawk', (args) => !args.some((w) => /[>|]|system\s*\(|getline/.test(w))],
|
|
359
|
+
['mawk', (args) => !args.some((w) => /[>|]|system\s*\(|getline/.test(w))],
|
|
360
|
+
['curl', (args) => curlShape(args)],
|
|
361
|
+
]);
|
|
362
|
+
const GIT_READ = new Set(['status', 'diff', 'log', 'show', 'blame', 'rev-parse', 'ls-files', 'ls-tree', 'grep', 'shortlog', 'describe', 'cat-file', 'merge-base', 'show-ref', 'rev-list', 'diff-tree', 'name-rev', 'for-each-ref', 'count-objects', 'check-ignore', 'var', 'version', '--version']);
|
|
363
|
+
const GIT_READ_WITH_ARGS = new Map([
|
|
364
|
+
['branch', (a) => a.every((w) => /^(-a|-r|-v|-vv|--list|--all|--remotes|--verbose|--show-current|--contains|--merged|--no-merged|--format=.*|--sort=.*)$/.test(w) || !w.startsWith('-') && a.some((x) => /^(--list|--contains|--merged|--no-merged)$/.test(x)))],
|
|
365
|
+
['remote', (a) => a.length === 0 || a[0] === '-v' || a[0] === 'show' || a[0] === 'get-url'],
|
|
366
|
+
['tag', (a) => a.length === 0 || a.every((w) => /^(-l|--list|-n\d*|--contains|--merged|--sort=.*|--format=.*)$/.test(w) || !w.startsWith('-'))],
|
|
367
|
+
['stash', (a) => a[0] === 'list' || a[0] === 'show'],
|
|
368
|
+
['config', (a) => a.some((w) => /^(--get|--get-all|--get-regexp|--list|-l)$/.test(w)) && !a.some((w) => /^(--global|--system|--file|-f|--unset|--add|--replace-all|--edit|-e)$/.test(w))],
|
|
369
|
+
['worktree', (a) => a[0] === 'list'],
|
|
370
|
+
['submodule', (a) => a[0] === 'status'],
|
|
371
|
+
]);
|
|
372
|
+
// Options of a git read that write a file or run a program (abbreviated long options too).
|
|
373
|
+
const GIT_BLOCKED = ['--output', '--ext-diff', '--open-files-in-pager', '-O', '--textconv'];
|
|
374
|
+
// `git <sub>` that writes the repository (anything not read is taken as writing).
|
|
375
|
+
const GIT_GLOBAL_WRITE_OPTIONS = ['--global', '--system'];
|
|
376
|
+
|
|
377
|
+
// Programs that write the paths they are given (every path-like word is checked), or that write somewhere
|
|
378
|
+
// known. `dest`: only the destination words (the last non-option word, or -t DIR) are checked.
|
|
379
|
+
const WRITERS = new Map([
|
|
380
|
+
['rm', 'all'], ['rmdir', 'all'], ['unlink', 'all'], ['shred', 'all'], ['truncate', 'all'], ['touch', 'all'], ['mkdir', 'all'],
|
|
381
|
+
['mkfifo', 'all'], ['mknod', 'all'], ['chmod', 'all'], ['chown', 'all'], ['chgrp', 'all'], ['chflags', 'all'], ['xattr', 'all'], ['setfacl', 'all'],
|
|
382
|
+
['cp', 'dest'], ['mv', 'all'], ['install', 'dest'], ['rsync', 'dest'], ['scp', 'dest'], ['ln', 'dest'],
|
|
383
|
+
['tee', 'all'], ['dd', 'dd'], ['patch', 'patch'], ['tar', 'tar'], ['unzip', 'unzip'], ['zip', 'zip'],
|
|
384
|
+
['sed', 'sed'], ['perl', 'perl'], ['wget', 'wget'], ['curl', 'curl'], ['git', 'git'],
|
|
385
|
+
['npm', 'pkg'], ['pnpm', 'pkg'], ['yarn', 'pkg'], ['pip', 'pip'], ['pip3', 'pip'], ['brew', 'brew'], ['gem', 'gem'], ['cargo', 'cargo'],
|
|
386
|
+
['defaults', 'defaults'], ['crontab', 'crontab'], ['launchctl', 'launchctl'], ['find', 'find'], ['fd', 'fd'], ['fdfind', 'fd'],
|
|
387
|
+
['uniq', 'uniq'], ['sort', 'sort'], ['awk', 'awk'], ['gawk', 'awk'], ['mawk', 'awk'],
|
|
388
|
+
]);
|
|
389
|
+
const FIND_WRITES = ['-delete', '-fprint', '-fprint0', '-fprintf', '-fls'];
|
|
390
|
+
const FIND_EXEC = ['-exec', '-execdir', '-ok', '-okdir'];
|
|
391
|
+
const FD_EXEC = /^(-x|--exec|-X|--exec-batch)$/;
|
|
392
|
+
const PRIVILEGE = new Set(['sudo', 'su', 'doas', 'pkexec']);
|
|
393
|
+
const WRAPPERS = new Set(['env', 'nice', 'nohup', 'time', 'timeout', 'gtimeout', 'command', 'builtin', 'exec', 'caffeinate', 'stdbuf', 'xargs', 'arch']);
|
|
394
|
+
const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish']);
|
|
395
|
+
// Interpreters that read their program from standard input when given none: fed from a pipe or a file they
|
|
396
|
+
// cannot be checked, so they are refused.
|
|
397
|
+
const INTERPRETERS = new Set(['node', 'python', 'python2', 'python3', 'perl', 'ruby', 'php', 'deno', 'bun', 'osascript', 'lua', 'tclsh']);
|
|
398
|
+
// Shell keywords that may open a segment (`for … ; do touch x; done`): taken off the front, the command follows.
|
|
399
|
+
const LEADING_KEYWORDS = new Set(['do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', '{', 'time']);
|
|
400
|
+
const LONE_KEYWORDS = new Set(['done', 'fi', 'esac', '}', 'else', 'then', 'do', ';;']);
|
|
401
|
+
// Programs that talk to a network address (a URL or host:port argument): kept off loopback where the rule says so.
|
|
402
|
+
const NET_CLIENTS = new Set(['curl', 'wget', 'http', 'https', 'xh', 'httpie', 'nc', 'ncat', 'netcat', 'telnet', 'websocat', 'wscat', 'ab', 'hey', 'siege']);
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* A command line → its simple commands, each { words, redirects: [{ op, target }], cwd, piped, stdin, vars },
|
|
406
|
+
* following `cd`, `(` `)`, `$( )` and backticks; null when it cannot be read (a control character, an unbalanced
|
|
407
|
+
* quote). Quotes are stripped from words (a quoted path is still a path), the word keeps a `quoted` mark. `piped`:
|
|
408
|
+
* the command reads what the one before wrote; `stdin`: it reads a file (`<`), a here-document or a here-string.
|
|
409
|
+
* `vars`: the shell variables this segment can resolve — the ones the line assigned before it (a literal, `$HOME`,
|
|
410
|
+
* or `$(mktemp -d)`). An assignment persists only where bash keeps it (a standalone `VAR=…`, an `export`/`declare`/
|
|
411
|
+
* `readonly`/`typeset`/`local`, never a `VAR=… cmd` prefix) and never leaks out of a subshell or a pipeline.
|
|
412
|
+
*/
|
|
413
|
+
export function shellSegments(command, { cwd, home = homedir(), env = process.env, seedVars = null } = {}) {
|
|
414
|
+
const text = String(command ?? '');
|
|
415
|
+
if (/[\u0000-\u0008\u000b-\u001f\u007f]/.test(text)) return null;
|
|
416
|
+
const segments = [];
|
|
417
|
+
const cwdStack = [];
|
|
418
|
+
let here = cwd;
|
|
419
|
+
let words = [];
|
|
420
|
+
let word = '';
|
|
421
|
+
let quoted = false;
|
|
422
|
+
let hasWord = false;
|
|
423
|
+
let wordSubs = []; // command substitutions seen while building the current word (for `VAR=$(…)`)
|
|
424
|
+
let redirectNext = null; // the operator text waiting for its target
|
|
425
|
+
let pendingRedirects = [];
|
|
426
|
+
let piped = false;
|
|
427
|
+
let nextPiped = false;
|
|
428
|
+
let stdin = false;
|
|
429
|
+
let subshellDepth = 0; // inside `( … )`: an assignment there does not leak out
|
|
430
|
+
const assigned = { ...(seedVars || {}) }; // the resolved variables in effect for the next segment
|
|
431
|
+
let i = 0;
|
|
432
|
+
const finishWord = () => {
|
|
433
|
+
if (!hasWord && !word && !wordSubs.length) return;
|
|
434
|
+
if (redirectNext !== null) { pendingRedirects.push({ op: redirectNext, target: word, quoted }); redirectNext = null; }
|
|
435
|
+
else words.push({ text: word, quoted, subs: wordSubs });
|
|
436
|
+
word = ''; quoted = false; hasWord = false; wordSubs = [];
|
|
437
|
+
};
|
|
438
|
+
// The assignments `VAR=val` carries, read off a word (unquoted `NAME=` head): { name, afterEq, subs }.
|
|
439
|
+
const assignOf = (w) => { const eq = w.text.indexOf('='); return { name: w.text.slice(0, eq), afterEq: w.text.slice(eq + 1), subs: w.subs || [] }; };
|
|
440
|
+
const isAssign = (w) => w && !w.quoted && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w.text);
|
|
441
|
+
const finishSegment = () => {
|
|
442
|
+
finishWord();
|
|
443
|
+
if (redirectNext !== null) { pendingRedirects.push({ op: redirectNext, target: '', quoted: false }); redirectNext = null; }
|
|
444
|
+
if (words.length || pendingRedirects.length) {
|
|
445
|
+
// `VAR=value` prefixes and leading keywords are stripped so the program is the first word.
|
|
446
|
+
const wl = words.slice();
|
|
447
|
+
const prefixAssigns = [];
|
|
448
|
+
for (;;) {
|
|
449
|
+
if (wl.length && isAssign(wl[0])) { prefixAssigns.push(assignOf(wl[0])); wl.shift(); continue; }
|
|
450
|
+
if (wl.length && !wl[0].quoted && LEADING_KEYWORDS.has(wl[0].text)) { wl.shift(); continue; }
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
let list = wl.map((w) => w.text);
|
|
454
|
+
let marks = wl.map((w) => w.quoted);
|
|
455
|
+
if (list.length === 1 && !marks[0] && LONE_KEYWORDS.has(list[0])) { list = []; marks = []; }
|
|
456
|
+
// `vars` is the state before this segment: the command's own prefix assignments do not affect its own
|
|
457
|
+
// expansion (bash sets them only in the command's environment), so they are folded in only afterwards.
|
|
458
|
+
const seg = { words: list, quoted: marks, redirects: pendingRedirects, cwd: here, piped, stdin, vars: { ...assigned } };
|
|
459
|
+
segments.push(seg);
|
|
460
|
+
// Which assignments persist for a later segment: a standalone `VAR=…` (no program), or the names an
|
|
461
|
+
// `export` / `declare` / `readonly` / `typeset` / `local` sets. Nothing inside a subshell (`subshellDepth`)
|
|
462
|
+
// or a pipeline segment (`piped` reads from the previous, `nextPiped` writes to the next) leaks out.
|
|
463
|
+
const topLevel = subshellDepth === 0 && !piped && !nextPiped;
|
|
464
|
+
const program = list[0];
|
|
465
|
+
const exportLike = program === 'export' || program === 'declare' || program === 'readonly' || program === 'typeset' || program === 'local';
|
|
466
|
+
const persist = !program ? prefixAssigns : exportLike ? wl.slice(1).filter(isAssign).map(assignOf) : [];
|
|
467
|
+
if (topLevel) for (const a of persist) {
|
|
468
|
+
const value = assignValue(a.afterEq, a.subs, { cwd: here, home, env, vars: assigned });
|
|
469
|
+
if (value === null) delete assigned[a.name]; // becomes unknown: forget any earlier value (conservative)
|
|
470
|
+
else assigned[a.name] = value;
|
|
471
|
+
}
|
|
472
|
+
// `cd X` moves what follows (also behind `command` / `builtin`, which run the same built-in).
|
|
473
|
+
const cdAt = (list[0] === 'command' || list[0] === 'builtin') && (list[1] === 'cd' || list[1] === 'pushd') ? 1 : (list[0] === 'cd' || list[0] === 'pushd') ? 0 : -1;
|
|
474
|
+
if (cdAt >= 0) {
|
|
475
|
+
const target = list[cdAt + 1];
|
|
476
|
+
if (!target || target === '-') here = cwd;
|
|
477
|
+
else here = placeOf(target, { cwd: here, home, env, vars: seg.vars }) || null;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
words = []; pendingRedirects = []; piped = nextPiped; nextPiped = false; stdin = false;
|
|
481
|
+
};
|
|
482
|
+
let quote = null;
|
|
483
|
+
while (i < text.length) {
|
|
484
|
+
const ch = text[i];
|
|
485
|
+
if (quote === "'") { if (ch === "'") { quote = null; } else word += ch; i++; continue; }
|
|
486
|
+
if (quote === '"') {
|
|
487
|
+
if (ch === '"') { quote = null; i++; continue; }
|
|
488
|
+
if (ch === '\\' && i + 1 < text.length && '"\\$`'.includes(text[i + 1])) { word += text[i + 1]; i += 2; continue; }
|
|
489
|
+
if (ch === '$' && text[i + 1] === '(') { // a substitution inside quotes: its words are their own segment
|
|
490
|
+
const end = matching(text, i + 1, '(', ')');
|
|
491
|
+
if (end < 0) return null;
|
|
492
|
+
const innerText = text.slice(i + 2, end);
|
|
493
|
+
const inner = shellSegments(innerText, { cwd: here, home, env, seedVars: assigned });
|
|
494
|
+
if (!inner) return null;
|
|
495
|
+
segments.push(...inner);
|
|
496
|
+
wordSubs.push(innerText); hasWord = true; i = end + 1; continue;
|
|
497
|
+
}
|
|
498
|
+
if (ch === '`') {
|
|
499
|
+
const end = text.indexOf('`', i + 1);
|
|
500
|
+
if (end < 0) return null;
|
|
501
|
+
const innerText = text.slice(i + 1, end);
|
|
502
|
+
const inner = shellSegments(innerText, { cwd: here, home, env, seedVars: assigned });
|
|
503
|
+
if (!inner) return null;
|
|
504
|
+
segments.push(...inner);
|
|
505
|
+
wordSubs.push(innerText); hasWord = true; i = end + 1; continue;
|
|
506
|
+
}
|
|
507
|
+
word += ch; i++; continue;
|
|
508
|
+
}
|
|
509
|
+
if (ch === "'" || ch === '"') { quote = ch; quoted = true; hasWord = true; i++; continue; }
|
|
510
|
+
if (ch === '\\') { if (i + 1 < text.length) { word += text[i + 1]; hasWord = true; i += 2; } else i++; continue; }
|
|
511
|
+
if (ch === '#' && !word && !hasWord) { // a comment to the end of the line
|
|
512
|
+
const end = text.indexOf('\n', i);
|
|
513
|
+
if (end < 0) break;
|
|
514
|
+
i = end; continue;
|
|
515
|
+
}
|
|
516
|
+
if (ch === '$' && text[i + 1] === '(') {
|
|
517
|
+
const end = matching(text, i + 1, '(', ')');
|
|
518
|
+
if (end < 0) return null;
|
|
519
|
+
const innerText = text.slice(i + 2, end);
|
|
520
|
+
const inner = shellSegments(innerText, { cwd: here, home, env, seedVars: assigned });
|
|
521
|
+
if (!inner) return null;
|
|
522
|
+
segments.push(...inner);
|
|
523
|
+
wordSubs.push(innerText); hasWord = true; i = end + 1; continue;
|
|
524
|
+
}
|
|
525
|
+
if (ch === '`') {
|
|
526
|
+
const end = text.indexOf('`', i + 1);
|
|
527
|
+
if (end < 0) return null;
|
|
528
|
+
const innerText = text.slice(i + 1, end);
|
|
529
|
+
const inner = shellSegments(innerText, { cwd: here, home, env, seedVars: assigned });
|
|
530
|
+
if (!inner) return null;
|
|
531
|
+
segments.push(...inner);
|
|
532
|
+
wordSubs.push(innerText); hasWord = true; i = end + 1; continue;
|
|
533
|
+
}
|
|
534
|
+
if ((ch === '<' || ch === '>') && text[i + 1] === '(') { // process substitution: its own segment
|
|
535
|
+
const end = matching(text, i + 1, '(', ')');
|
|
536
|
+
if (end < 0) return null;
|
|
537
|
+
const inner = shellSegments(text.slice(i + 2, end), { cwd: here, home, env, seedVars: assigned });
|
|
538
|
+
if (!inner) return null;
|
|
539
|
+
segments.push(...inner);
|
|
540
|
+
hasWord = true; i = end + 1; continue;
|
|
541
|
+
}
|
|
542
|
+
if (ch === '(') { finishSegment(); cwdStack.push(here); subshellDepth++; i++; continue; }
|
|
543
|
+
if (ch === ')') { finishSegment(); here = cwdStack.length ? cwdStack.pop() : here; if (subshellDepth > 0) subshellDepth--; i++; continue; }
|
|
544
|
+
// `{ …; }` groups: a standalone brace only (`{}` of xargs and `${VAR}` are words)
|
|
545
|
+
if (ch === '{' && !word && !hasWord && /\s/.test(text[i + 1] || '')) { i++; continue; }
|
|
546
|
+
if (ch === '}' && !word && !hasWord && (i === 0 || /[\s;]/.test(text[i - 1])) && (i + 1 >= text.length || /[\s;&|)]/.test(text[i + 1]))) { finishSegment(); i++; continue; }
|
|
547
|
+
if (ch === '\n' || ch === ';') { finishSegment(); i++; continue; }
|
|
548
|
+
if (ch === '&' || ch === '|') { // && || | |& & (a background job)
|
|
549
|
+
let op = '';
|
|
550
|
+
while (i < text.length && (text[i] === '&' || text[i] === '|')) { op += text[i]; i++; }
|
|
551
|
+
nextPiped = op === '|' || op === '|&';
|
|
552
|
+
finishSegment();
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (ch === '<' || ch === '>') {
|
|
556
|
+
finishWord();
|
|
557
|
+
// the operator: >, >>, >|, &>, &>>, 2>, 2>>, <, <<, <<<, <>, and fd dups (2>&1, >&2)
|
|
558
|
+
let op = '';
|
|
559
|
+
let j = i;
|
|
560
|
+
while (j < text.length && '<>|&'.includes(text[j])) { op += text[j]; j++; }
|
|
561
|
+
// a digit before the operator was taken as a word: `2` → part of the redirect
|
|
562
|
+
if (words.length && /^\d+$/.test(words[words.length - 1].text) && !words[words.length - 1].quoted && (i === 0 || !/\s/.test(text[i - 1]))) { op = words.pop().text + op; }
|
|
563
|
+
if (/&\d*$/.test(op) && (text[j] === undefined || /[\s;&|)]/.test(text[j]) || /\d/.test(text[j]))) { // a dup (2>&1, >&2): no file
|
|
564
|
+
while (j < text.length && /\d/.test(text[j])) j++;
|
|
565
|
+
i = j; continue;
|
|
566
|
+
}
|
|
567
|
+
if (op.startsWith('<<')) { // a here-doc or here-string: not a file; skip the word
|
|
568
|
+
redirectNext = null;
|
|
569
|
+
stdin = true;
|
|
570
|
+
i = j;
|
|
571
|
+
while (i < text.length && (text[i] === ' ' || text[i] === '\t')) i++;
|
|
572
|
+
while (i < text.length && !/[\s;&|<>]/.test(text[i])) i++;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (op.includes('<') && !op.includes('>')) { // input: skip the file word
|
|
576
|
+
stdin = true;
|
|
577
|
+
i = j;
|
|
578
|
+
while (i < text.length && (text[i] === ' ' || text[i] === '\t')) i++;
|
|
579
|
+
while (i < text.length && !/[\s;&|<>]/.test(text[i])) i++;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
redirectNext = op; i = j;
|
|
583
|
+
while (i < text.length && (text[i] === ' ' || text[i] === '\t')) i++;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (ch === ' ' || ch === '\t' || ch === '\r') { finishWord(); i++; continue; }
|
|
587
|
+
word += ch; hasWord = true; i++;
|
|
588
|
+
}
|
|
589
|
+
if (quote) return null;
|
|
590
|
+
finishSegment();
|
|
591
|
+
return segments;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function matching(text, open, a, b) {
|
|
595
|
+
let depth = 0;
|
|
596
|
+
let quote = null;
|
|
597
|
+
for (let i = open; i < text.length; i++) {
|
|
598
|
+
const ch = text[i];
|
|
599
|
+
if (quote) { if (ch === quote) quote = null; else if (ch === '\\') i++; continue; }
|
|
600
|
+
if (ch === "'" || ch === '"') { quote = ch; continue; }
|
|
601
|
+
if (ch === '\\') { i++; continue; }
|
|
602
|
+
if (ch === a) depth++;
|
|
603
|
+
else if (ch === b) { depth--; if (depth === 0) return i; }
|
|
604
|
+
}
|
|
605
|
+
return -1;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function blockedFlag(word, flags) {
|
|
609
|
+
return flags.some((flag) => word === flag || word.startsWith(`${flag}=`)
|
|
610
|
+
|| (flag.startsWith('--') && /^--[^-=]/.test(word) && flag.length > 3 && flag.startsWith(word.split('=')[0]) && word.split('=')[0].length >= 4)
|
|
611
|
+
|| (/^-[a-zA-Z]$/.test(flag) && /^-[a-zA-Z]/.test(word) && word.slice(1).includes(flag[1])));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const isOption = (w) => w.startsWith('-') && w !== '-';
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* The words of a segment with wrappers (env, timeout 5, nice, xargs -I{}, arch -arm64) taken off the front:
|
|
618
|
+
* { words, cwd } where `cwd` is the folder `env -C` / `--chdir` moved to (null when it cannot be read), else
|
|
619
|
+
* undefined.
|
|
620
|
+
*/
|
|
621
|
+
function unwrap(words, { cwd, home, env, vars } = {}) {
|
|
622
|
+
let list = words;
|
|
623
|
+
let moved;
|
|
624
|
+
const at = (raw) => placeOf(raw, { cwd: moved ?? cwd, home, env, vars }) || null;
|
|
625
|
+
for (let guard = 0; guard < 8 && list.length; guard++) {
|
|
626
|
+
const program = list[0];
|
|
627
|
+
if (!WRAPPERS.has(program)) return { words: list, cwd: moved };
|
|
628
|
+
let rest = list.slice(1);
|
|
629
|
+
if (program === 'timeout' || program === 'gtimeout') {
|
|
630
|
+
while (rest.length && rest[0].startsWith('-')) rest = rest.slice(rest[0] === '-s' || rest[0] === '-k' ? 2 : 1);
|
|
631
|
+
rest = rest.slice(1); // the duration
|
|
632
|
+
} else if (program === 'env') {
|
|
633
|
+
// env [-i] [-u NAME] [-C DIR] [-S string] [-P path] [NAME=value]… command
|
|
634
|
+
while (rest.length && (rest[0].startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(rest[0]))) {
|
|
635
|
+
const flag = rest[0];
|
|
636
|
+
rest = rest.slice(1);
|
|
637
|
+
if (flag === '-u' || flag === '--unset' || flag === '-S' || flag === '--split-string' || flag === '-P' || flag === '--default-path') rest = rest.slice(1);
|
|
638
|
+
else if (flag === '-C' || flag === '--chdir') { moved = rest[0] !== undefined ? at(rest[0]) : null; rest = rest.slice(1); }
|
|
639
|
+
else if (flag.startsWith('--chdir=')) moved = at(flag.slice('--chdir='.length));
|
|
640
|
+
else if (/^-[a-zA-Z]*C$/.test(flag)) { moved = rest[0] !== undefined ? at(rest[0]) : null; rest = rest.slice(1); }
|
|
641
|
+
else if (/^-[a-zA-Z]*u$/.test(flag)) rest = rest.slice(1);
|
|
642
|
+
}
|
|
643
|
+
} else if (program === 'nice' || program === 'stdbuf' || program === 'xargs' || program === 'caffeinate' || program === 'arch') {
|
|
644
|
+
while (rest.length && rest[0].startsWith('-')) {
|
|
645
|
+
const flag = rest[0];
|
|
646
|
+
rest = rest.slice(1);
|
|
647
|
+
if (program === 'xargs' && /^-(I|L|n|P|d|s|E|a)$/.test(flag) && rest.length) rest = rest.slice(1);
|
|
648
|
+
if (program === 'nice' && flag === '-n' && rest.length) rest = rest.slice(1);
|
|
649
|
+
if (program === 'stdbuf' && /^-[ioe]$/.test(flag) && rest.length) rest = rest.slice(1);
|
|
650
|
+
if (program === 'caffeinate' && /^-[tw]$/.test(flag) && rest.length) rest = rest.slice(1);
|
|
651
|
+
}
|
|
652
|
+
if (program === 'xargs') rest = rest.filter((w) => w !== '{}');
|
|
653
|
+
} else if (program === 'time' || program === 'nohup' || program === 'command' || program === 'builtin' || program === 'exec') {
|
|
654
|
+
while (rest.length && rest[0].startsWith('-')) rest = rest.slice(1);
|
|
655
|
+
}
|
|
656
|
+
list = rest;
|
|
657
|
+
}
|
|
658
|
+
return { words: list, cwd: moved };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** `bash -c "…"` / `sh -c` / `eval` → the inner command line, or null. */
|
|
662
|
+
function innerCommand(words) {
|
|
663
|
+
const program = words[0];
|
|
664
|
+
if (program === 'eval') return words.slice(1).join(' ');
|
|
665
|
+
if (SHELLS.has(program)) {
|
|
666
|
+
const at = words.findIndex((w, i) => i > 0 && (w === '-c' || /^-[a-z]*c[a-z]*$/.test(w)));
|
|
667
|
+
if (at > 0 && words[at + 1] !== undefined) return words[at + 1];
|
|
668
|
+
}
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** The destination of a copy-like command: -t DIR, else the last non-option word when two or more. */
|
|
673
|
+
function destinationOf(args) {
|
|
674
|
+
const t = args.findIndex((w) => w === '-t' || w === '--target-directory');
|
|
675
|
+
if (t >= 0 && args[t + 1]) return [args[t + 1]];
|
|
676
|
+
const eq = args.find((w) => w.startsWith('--target-directory='));
|
|
677
|
+
if (eq) return [eq.slice('--target-directory='.length)];
|
|
678
|
+
const paths = args.filter((w) => !isOption(w));
|
|
679
|
+
return paths.length >= 2 ? [paths[paths.length - 1]] : [];
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** curl's request shape under read-only: a GET or HEAD of a single address, nothing sent, nothing written. */
|
|
683
|
+
function curlShape(args) {
|
|
684
|
+
for (let i = 0; i < args.length; i++) {
|
|
685
|
+
const w = args[i];
|
|
686
|
+
if (w === '-X' || w === '--request') { if (!/^(GET|HEAD)$/i.test(String(args[i + 1] || ''))) return false; i++; continue; }
|
|
687
|
+
if (/^--request=/.test(w) && !/^--request=(GET|HEAD)$/i.test(w)) return false;
|
|
688
|
+
if (/^-[a-zA-Z]*X$/.test(w)) { if (!/^(GET|HEAD)$/i.test(String(args[i + 1] || ''))) return false; i++; continue; }
|
|
689
|
+
if (/^(-d|--data|--data-raw|--data-binary|--data-urlencode|--data-ascii|-F|--form|--form-string|--json|-T|--upload-file|-u|--user|--oauth2-bearer|--proxy-user|--netrc|-n|--netrc-file|--netrc-optional)(=|$)/.test(w)) return false;
|
|
690
|
+
if (/^-[a-zA-Z]*[dFTun][a-zA-Z]*$/.test(w) && !/^-[sSLkvIi]+$/.test(w)) return false;
|
|
691
|
+
}
|
|
692
|
+
return true;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Programs that name a host and a port as two words (`nc 127.0.0.1 4123`, `telnet localhost 4123`).
|
|
696
|
+
const HOST_PORT_CLIENTS = new Set(['nc', 'ncat', 'netcat', 'telnet']);
|
|
697
|
+
|
|
698
|
+
/** The network addresses a client command names (URLs, host:port words, and `host port` for nc / telnet): [{ host, port }]. */
|
|
699
|
+
function netTargets(args, program = '') {
|
|
700
|
+
const out = [];
|
|
701
|
+
for (const w of args) {
|
|
702
|
+
if (isOption(w)) continue;
|
|
703
|
+
const url = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i.exec(w);
|
|
704
|
+
const authority = url ? url[1] : (/^[A-Za-z0-9.\[\]:_-]+$/.test(w) && /:\d{2,5}$/.test(w) ? w : null);
|
|
705
|
+
if (!authority) continue;
|
|
706
|
+
const hostPort = authority.replace(/^[^@]*@/, '');
|
|
707
|
+
const m = /^\[([^\]]+)\](?::(\d+))?$|^([^:]+)(?::(\d+))?$/.exec(hostPort);
|
|
708
|
+
if (!m) continue;
|
|
709
|
+
const host = (m[1] || m[3] || '').toLowerCase();
|
|
710
|
+
const port = Number(m[2] || m[4] || (url && /^https:/i.test(w) ? 443 : url ? 80 : 0)) || null;
|
|
711
|
+
out.push({ host, port });
|
|
712
|
+
}
|
|
713
|
+
if (HOST_PORT_CLIENTS.has(program)) {
|
|
714
|
+
const words = args.filter((w) => !isOption(w));
|
|
715
|
+
for (let i = 0; i + 1 < words.length; i++) {
|
|
716
|
+
if (/^\d{1,5}$/.test(words[i + 1]) && isLoopbackHost(words[i].toLowerCase().replace(/^\[|\]$/g, ''))) out.push({ host: words[i].toLowerCase(), port: Number(words[i + 1]) });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return out;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* A host name or address of this computer's loopback: localhost (and *.localhost, a trailing dot), 127.0.0.0/8 in
|
|
723
|
+
* any spelling a client accepts (dotted, 127.1, one decimal, hex or octal number), 0.0.0.0, ::1 / :: and the
|
|
724
|
+
* IPv4-mapped forms (::ffff:127.0.0.1, ::ffff:7f00:1).
|
|
725
|
+
*/
|
|
726
|
+
export function isLoopbackHost(raw) {
|
|
727
|
+
const host = String(raw || '').toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
728
|
+
if (!host) return false;
|
|
729
|
+
if (host === 'localhost' || host.endsWith('.localhost') || host === '0.0.0.0' || host === '0') return true;
|
|
730
|
+
if (/^127\./.test(host) || host === '127') return true;
|
|
731
|
+
if (/^(?:0:){0,7}:?:?0*1$/.test(host) && host.includes(':')) return true; // ::1, 0:0:0:0:0:0:0:1
|
|
732
|
+
if (/^(?:0*:)*:?$/.test(host) && host.includes(':')) return true; // ::
|
|
733
|
+
const mapped = /^(?:0*:){0,5}:?ffff:(.+)$/.exec(host);
|
|
734
|
+
if (mapped) return /^127\./.test(mapped[1]) || /^7f[0-9a-f]{2}:[0-9a-f]{1,4}$/.test(mapped[1]);
|
|
735
|
+
// One number for the whole address (2130706433, 0x7f000001, 017700000001): its top byte is 127.
|
|
736
|
+
let n = null;
|
|
737
|
+
if (/^0x[0-9a-f]+$/.test(host)) n = parseInt(host.slice(2), 16);
|
|
738
|
+
else if (/^0[0-7]+$/.test(host)) n = parseInt(host, 8);
|
|
739
|
+
else if (/^\d+$/.test(host)) n = Number(host);
|
|
740
|
+
if (n !== null && Number.isFinite(n) && n <= 0xffffffff) return (n >>> 24) === 127 || n === 0;
|
|
741
|
+
// The first part as a number (0x7f.1, 0177.0.0.1).
|
|
742
|
+
const first = host.split('.')[0];
|
|
743
|
+
if (host.includes('.') && /^(?:0x7f|0177)$/.test(first)) return true;
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// A loopback address with a port, as inline code writes it.
|
|
748
|
+
const INLINE_LOOPBACK = /(?:^|[^\w.])(localhost|127(?:\.\d{1,3}){1,3}|0\.0\.0\.0|\[[0-9a-f:.]+\]|::1|0x7f[0-9a-f]{6}|2130706433):(\d{1,5})(?!\d)/gi;
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Which of SynaBun's own local services an interpreter's inline code (node -e, python -c, …) names, a loopback host
|
|
752
|
+
* and a control port together in any word: { kind, host, port } (controlPortOf), else null. A cheap explanatory layer,
|
|
753
|
+
* not a boundary — inline code is opaque, so a worker can hide the address in a variable or build the string in
|
|
754
|
+
* pieces; the real boundary for this is the OS isolation of the worker's process tree. It is here to turn the
|
|
755
|
+
* published proof-of-concept (`node -e "fetch('http://127.0.0.1:<port>/api/page-session')"`) into a plain refusal
|
|
756
|
+
* instead of a silent pass.
|
|
757
|
+
*/
|
|
758
|
+
function inlineCodeControlPort(args, confinement) {
|
|
759
|
+
const text = args.join(' ');
|
|
760
|
+
for (const m of text.matchAll(INLINE_LOOPBACK)) {
|
|
761
|
+
if (!isLoopbackHost(m[1])) continue;
|
|
762
|
+
const kind = controlPortOf(m[2], confinement);
|
|
763
|
+
if (kind) return { kind, host: m[1], port: Number(m[2]) };
|
|
764
|
+
}
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Paths a writing command writes, by its kind; `null` means "writes somewhere SynaBun cannot name" (refused). */
|
|
769
|
+
function writtenPaths(program, kind, args) {
|
|
770
|
+
const paths = args.filter((w) => !isOption(w));
|
|
771
|
+
switch (kind) {
|
|
772
|
+
case 'all': return paths;
|
|
773
|
+
case 'dest': return destinationOf(args);
|
|
774
|
+
case 'dd': return args.filter((w) => w.startsWith('of=')).map((w) => w.slice(3));
|
|
775
|
+
case 'patch': { const d = args.findIndex((w) => w === '-d' || w === '--directory'); return d >= 0 && args[d + 1] ? [args[d + 1]] : ['.']; }
|
|
776
|
+
case 'tar': {
|
|
777
|
+
const out = [];
|
|
778
|
+
const creating = args.some((w) => /^-?[A-Za-z]*c[A-Za-z]*$/.test(w) && !w.startsWith('--')) || args.includes('--create');
|
|
779
|
+
const extracting = args.some((w) => /^-?[A-Za-z]*x[A-Za-z]*$/.test(w) && !w.startsWith('--')) || args.includes('--extract') || args.includes('--get');
|
|
780
|
+
const c = args.findIndex((w) => w === '-C' || w === '--directory');
|
|
781
|
+
if (c >= 0 && args[c + 1]) out.push(args[c + 1]); else if (extracting) out.push('.');
|
|
782
|
+
if (creating) { const f = args.findIndex((w) => w === '-f' || w === '--file' || /^-[A-Za-z]*f$/.test(w)); if (f >= 0 && args[f + 1] && args[f + 1] !== '-') out.push(args[f + 1]); }
|
|
783
|
+
return out;
|
|
784
|
+
}
|
|
785
|
+
case 'unzip': { const d = args.findIndex((w) => w === '-d'); return d >= 0 && args[d + 1] ? [args[d + 1]] : ['.']; }
|
|
786
|
+
case 'zip': return paths.length ? [paths[0]] : ['.'];
|
|
787
|
+
case 'sed': {
|
|
788
|
+
if (args.some((w) => blockedFlag(w, ['-i', '-I', '--in-place']))) return paths.filter((w) => !/^(-e|-f)$/.test(w));
|
|
789
|
+
// a `w file` / `W file` command or an `s///w file` flag writes the file it names; it cannot be read apart, so it is refused
|
|
790
|
+
return paths.some((w) => /(^|[;\n{}\s])\s*[wW](\s|$)|\/[gpImMx0-9]*[wW][gpImMx0-9]*(\s|;|$)/.test(w)) ? null : [];
|
|
791
|
+
}
|
|
792
|
+
case 'awk': return args.some((w) => />/.test(w) && !isOption(w)) ? null : [];
|
|
793
|
+
case 'uniq': return paths.length >= 2 ? [paths[paths.length - 1]] : [];
|
|
794
|
+
case 'sort': { const o = args.findIndex((w) => w === '-o' || w === '--output'); if (o >= 0 && args[o + 1]) return [args[o + 1]]; const eq = args.find((w) => w.startsWith('--output=')); return eq ? [eq.slice('--output='.length)] : []; }
|
|
795
|
+
case 'perl': return args.some((w) => /^-[a-zA-Z]*i/.test(w)) ? paths : [];
|
|
796
|
+
case 'wget': {
|
|
797
|
+
const o = args.findIndex((w) => w === '-O' || w === '--output-document');
|
|
798
|
+
if (o >= 0 && args[o + 1]) return args[o + 1] === '-' ? [] : [args[o + 1]];
|
|
799
|
+
if (args.some((w) => /^-[a-zA-Z]*O-$/.test(w) || w === '--output-document=-')) return [];
|
|
800
|
+
const p = args.findIndex((w) => w === '-P' || w === '--directory-prefix');
|
|
801
|
+
if (p >= 0 && args[p + 1]) return [args[p + 1]];
|
|
802
|
+
return ['.'];
|
|
803
|
+
}
|
|
804
|
+
case 'curl': {
|
|
805
|
+
const out = [];
|
|
806
|
+
for (let i = 0; i < args.length; i++) {
|
|
807
|
+
const w = args[i];
|
|
808
|
+
if (['-o', '--output', '-D', '--dump-header', '-c', '--cookie-jar', '--trace', '--trace-ascii', '--libcurl', '--etag-save', '--hsts', '--alt-svc', '--stderr', '--output-dir'].includes(w)) { if (args[i + 1]) out.push(args[i + 1]); i++; }
|
|
809
|
+
else if (/^--(output|output-dir|dump-header|cookie-jar|trace|trace-ascii|libcurl|etag-save|hsts|alt-svc|stderr)=/.test(w)) out.push(w.split('=').slice(1).join('='));
|
|
810
|
+
else if (/^-[a-zA-Z]*o$/.test(w) && args[i + 1]) { out.push(args[i + 1]); i++; }
|
|
811
|
+
else if (w === '-O' || w === '--remote-name' || /^-[a-zA-Z]*O$/.test(w) || w === '-J' || w === '--remote-header-name') out.push('.');
|
|
812
|
+
}
|
|
813
|
+
return out;
|
|
814
|
+
}
|
|
815
|
+
case 'git': {
|
|
816
|
+
let rest = args;
|
|
817
|
+
let dir = null;
|
|
818
|
+
while (rest.length && isOption(rest[0])) {
|
|
819
|
+
if (rest[0] === '-C' && rest[1]) { dir = rest[1]; rest = rest.slice(2); continue; }
|
|
820
|
+
if (rest[0] === '-c' && rest[1]) { rest = rest.slice(2); continue; }
|
|
821
|
+
if (rest[0].startsWith('--git-dir') || rest[0].startsWith('--work-tree')) { const eq = rest[0].indexOf('='); if (eq > 0) dir = rest[0].slice(eq + 1); else if (rest[1]) { dir = rest[1]; rest = rest.slice(2); continue; } }
|
|
822
|
+
rest = rest.slice(1);
|
|
823
|
+
}
|
|
824
|
+
const [sub, ...subArgs] = rest;
|
|
825
|
+
if (!sub) return [];
|
|
826
|
+
if (GIT_READ.has(sub)) return subArgs.some((w) => blockedFlag(w, GIT_BLOCKED)) ? null : [];
|
|
827
|
+
const shaped = GIT_READ_WITH_ARGS.get(sub);
|
|
828
|
+
if (shaped && shaped(subArgs)) return [];
|
|
829
|
+
if (sub === 'config' && subArgs.some((w) => GIT_GLOBAL_WRITE_OPTIONS.includes(w))) return null;
|
|
830
|
+
if (sub === 'clone') { const p = subArgs.filter((w) => !isOption(w)); return [p.length >= 2 ? p[p.length - 1] : dir || '.']; }
|
|
831
|
+
if (sub === 'worktree' && subArgs[0] === 'add') { const p = subArgs.slice(1).filter((w) => !isOption(w)); return [p[0] || '.']; }
|
|
832
|
+
if (sub === 'init') { const p = subArgs.filter((w) => !isOption(w)); return [p[0] || dir || '.']; }
|
|
833
|
+
if (sub === 'archive') { const o = subArgs.findIndex((w) => w === '-o' || w === '--output'); if (o >= 0 && subArgs[o + 1]) return [subArgs[o + 1]]; const eq = subArgs.find((w) => w.startsWith('--output=')); return eq ? [eq.slice('--output='.length)] : []; }
|
|
834
|
+
return [dir || '.'];
|
|
835
|
+
}
|
|
836
|
+
case 'pkg': {
|
|
837
|
+
const sub = args.find((w) => !isOption(w)) || '';
|
|
838
|
+
if (args.some((w) => w === '-g' || w === '--global' || w === 'global')) return null;
|
|
839
|
+
if (sub === 'config' && args.includes('set')) return null;
|
|
840
|
+
const prefix = args.findIndex((w) => w === '--prefix' || w === '-C' || w === '--dir');
|
|
841
|
+
if (prefix >= 0 && args[prefix + 1]) return [args[prefix + 1]];
|
|
842
|
+
const eq = args.find((w) => w.startsWith('--prefix=') || w.startsWith('--dir='));
|
|
843
|
+
if (eq) return [eq.split('=').slice(1).join('=')];
|
|
844
|
+
return ['.'];
|
|
845
|
+
}
|
|
846
|
+
case 'pip': {
|
|
847
|
+
if (args.includes('--user')) return null;
|
|
848
|
+
const t = args.findIndex((w) => w === '-t' || w === '--target' || w === '--prefix' || w === '--root');
|
|
849
|
+
if (t >= 0 && args[t + 1]) return [args[t + 1]];
|
|
850
|
+
return ['.']; // an install into the active environment: not seen (a venv inside the workspace is the usual case)
|
|
851
|
+
}
|
|
852
|
+
case 'find': {
|
|
853
|
+
if (!args.some((w) => FIND_WRITES.includes(w) || FIND_EXEC.includes(w))) return [];
|
|
854
|
+
const roots = [];
|
|
855
|
+
for (const w of args) { if (w.startsWith('-') || w === '(' || w === '!') break; roots.push(w); }
|
|
856
|
+
// -exec payloads are checked as commands of their own (checkWords); -delete and -fprint* write the roots
|
|
857
|
+
return args.some((w) => FIND_WRITES.includes(w)) ? (roots.length ? roots : ['.']) : [];
|
|
858
|
+
}
|
|
859
|
+
case 'fd': return [];
|
|
860
|
+
case 'brew': return args.some((w) => /^(install|uninstall|upgrade|link|unlink|tap|untap|services|cleanup|pin|unpin|reinstall)$/.test(w)) ? null : [];
|
|
861
|
+
case 'gem': return args[0] === 'install' && !args.includes('--user-install') ? null : [];
|
|
862
|
+
case 'cargo': return args[0] === 'install' ? null : ['.'];
|
|
863
|
+
case 'defaults': return args.some((w) => /^(write|delete|import|rename)$/.test(w)) ? null : [];
|
|
864
|
+
case 'crontab': return args.some((w) => w === '-e' || w === '-r' || w === '-' || !isOption(w)) ? null : [];
|
|
865
|
+
case 'launchctl': return args.some((w) => /^(load|unload|bootstrap|bootout|enable|disable|submit|setenv|unsetenv)$/.test(w)) ? null : [];
|
|
866
|
+
default: return [];
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** The payloads of find -exec / -execdir / -ok / -okdir (up to `;` or `+`) and fd -x / -X: each a command of its own. */
|
|
871
|
+
function execPayloads(program, args) {
|
|
872
|
+
const out = [];
|
|
873
|
+
if (program === 'find') {
|
|
874
|
+
for (let i = 0; i < args.length; i++) {
|
|
875
|
+
if (!FIND_EXEC.includes(args[i])) continue;
|
|
876
|
+
const words = [];
|
|
877
|
+
let j = i + 1;
|
|
878
|
+
for (; j < args.length && args[j] !== ';' && args[j] !== '+'; j++) words.push(args[j]);
|
|
879
|
+
out.push(words); // `{}` stays: a file find found, under the roots (checked as a relative path)
|
|
880
|
+
i = j;
|
|
881
|
+
}
|
|
882
|
+
} else if (program === 'fd' || program === 'fdfind') {
|
|
883
|
+
const at = args.findIndex((w) => FD_EXEC.test(w));
|
|
884
|
+
if (at >= 0) out.push(args.slice(at + 1).filter((w) => w !== ';' && w !== '+'));
|
|
885
|
+
}
|
|
886
|
+
return out.filter((w) => w.length);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const READ_ONLY_INTRO = 'This run has capability read-only: the shell runs read-only commands only (rg, grep, ls, cat, head, tail, find, stat, diff, jq, git log / diff / status / show / blame, node --check, the project\'s own tests (node --test, npm test, vitest), npm ls, curl without an output file…).';
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Whether a worker may run this command line: null when it may, else a refusal { rule, reason, target? }.
|
|
893
|
+
* read-only: every simple command must be a known read-only program, and nothing may be redirected to a file.
|
|
894
|
+
* workspace: nothing privileged, no redirection to a file outside the writable roots, no writing program whose
|
|
895
|
+
* written path is outside them (or that writes somewhere SynaBun cannot name). Both: no shell or interpreter fed
|
|
896
|
+
* from a pipe or a file, no call to SynaBun's own port (read-only: no call to loopback at all).
|
|
897
|
+
*/
|
|
898
|
+
export function workerShellDecision(command, confinement) {
|
|
899
|
+
if (!confinement) return null;
|
|
900
|
+
try {
|
|
901
|
+
const text = String(command ?? '');
|
|
902
|
+
if (!text.trim()) return null;
|
|
903
|
+
const segments = shellSegments(text, { cwd: confinement.cwd, home: confinement.home });
|
|
904
|
+
if (!segments) return refusal('unreadable', 'SynaBun could not read this command line (an unbalanced quote or a control character), so it did not run. Write it more plainly.');
|
|
905
|
+
const readOnly = confinement.capability === 'read-only';
|
|
906
|
+
for (const seg of segments) {
|
|
907
|
+
const found = checkSegment(seg, confinement, readOnly);
|
|
908
|
+
if (found) return found;
|
|
909
|
+
}
|
|
910
|
+
return null;
|
|
911
|
+
} catch (err) {
|
|
912
|
+
return refusal('check-failed', `The command could not be checked (${err?.message || err}), so it is refused.`);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** bash's and zsh's /dev/tcp/<host>/<port> (a connection the shell opens itself) to one of SynaBun's control ports. */
|
|
917
|
+
function devTcpControlPort(text, confinement) {
|
|
918
|
+
const m = /^\/dev\/(?:tcp|udp)\/([^/]+)\/(\d{1,5})$/i.exec(String(text || ''));
|
|
919
|
+
if (!m || !isLoopbackHost(m[1])) return null;
|
|
920
|
+
const kind = controlPortOf(m[2], confinement);
|
|
921
|
+
return kind ? { kind, host: m[1], port: Number(m[2]) } : null;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function checkSegment(seg, confinement, readOnly) {
|
|
925
|
+
const cwd = seg.cwd;
|
|
926
|
+
// A connection the shell itself opens (exec 3<>/dev/tcp/127.0.0.1/<port>), in either direction.
|
|
927
|
+
for (const t of [...seg.redirects.map((r) => r.target), ...seg.words]) {
|
|
928
|
+
const hit = devTcpControlPort(t, confinement);
|
|
929
|
+
if (hit) return refusal('loopback', `${controlPortText(hit.kind, hit.host, hit.port, confinement)}: a dispatched worker does not call it with the shell (its MCP tools are the way in).`);
|
|
930
|
+
}
|
|
931
|
+
// Redirections first: a file written, wherever the program is.
|
|
932
|
+
for (const r of seg.redirects) {
|
|
933
|
+
if (!r.op.includes('>')) continue;
|
|
934
|
+
const target = r.target;
|
|
935
|
+
if (DEVICE_PATHS.has(target)) continue;
|
|
936
|
+
if (readOnly) return refusal('read-only', `${READ_ONLY_INTRO} Writing output to a file (${r.op} ${target || ''}) is not available; a run that must write needs capability workspace.`);
|
|
937
|
+
if (cwd === null) return refusal('unknown-path', `After a cd to a folder SynaBun could not resolve, ${r.op} ${target} cannot be checked, so it did not run.`);
|
|
938
|
+
const place = placeOf(target, { cwd, home: confinement.home, vars: seg.vars });
|
|
939
|
+
if (!place) return refusal('unknown-path', `The redirection target "${target.slice(0, 200)}" uses a value SynaBun could not resolve (an unknown variable, a command substitution other than $(mktemp …), or an odd character), so the command did not run. ${passHint(confinement)}`);
|
|
940
|
+
if (!mayWrite(place, confinement)) return refusal('write-outside', `${r.op} ${target} would write ${place}, outside this run's workspace. This run writes inside ${rootsText(confinement)} only.`, place);
|
|
941
|
+
}
|
|
942
|
+
return checkWords(seg.words, { cwd, piped: seg.piped, stdin: seg.stdin, vars: seg.vars }, confinement, readOnly);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** One command's words (wrappers still on), in a context { cwd, piped, stdin, vars }. */
|
|
946
|
+
function checkWords(rawWords, ctx, confinement, readOnly, depth = 0) {
|
|
947
|
+
if (depth > 6) return refusal('unreadable', 'The command nests too deep to check, so it did not run.');
|
|
948
|
+
const unwrapped = unwrap(rawWords, { cwd: ctx.cwd, home: confinement.home, vars: ctx.vars });
|
|
949
|
+
const words = unwrapped.words;
|
|
950
|
+
if (!words.length) return null;
|
|
951
|
+
const cwd = unwrapped.cwd !== undefined ? unwrapped.cwd : ctx.cwd;
|
|
952
|
+
const unknownCwd = cwd === null;
|
|
953
|
+
const program = words[0].replace(/^.*\//, '');
|
|
954
|
+
if (PRIVILEGE.has(program)) return refusal('privilege', `${program} is not available to a dispatched worker (capability ${confinement.capability}): nothing runs with higher privileges than the run itself.`);
|
|
955
|
+
const args = words.slice(1);
|
|
956
|
+
// A shell or interpreter with no program of its own reads it from a pipe or a file: it cannot be checked.
|
|
957
|
+
if (SHELLS.has(program) || INTERPRETERS.has(program) || program === 'eval') {
|
|
958
|
+
const inner = innerCommand([program, ...args]);
|
|
959
|
+
if (inner !== null) {
|
|
960
|
+
const nested = shellSegments(inner, { cwd, home: confinement.home, seedVars: ctx.vars });
|
|
961
|
+
if (!nested) return refusal('unreadable', `SynaBun could not read the command passed to ${program} -c, so it did not run. Write it more plainly.`);
|
|
962
|
+
for (const sub of nested) { const found = checkSegment({ ...sub, piped: sub.piped, stdin: sub.stdin }, confinement, readOnly); if (found) return found; }
|
|
963
|
+
return null;
|
|
964
|
+
}
|
|
965
|
+
const scriptless = !args.some((w) => !isOption(w)) || args.includes('-') || args.includes('/dev/stdin');
|
|
966
|
+
const hasCode = args.some((w) => /^(-e|--eval|-p|--print|-c|-m|-r|-E|-S)$/.test(w));
|
|
967
|
+
if (program !== 'eval' && scriptless && !hasCode && (ctx.piped || ctx.stdin || SHELLS.has(program) || args.includes('-') || args.includes('/dev/stdin'))) {
|
|
968
|
+
return refusal('unreadable', `${program} would read its program from ${ctx.piped ? 'a pipe' : ctx.stdin ? 'a file' : 'standard input'}, which SynaBun cannot check, so it did not run. Write the command out, or put the script in a file inside your working directory and run that.`);
|
|
969
|
+
}
|
|
970
|
+
// A guarded worker's shell may not call SynaBun's own server or its OpenCode serves (its MCP tools are the way
|
|
971
|
+
// in). The net-client check below reads the command line, but an interpreter's inline code is opaque, so under
|
|
972
|
+
// workspace a fetch to one of them would pass unseen. This refuses the obvious form with a plain reason; it is a
|
|
973
|
+
// speed-bump, not the boundary (see inlineCodeControlPort). Under read-only an inline interpreter never runs at
|
|
974
|
+
// all (it is not a read-only program), so this only matters for workspace.
|
|
975
|
+
const inline = INTERPRETERS.has(program) && hasCode && !readOnly ? inlineCodeControlPort(args, confinement) : null;
|
|
976
|
+
if (inline) {
|
|
977
|
+
if (inline.kind === 'server') return refusal('loopback-inline', `${program} with inline code (-e / -c) that names SynaBun's own server (localhost:${confinement.serverPort}) did not run: a dispatched worker does not call SynaBun's own port — its MCP tools are the way in, and whatever they cannot do is the run's to report. Put a script that does not call SynaBun's own port in a file inside your working directory and run that.`);
|
|
978
|
+
return refusal('loopback-inline', `${program} with inline code (-e / -c) did not run: ${controlPortText(inline.kind, inline.host, inline.port, confinement)}, which a dispatched worker does not call — its tools are the way in, and whatever they cannot do is the run's to report.`);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
// A network client aimed at loopback: never SynaBun's own port or its OpenCode serves; under read-only not loopback
|
|
982
|
+
// at all.
|
|
983
|
+
if (NET_CLIENTS.has(program)) {
|
|
984
|
+
const targets = netTargets(args, program);
|
|
985
|
+
for (const t of targets) {
|
|
986
|
+
if (!isLoopbackHost(t.host)) continue;
|
|
987
|
+
if (readOnly) return refusal('loopback', `${READ_ONLY_INTRO} A request to ${t.host}${t.port ? `:${t.port}` : ''} (this computer's own services) is not available to a read-only run.`);
|
|
988
|
+
const kind = controlPortOf(t.port, confinement);
|
|
989
|
+
if (kind === 'server') return refusal('loopback', `${program} to ${t.host}:${t.port} is SynaBun's own server: a dispatched worker does not call it with the shell (its MCP tools are the way in).`);
|
|
990
|
+
if (kind) return refusal('loopback', `${program} to ${controlPortText(kind, t.host, t.port, confinement)}: a dispatched worker does not call it with the shell (its tools are the way in).`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
// find -exec / fd -x payloads are commands of their own; a writing payload writes what find found, under the roots.
|
|
994
|
+
if ((program === 'find' || program === 'fd' || program === 'fdfind') && !readOnly) {
|
|
995
|
+
for (const payload of execPayloads(program, args)) {
|
|
996
|
+
const found = checkWords(payload, { cwd, piped: false, stdin: false, vars: ctx.vars }, confinement, readOnly, depth + 1);
|
|
997
|
+
if (found) return found;
|
|
998
|
+
const inner = unwrap(payload, { cwd, home: confinement.home, vars: ctx.vars }).words;
|
|
999
|
+
const innerProgram = String(inner[0] || '').replace(/^.*\//, '');
|
|
1000
|
+
if (WRITERS.has(innerProgram) || SHELLS.has(innerProgram) || INTERPRETERS.has(innerProgram)) {
|
|
1001
|
+
const roots = [];
|
|
1002
|
+
if (program === 'find') { for (const w of args) { if (w.startsWith('-') || w === '(' || w === '!') break; roots.push(w); } }
|
|
1003
|
+
else { const at = args.findIndex((w) => FD_EXEC.test(w)); const before = args.slice(0, at).filter((w) => !isOption(w)); if (before.length >= 2) roots.push(before[1]); }
|
|
1004
|
+
for (const root of roots.length ? roots : ['.']) {
|
|
1005
|
+
if (unknownCwd) return refusal('unknown-path', `After a cd to a folder SynaBun could not resolve, ${program} … -exec ${innerProgram} cannot be checked, so it did not run.`);
|
|
1006
|
+
const place = placeOf(root, { cwd, home: confinement.home, vars: ctx.vars });
|
|
1007
|
+
if (!place) return refusal('unknown-path', `${program} names a folder SynaBun could not read ("${String(root).slice(0, 200)}"), so its -exec ${innerProgram} did not run.`);
|
|
1008
|
+
if (!mayWrite(place, confinement)) return refusal('write-outside', `${program} … -exec ${innerProgram} would act on files under ${place}, outside this run's workspace. This run writes inside ${rootsText(confinement)} only.`, place);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
if (readOnly) {
|
|
1014
|
+
if (program === 'cd' || program === 'pushd' || program === 'popd' || program === 'export' || program === 'unset' || program === 'set' || program === 'alias' || program === 'wait' || program === 'exit' || program === 'return' || program === 'for' || program === 'case' || program === 'select' || program === 'function' || program === 'local' || program === 'read') return null;
|
|
1015
|
+
if (program === 'git') {
|
|
1016
|
+
let rest = args;
|
|
1017
|
+
while (rest.length && isOption(rest[0])) {
|
|
1018
|
+
if (rest[0] === '-C' && rest[1]) { rest = rest.slice(2); continue; }
|
|
1019
|
+
if (rest[0] === '--no-pager' || rest[0] === '-P') { rest = rest.slice(1); continue; }
|
|
1020
|
+
return refusal('read-only', `${READ_ONLY_INTRO} git with the global option ${rest[0]} may run a program or write, so it did not run.`);
|
|
1021
|
+
}
|
|
1022
|
+
const [sub, ...subArgs] = rest;
|
|
1023
|
+
if (sub && (GIT_READ.has(sub) || GIT_READ_WITH_ARGS.get(sub)?.(subArgs)) && !subArgs.some((w) => blockedFlag(w, GIT_BLOCKED))) return null;
|
|
1024
|
+
return refusal('read-only', `${READ_ONLY_INTRO} git ${sub || ''} may change the repository, so it did not run.`);
|
|
1025
|
+
}
|
|
1026
|
+
const blocked = READ_PROGRAMS.get(program);
|
|
1027
|
+
if (blocked === undefined) {
|
|
1028
|
+
const runner = /^(pytest|jest|mocha|make|cargo|go|deno|bun|tsc|eslint|prettier|python2|perl|ruby|php|sh|bash|zsh|dash|ksh|fish)$/.test(program);
|
|
1029
|
+
return refusal('read-only', `${READ_ONLY_INTRO} ${program} is not in that list${runner ? ' (a program or a test runner may write files and build output)' : ''}, so it did not run; a run that must execute programs needs capability workspace.`);
|
|
1030
|
+
}
|
|
1031
|
+
const bad = args.find((w) => blockedFlag(w, blocked));
|
|
1032
|
+
if (bad) return refusal('read-only', `${READ_ONLY_INTRO} ${program} with an option that writes or runs something (${bad}) did not run.`);
|
|
1033
|
+
const shape = READ_SHAPES.get(program);
|
|
1034
|
+
const inside = (w) => { const place = placeOf(w, { cwd, home: confinement.home, vars: ctx.vars }); return !!place && inside_(confinement.cwd, place); };
|
|
1035
|
+
if (shape && !shape(args, { cwd, inside })) return refusal('read-only', `${READ_ONLY_INTRO} ${program} ${args[0] || ''} may write or run a program, so it did not run; a run that must execute programs needs capability workspace.`);
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
// workspace
|
|
1039
|
+
const kind = WRITERS.get(program);
|
|
1040
|
+
if (!kind) return null;
|
|
1041
|
+
const targets = writtenPaths(program, kind, args);
|
|
1042
|
+
if (targets === null) return refusal('write-outside', `${program} ${args.slice(0, 3).join(' ')} writes outside this run's workspace, or to a file SynaBun cannot name (a global install, a user-wide setting, a system folder, a sed w / awk > inside a program). This run (capability workspace) writes inside ${rootsText(confinement)} only.`);
|
|
1043
|
+
for (const target of targets) {
|
|
1044
|
+
if (unknownCwd) return refusal('unknown-path', `After a cd to a folder SynaBun could not resolve, ${program} ${target} cannot be checked, so it did not run.`);
|
|
1045
|
+
const place = placeOf(target, { cwd, home: confinement.home, vars: ctx.vars });
|
|
1046
|
+
if (!place) return refusal('unknown-path', `${program} names a path SynaBun could not resolve ("${String(target).slice(0, 200)}": an unknown variable, a command substitution other than $(mktemp …), or an odd character), so it did not run. ${passHint(confinement)}`);
|
|
1047
|
+
if (!mayWrite(place, confinement)) return refusal('write-outside', `${program} would write ${place}, outside this run's workspace. This run (capability workspace) writes inside ${rootsText(confinement)} only; nothing is written under the home folder, another project or a tool's configuration.`, place);
|
|
1048
|
+
}
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
const inside_ = (root, target) => inside(root, target, true);
|
|
1052
|
+
|
|
1053
|
+
// ── Provider mappings ───────────────────────────────────────────────────────
|
|
1054
|
+
|
|
1055
|
+
/** Codex: the sandbox mode and the extra writable folders a guarded run gets. */
|
|
1056
|
+
export function codexSandboxFor(confinement, { parentDir = null, scratchDir = null } = {}) {
|
|
1057
|
+
if (!confinement) return null;
|
|
1058
|
+
if (confinement.capability === 'read-only') return { sandboxMode: 'read-only', additionalDirectories: undefined };
|
|
1059
|
+
const extra = [];
|
|
1060
|
+
if (!confinement.enforced && parentDir) extra.push(parentDir);
|
|
1061
|
+
if (scratchDir) extra.push(scratchDir);
|
|
1062
|
+
return { sandboxMode: 'workspace-write', additionalDirectories: extra.length ? extra : undefined };
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* OpenCode 2's Code Mode tool. Inside it a model scripts the tools of the Code Mode catalog, among them
|
|
1067
|
+
* `tools.opencode.session_move` (which moves the session to another folder: the read tool then reads there, and
|
|
1068
|
+
* nothing asks), `worktree_create` / `worktree_remove`, and every MCP server whose `codemode` is not false (OpenCode
|
|
1069
|
+
* 2.1.4's default: such a server's tools are reachable through `execute` only). Code Mode itself asks nothing, so the
|
|
1070
|
+
* guard cannot check what runs inside it: an enforced worker has it off (a `deny` rule takes the tool, and the whole
|
|
1071
|
+
* catalog with it, off the model's list) and its MCP servers set `codemode: false`, which keeps their tools direct
|
|
1072
|
+
* calls (server.js setupOpencodeLoopConfig, lib/native-loop-providers.js openCodeRunConfigFor).
|
|
1073
|
+
*/
|
|
1074
|
+
export const OPENCODE_CODE_MODE_TOOL = 'execute';
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* OpenCode: the permission rules (OpenCode 2's `{ action, resource, effect }`) a guarded run's config and its
|
|
1078
|
+
* session get. Every ask reaches the adapter, which answers it with openCodePermissionDecision(); nothing is a
|
|
1079
|
+
* blanket "always". Under Enforce, Code Mode (`execute`) is denied; Warn keeps it and notes each call
|
|
1080
|
+
* (openCodeCodeModeDecision).
|
|
1081
|
+
*/
|
|
1082
|
+
export function openCodePermissionsFor(confinement) {
|
|
1083
|
+
if (!confinement) return null;
|
|
1084
|
+
const rule = (action, effect) => ({ action, resource: '*', effect });
|
|
1085
|
+
return [
|
|
1086
|
+
rule('edit', confinement.capability === 'read-only' ? 'deny' : 'ask'),
|
|
1087
|
+
rule('shell', 'ask'),
|
|
1088
|
+
rule('webfetch', 'allow'),
|
|
1089
|
+
rule('external_directory', 'ask'),
|
|
1090
|
+
...(confinement.enforced ? [rule(OPENCODE_CODE_MODE_TOOL, 'deny')] : []),
|
|
1091
|
+
];
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* A Code Mode call (an OpenCode tool part named `execute`) under a confinement: null for any other tool or an
|
|
1096
|
+
* unguarded run, else what the run records. Under Enforce the call never ran (the tool is not offered, so OpenCode
|
|
1097
|
+
* answers it with its own "No tool named" error): the reason says plainly that Code Mode is off. Under Warn it ran:
|
|
1098
|
+
* the reason says the guard could not check inside it.
|
|
1099
|
+
*/
|
|
1100
|
+
export function openCodeCodeModeDecision(toolName, confinement) {
|
|
1101
|
+
if (!confinement || String(toolName || '').toLowerCase() !== OPENCODE_CODE_MODE_TOOL) return null;
|
|
1102
|
+
if (confinement.enforced) {
|
|
1103
|
+
return refusal('code-mode', 'Code Mode is off for protected workers: OpenCode\'s execute tool is not available in this run (inside it, session_move could move the session out of its working directory and nothing would ask). Call each tool directly instead: read, glob, grep, edit, write, the shell, and SynaBun\'s MCP tools.');
|
|
1104
|
+
}
|
|
1105
|
+
return refusal('code-mode', 'Code Mode (OpenCode\'s execute tool) ran: Protect workers cannot check the calls inside it (session_move can move the session out of its working directory). Under Enforce, Code Mode is off for protected workers.');
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** The line a guarded OpenCode run's notes start with about Code Mode, or null (no guard). */
|
|
1109
|
+
export function openCodeCodeModeNote(confinement) {
|
|
1110
|
+
if (!confinement) return null;
|
|
1111
|
+
return confinement.enforced
|
|
1112
|
+
? `worker confinement (enforce, capability ${confinement.capability}): Code Mode is off for protected workers. OpenCode's execute tool is not offered to this run, and its MCP servers (SynaBun's included) are direct tool calls.`
|
|
1113
|
+
: `worker confinement (warn, capability ${confinement.capability}): Code Mode stays on as before; each execute call is noted, because Protect workers cannot check the calls inside it. Enforce turns it off.`;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const OPENCODE_EDIT_KINDS = /^(?:edit|write|patch|multiedit|apply_patch)$/i;
|
|
1117
|
+
|
|
1118
|
+
/** The path an OpenCode permission request names, from its metadata or its pattern. */
|
|
1119
|
+
function openCodePathOf(request) {
|
|
1120
|
+
const meta = request?.metadata && typeof request.metadata === 'object' ? request.metadata : {};
|
|
1121
|
+
for (const key of ['filePath', 'filepath', 'file_path', 'path', 'directory', 'dir']) if (typeof meta[key] === 'string' && meta[key]) return meta[key];
|
|
1122
|
+
const pattern = request?.pattern ?? request?.patterns;
|
|
1123
|
+
if (typeof pattern === 'string' && pattern) return pattern;
|
|
1124
|
+
if (Array.isArray(pattern) && typeof pattern[0] === 'string' && pattern[0]) return pattern[0];
|
|
1125
|
+
return null;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* An OpenCode permission request (the `permission.asked` payload: { permission | type, metadata, pattern(s) }) under
|
|
1130
|
+
* a confinement: null when it may go ahead, else a refusal.
|
|
1131
|
+
*/
|
|
1132
|
+
export function openCodePermissionDecision(request, confinement, { toolName = null } = {}) {
|
|
1133
|
+
if (!confinement) return null;
|
|
1134
|
+
try {
|
|
1135
|
+
const kind = String(request?.permission || request?.type || '').toLowerCase();
|
|
1136
|
+
if (kind === 'bash') return workerShellDecision(request?.metadata?.command ?? openCodePathOf(request) ?? '', confinement);
|
|
1137
|
+
if (OPENCODE_EDIT_KINDS.test(kind)) {
|
|
1138
|
+
if (confinement.capability === 'read-only') return refusal('read-only', `This run has capability read-only: ${kind} is not available. Report what you found instead of changing files.`);
|
|
1139
|
+
const raw = openCodePathOf(request);
|
|
1140
|
+
const place = raw ? placeOf(raw, { cwd: confinement.cwd, home: confinement.home }) : null;
|
|
1141
|
+
if (!place) return refusal('unknown-path', `${kind} names a path SynaBun could not read; this run writes inside ${rootsText(confinement)} only.`);
|
|
1142
|
+
if (!mayWrite(place, confinement)) return refusal('write-outside', `${kind} to ${place} is outside this run's workspace: this run writes inside ${rootsText(confinement)} only.`, place);
|
|
1143
|
+
return null;
|
|
1144
|
+
}
|
|
1145
|
+
if (kind === 'external_directory') {
|
|
1146
|
+
// Reads outside the workspace are allowed; writes are not. The adapter resolves the ask's tool reference
|
|
1147
|
+
// against the actual tool part (not metadata guessed from the path), so an unidentified caller fails closed
|
|
1148
|
+
// outside writable roots, as do shell and edit calls. SynaBun's own protected home folders (its data home,
|
|
1149
|
+
// the runtime credential stores) stay refused even for a read: the serve's OS sandbox denies them too.
|
|
1150
|
+
const raw = openCodePathOf(request);
|
|
1151
|
+
const place = raw ? placeOf(raw, { cwd: confinement.cwd, home: confinement.home }) : null;
|
|
1152
|
+
if (/^(?:read|grep|glob|list)$/i.test(String(toolName || ''))) {
|
|
1153
|
+
if (place && isProtectedPlace(place, confinement)) {
|
|
1154
|
+
return refusal('protected-path', `${toolName} requested ${place}, one of SynaBun's protected home folders (its data home or a runtime's credential store): a worker does not read there, whatever its capability.`, place);
|
|
1155
|
+
}
|
|
1156
|
+
return null;
|
|
1157
|
+
}
|
|
1158
|
+
if (place && mayWrite(place, confinement)) return null;
|
|
1159
|
+
return refusal('write-outside', `${toolName || 'An unidentified tool'} requested access to ${place || raw || 'a folder outside the working directory'} (capability ${confinement.capability}): only read, grep, glob and list may read outside this run's workspace; the shell and edit tools stay inside ${rootsText(confinement)}.`, place || undefined);
|
|
1160
|
+
}
|
|
1161
|
+
return null; // every other ask (webfetch, a tool rule the user set) keeps today's answer
|
|
1162
|
+
} catch (err) {
|
|
1163
|
+
return refusal('check-failed', `The request could not be checked (${err?.message || err}), so it is refused.`);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// The ACP tool kinds that change files (session/request_permission's toolCall.kind).
|
|
1168
|
+
const ACP_WRITE_KINDS = new Set(['edit', 'delete', 'move']);
|
|
1169
|
+
// Preserve the public import while using Antigravity's canonical path reader.
|
|
1170
|
+
export { acpToolPaths };
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* An ACP permission request (session/request_permission: its toolCall { kind, title, locations, rawInput }) under
|
|
1174
|
+
* a confinement, the way Antigravity's `agy acp` asks before an edit, a command or work outside the workspace:
|
|
1175
|
+
* null when it may go ahead, else a refusal. `execute` is read through the shell heuristic, `edit` / `delete` /
|
|
1176
|
+
* `move` by every path the call names (all of them must be writable; read-only refuses them all), `read` /
|
|
1177
|
+
* `search` pass unless they reach one of SynaBun's protected home folders, `fetch` passes unless it calls one of
|
|
1178
|
+
* SynaBun's own local ports, and anything else (an MCP tool) goes through workerToolDecision by its title.
|
|
1179
|
+
*/
|
|
1180
|
+
export function acpPermissionDecision(rawCall, confinement) {
|
|
1181
|
+
if (!confinement) return null;
|
|
1182
|
+
try {
|
|
1183
|
+
// Either shape Antigravity sends (a permission request's toolCall, a hook payload's { name, args }), read as one
|
|
1184
|
+
// (lib/antigravity/tool-call.js): its kind from its name when it has none, an MCP call as its server and tool.
|
|
1185
|
+
const call = rawCall?.args && 'mcp' in rawCall ? rawCall : acpCall(rawCall);
|
|
1186
|
+
const toolCall = { ...call, rawInput: call.args };
|
|
1187
|
+
// An MCP call is the tool it calls (agy reports it as kind `execute` with no command line).
|
|
1188
|
+
if (call.mcp) return workerToolDecision(`mcp__${call.mcp.server}__${call.mcp.tool}`, call.mcp.args, confinement);
|
|
1189
|
+
const kind = String(call.kind || 'other').toLowerCase();
|
|
1190
|
+
const input = call.args;
|
|
1191
|
+
const label = String(call.title || call.name || kind).slice(0, 120);
|
|
1192
|
+
if (kind === 'execute') {
|
|
1193
|
+
const command = typeof input.command === 'string' ? input.command
|
|
1194
|
+
: Array.isArray(input.command) ? input.command.join(' ')
|
|
1195
|
+
: typeof input.CommandLine === 'string' ? input.CommandLine
|
|
1196
|
+
: typeof input.cmd === 'string' ? input.cmd : '';
|
|
1197
|
+
if (!command) return refusal('unknown-command', `${label} names no command SynaBun could read, so it is refused; this run writes inside ${rootsText(confinement)} only.`);
|
|
1198
|
+
return workerShellDecision(command, confinement);
|
|
1199
|
+
}
|
|
1200
|
+
if (ACP_WRITE_KINDS.has(kind)) {
|
|
1201
|
+
if (confinement.capability === 'read-only') return refusal('read-only', `This run has capability read-only: ${label} (${kind}) is not available. Report what you found instead of changing files.`);
|
|
1202
|
+
const paths = acpToolPaths(toolCall);
|
|
1203
|
+
if (!paths.length) return refusal('unknown-path', `${label} (${kind}) names no path SynaBun could read; this run writes inside ${rootsText(confinement)} only.`);
|
|
1204
|
+
for (const raw of paths) {
|
|
1205
|
+
const place = placeOf(raw, { cwd: confinement.cwd, home: confinement.home });
|
|
1206
|
+
if (!place) return refusal('unknown-path', `${label} names a path SynaBun could not read ("${String(raw).slice(0, 200)}"); this run writes inside ${rootsText(confinement)} only.`);
|
|
1207
|
+
if (!mayWrite(place, confinement)) return refusal('write-outside', `${label} (${kind}) to ${place} is outside this run's workspace: this run writes inside ${rootsText(confinement)} only.`, place);
|
|
1208
|
+
}
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
if (kind === 'read' || kind === 'search') {
|
|
1212
|
+
for (const raw of acpToolPaths(toolCall)) {
|
|
1213
|
+
const place = placeOf(raw, { cwd: confinement.cwd, home: confinement.home });
|
|
1214
|
+
if (place && isProtectedPlace(place, confinement)) {
|
|
1215
|
+
return refusal('protected-path', `${label} reaches ${place}, one of SynaBun's protected home folders (its data home or a runtime's credential store): a worker does not read there, whatever its capability.`, place);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
if (kind === 'fetch') {
|
|
1221
|
+
const url = typeof input.url === 'string' ? input.url : typeof input.Url === 'string' ? input.Url : '';
|
|
1222
|
+
if (url) {
|
|
1223
|
+
let parsed = null;
|
|
1224
|
+
try { parsed = new URL(url); } catch { /* not a URL: nothing to compare */ }
|
|
1225
|
+
if (parsed && isLoopbackHost(parsed.hostname)) {
|
|
1226
|
+
const port = Number(parsed.port || (parsed.protocol === 'https:' ? 443 : 80));
|
|
1227
|
+
const kindOfPort = controlPortOf(port, confinement);
|
|
1228
|
+
if (kindOfPort) return refusal('control-port', `${label}: ${controlPortText(kindOfPort, parsed.hostname, port, confinement)}; a worker does not call it.`);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
return null;
|
|
1232
|
+
}
|
|
1233
|
+
if (kind === 'think' || kind === 'switch_mode') return null;
|
|
1234
|
+
// MCP and other tools: by the name the call carries ("mcp__server__tool" when the agent says so).
|
|
1235
|
+
const name = typeof input.toolName === 'string' ? input.toolName : typeof toolCall?.title === 'string' ? toolCall.title : '';
|
|
1236
|
+
return name ? workerToolDecision(name, input, confinement) : null;
|
|
1237
|
+
} catch (err) {
|
|
1238
|
+
return refusal('check-failed', `The request could not be checked (${err?.message || err}), so it is refused.`);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** What the run's transcript records for a decision: { type: 'synabun.confinement', action, tool, rule, reason, target? }. */
|
|
1243
|
+
export function confinementEvent(decision, { tool, confinement, provider = null } = {}) {
|
|
1244
|
+
return {
|
|
1245
|
+
type: 'synabun.confinement',
|
|
1246
|
+
action: confinement?.enforced ? 'refused' : 'warned',
|
|
1247
|
+
capability: confinement?.capability || null,
|
|
1248
|
+
tool: tool || null,
|
|
1249
|
+
rule: decision?.rule || null,
|
|
1250
|
+
reason: decision?.reason || '',
|
|
1251
|
+
...(decision?.target ? { target: decision.target } : {}),
|
|
1252
|
+
...(provider ? { provider } : {}),
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** One line for a transcript tail. */
|
|
1257
|
+
export function describeConfinementEvent(event) {
|
|
1258
|
+
const verb = event?.action === 'warned' ? 'guard warning' : 'refused';
|
|
1259
|
+
return `${verb}: ${event?.tool || 'tool'}${event?.target ? ` → ${event.target}` : ''} (${event?.rule || 'rule'}) ${String(event?.reason || '').slice(0, 200)}`;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/** Whether a path is one of the protected home folders (for messages and tests). */
|
|
1263
|
+
export function isProtectedPlace(place, confinement) {
|
|
1264
|
+
if (!place || !confinement) return false;
|
|
1265
|
+
return confinement.protectedDirs.some((dir) => inside(realPlace(dir), place, true));
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
export const _internals = { shellSegments, writtenPaths, unwrap, READ_PROGRAMS, WRITERS, existsSync, lstatSync };
|