@cohortapp/agent-sdk 2.16.0 → 2.18.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +18 -0
- package/.env.example +23 -7
- package/README.md +1 -0
- package/bin/maestro.mjs +62 -0
- package/docs/guides/billing-console-keys.md +60 -0
- package/docs/guides/front-door-session.md +54 -9
- package/docs/guides/mac-mini.md +20 -25
- package/docs/guides/poller-daemon-setup.md +4 -1
- package/docs/guides/setup-wizard.md +1 -1
- package/docs/runbooks/fleet-rollout.md +156 -0
- package/docs/runbooks/mac-mini-bootstrap.md +12 -14
- package/lib/action-executor.js +19 -3
- package/lib/budget-guard.mjs +279 -3
- package/lib/channels/base-adapter.mjs +3 -1
- package/lib/channels/contract.mjs +2 -1
- package/lib/channels/inbox-item.mjs +8 -0
- package/lib/claude-bin.mjs +5 -6
- package/lib/cli/doctor-checks.mjs +141 -10
- package/lib/cli/global-setup-extras.mjs +5 -1
- package/lib/cli/inbox.mjs +100 -15
- package/lib/cli/seat-auth.mjs +463 -0
- package/lib/cli/session.mjs +80 -12
- package/lib/collective/capture.mjs +8 -6
- package/lib/collective/global-config.mjs +63 -1
- package/lib/collective/presence.mjs +142 -5
- package/lib/comms/send-gate.mjs +559 -1
- package/lib/context/budget.mjs +327 -0
- package/lib/context/history-scope.mjs +138 -0
- package/lib/diagnostics/alerts.mjs +49 -0
- package/lib/diagnostics/cadence-output-freshness.mjs +288 -0
- package/lib/engine/agents/definitions.mjs +343 -0
- package/lib/engine/agents/persist.mjs +275 -0
- package/lib/engine/agents/runtime.mjs +748 -0
- package/lib/engine/agents/usage.mjs +95 -0
- package/lib/engine/auth-status.mjs +139 -0
- package/lib/engine/budget.mjs +194 -0
- package/lib/engine/cli.mjs +1204 -0
- package/lib/engine/commands/index.mjs +269 -0
- package/lib/engine/context/budget.mjs +219 -0
- package/lib/engine/context/cache.mjs +125 -0
- package/lib/engine/context/child-env.mjs +215 -0
- package/lib/engine/context/compaction.mjs +342 -0
- package/lib/engine/context/images.mjs +90 -0
- package/lib/engine/context/instructions.mjs +327 -0
- package/lib/engine/context/lazy-instructions.mjs +169 -0
- package/lib/engine/context/manager.mjs +182 -0
- package/lib/engine/context/real-path.mjs +91 -0
- package/lib/engine/context/secret-values.mjs +163 -0
- package/lib/engine/context/settings.mjs +274 -0
- package/lib/engine/context/stream-input.mjs +159 -0
- package/lib/engine/guard.mjs +152 -0
- package/lib/engine/hooks.mjs +713 -0
- package/lib/engine/loop.mjs +560 -0
- package/lib/engine/mcp/client.mjs +254 -0
- package/lib/engine/mcp/config.mjs +301 -0
- package/lib/engine/mcp/http.mjs +201 -0
- package/lib/engine/mcp/index.mjs +146 -0
- package/lib/engine/mcp/jsonrpc.mjs +147 -0
- package/lib/engine/mcp/naming.mjs +66 -0
- package/lib/engine/mcp/resources.mjs +89 -0
- package/lib/engine/mcp/results.mjs +133 -0
- package/lib/engine/mcp/stdio.mjs +137 -0
- package/lib/engine/mcp/supervisor.mjs +116 -0
- package/lib/engine/messages.mjs +104 -0
- package/lib/engine/output/json.mjs +164 -0
- package/lib/engine/output/stream-json.mjs +266 -0
- package/lib/engine/permissions.mjs +845 -0
- package/lib/engine/process-identity.mjs +164 -0
- package/lib/engine/process-tree.mjs +551 -0
- package/lib/engine/prompt.mjs +60 -0
- package/lib/engine/session/store.mjs +299 -0
- package/lib/engine/session-runtime/args.mjs +97 -0
- package/lib/engine/session-runtime/host.mjs +143 -0
- package/lib/engine/session-runtime/inbox.mjs +122 -0
- package/lib/engine/session-runtime/notifications.mjs +129 -0
- package/lib/engine/session-runtime/registry.mjs +328 -0
- package/lib/engine/session-runtime/runner.mjs +344 -0
- package/lib/engine/session-runtime/socket.mjs +212 -0
- package/lib/engine/session-runtime/wakeup.mjs +115 -0
- package/lib/engine/skills/index.mjs +321 -0
- package/lib/engine/tools/bash-background.mjs +533 -0
- package/lib/engine/tools/bash.mjs +216 -0
- package/lib/engine/tools/edit.mjs +97 -0
- package/lib/engine/tools/glob.mjs +81 -0
- package/lib/engine/tools/grep.mjs +224 -0
- package/lib/engine/tools/index.mjs +84 -0
- package/lib/engine/tools/list-agents.mjs +32 -0
- package/lib/engine/tools/ls.mjs +127 -0
- package/lib/engine/tools/monitor.mjs +82 -0
- package/lib/engine/tools/notebook-edit.mjs +218 -0
- package/lib/engine/tools/read.mjs +103 -0
- package/lib/engine/tools/schedule-wakeup.mjs +45 -0
- package/lib/engine/tools/schema.mjs +144 -0
- package/lib/engine/tools/send-message.mjs +77 -0
- package/lib/engine/tools/session.mjs +70 -0
- package/lib/engine/tools/todo.mjs +144 -0
- package/lib/engine/tools/toolsearch.mjs +217 -0
- package/lib/engine/tools/walk.mjs +193 -0
- package/lib/engine/tools/web-switch.mjs +31 -0
- package/lib/engine/tools/webfetch-html.mjs +387 -0
- package/lib/engine/tools/webfetch-net.mjs +340 -0
- package/lib/engine/tools/webfetch.mjs +198 -0
- package/lib/engine/tools/websearch.mjs +91 -0
- package/lib/engine/tools/workflow.mjs +95 -0
- package/lib/engine/tools/write.mjs +76 -0
- package/lib/engine/tui/line-editor.mjs +137 -0
- package/lib/engine/tui/render.mjs +86 -0
- package/lib/engine/tui/tui.mjs +274 -0
- package/lib/engine/wire/anthropic-messages.mjs +263 -0
- package/lib/engine/wire/effort.mjs +36 -0
- package/lib/engine/wire/errors.mjs +496 -0
- package/lib/engine/wire/http.mjs +441 -0
- package/lib/engine/wire/index.mjs +76 -0
- package/lib/engine/wire/openai-chat.mjs +332 -0
- package/lib/engine/wire/prompt-cache.mjs +79 -0
- package/lib/engine/wire/search.mjs +140 -0
- package/lib/engine/wire/sse.mjs +114 -0
- package/lib/engine/wire/stall.mjs +349 -0
- package/lib/engine/wire/token-provider.mjs +175 -0
- package/lib/engine/wire/usage.mjs +192 -0
- package/lib/engine/workflow/host.mjs +524 -0
- package/lib/engine/workflow/journal.mjs +188 -0
- package/lib/engine/workflow/json-schema.mjs +171 -0
- package/lib/engine/workflow/meta.mjs +329 -0
- package/lib/engine/workflow/notifications.mjs +52 -0
- package/lib/engine/workflow/runtime.mjs +447 -0
- package/lib/engine/workflow/sandbox.mjs +534 -0
- package/lib/engine/workflow/worker.mjs +141 -0
- package/lib/engine/workflow/worktree.mjs +74 -0
- package/lib/execution/disposition.mjs +1 -1
- package/lib/execution/intake.mjs +10 -0
- package/lib/execution/surface-policy.mjs +15 -0
- package/lib/learning/curator.mjs +8 -6
- package/lib/learning/reflect.mjs +8 -6
- package/lib/model-router/catalog/cohort.yaml +137 -0
- package/lib/model-router/catalog.mjs +118 -1
- package/lib/model-router/economics.mjs +9 -0
- package/lib/model-router/failover.mjs +67 -16
- package/lib/model-router/llm-task.mjs +39 -3
- package/lib/model-router/resolve.mjs +95 -3
- package/lib/model-router/spawn.mjs +46 -47
- package/lib/model-router/taxonomy.mjs +126 -4
- package/lib/org/cost-sync.mjs +141 -11
- package/lib/org/inbound/broadcast.mjs +289 -0
- package/lib/org/inbound/collective.mjs +375 -0
- package/lib/org/inbound/directedness.mjs +96 -8
- package/lib/org/inbound/facts.mjs +82 -4
- package/lib/org/inbound/hydrate.mjs +555 -51
- package/lib/org/inbound/project.mjs +22 -0
- package/lib/org/inbound/surfaces.mjs +14 -0
- package/lib/org/llm-token.mjs +879 -0
- package/lib/org/mesh.mjs +61 -0
- package/lib/org/messaging.mjs +3 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +15 -0
- package/lib/org/quota.mjs +520 -0
- package/lib/org/tool-surface.mjs +104 -16
- package/lib/org/ui-parity.mjs +16 -1
- package/lib/org/work-ledger.mjs +37 -6
- package/lib/rate-guard.mjs +114 -1
- package/lib/resource-governor.mjs +41 -6
- package/lib/runtime/adapter.mjs +823 -0
- package/lib/runtime/child-env.mjs +191 -0
- package/lib/runtime/legacy-shell-guard.mjs +97 -0
- package/lib/runtime/seat-engine.mjs +162 -0
- package/lib/session/ask-ledger.mjs +271 -0
- package/lib/session/current-work.mjs +676 -0
- package/lib/session/feed-core.mjs +40 -3
- package/lib/session/launch-args.mjs +56 -4
- package/lib/session/status-summary.mjs +26 -9
- package/lib/session/upgrade-notice.mjs +42 -0
- package/lib/setup/claude-probe.mjs +117 -13
- package/lib/setup/enrich.mjs +13 -10
- package/lib/setup/sections/model.mjs +39 -13
- package/lib/telemetry/collect.mjs +208 -9
- package/lib/upgrade/ignored-drift.mjs +105 -0
- package/lib/voice/post-call-brief.mjs +30 -17
- package/package.json +15 -3
- package/plugins/maestro-skills/skills/board-work.md +5 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
- package/plugins/maestro-skills/skills/main-session.md +18 -7
- package/scripts/ci/check-tarball-fidelity.mjs +126 -2
- package/scripts/ci/run-tests.mjs +47 -19
- package/scripts/cohort-llm/api-key-helper.mjs +92 -0
- package/scripts/collective/hook-runner.mjs +29 -2
- package/scripts/continuous-monitor.sh +13 -0
- package/scripts/cost/track-claude-usage.mjs +15 -0
- package/scripts/daemon/agent-daemon.mjs +408 -20
- package/scripts/daemon/assurance.mjs +48 -12
- package/scripts/daemon/cadence-consumer.mjs +218 -68
- package/scripts/daemon/cadence-handlers.mjs +73 -4
- package/scripts/daemon/classifier.mjs +75 -26
- package/scripts/daemon/context-compiler.mjs +104 -59
- package/scripts/daemon/deliver.mjs +30 -1
- package/scripts/daemon/dispatcher.mjs +804 -157
- package/scripts/daemon/health.mjs +14 -1
- package/scripts/daemon/lib/session-router.mjs +310 -42
- package/scripts/daemon/maestro-daemon.mjs +11 -0
- package/scripts/daemon/prompt-builder.mjs +121 -12
- package/scripts/daemon/responder.mjs +315 -146
- package/scripts/daemon/sdk-version.mjs +98 -16
- package/scripts/eval/probe-gateway.mjs +635 -0
- package/scripts/eval/replay/extract.mjs +270 -0
- package/scripts/eval/replay/grade.mjs +260 -0
- package/scripts/eval/replay/lib/config.mjs +50 -0
- package/scripts/eval/replay/lib/effects.mjs +65 -0
- package/scripts/eval/replay/lib/fixture.mjs +188 -0
- package/scripts/eval/replay/lib/judge.mjs +72 -0
- package/scripts/eval/replay/lib/redact.mjs +136 -0
- package/scripts/eval/replay/lib/sandbox.mjs +170 -0
- package/scripts/eval/replay/lib/schema-check.mjs +63 -0
- package/scripts/eval/replay/lib/transcript.mjs +76 -0
- package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
- package/scripts/eval/replay/report.mjs +185 -0
- package/scripts/eval/replay/run.mjs +404 -0
- package/scripts/fleet/rollout.mjs +1094 -0
- package/scripts/hooks/pre-send-audit.sh +36 -245
- package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
- package/scripts/hooks/validate-state-yaml.sh +190 -0
- package/scripts/huddle/huddle-llm.mjs +361 -0
- package/scripts/huddle/huddle-server.mjs +46 -121
- package/scripts/local-triggers/autoupdate.sh +448 -78
- package/scripts/local-triggers/run-trigger.sh +13 -0
- package/scripts/maintenance/pin-integrity.mjs +364 -0
- package/scripts/poll-slack-events.sh +41 -9
- package/scripts/poller/slack-socket-mode.mjs +28 -3
- package/scripts/session/supervisor.mjs +80 -13
- package/scripts/spawn-session.sh +13 -0
- package/bin/maestro.test.mjs +0 -1574
- package/lib/action-executor.test.mjs +0 -871
- package/lib/archetype.test.mjs +0 -132
- package/lib/assurance/plan-note.test.mjs +0 -234
- package/lib/assurance/room-budget.test.mjs +0 -486
- package/lib/assurance/tier.test.mjs +0 -174
- package/lib/autonomy.test.mjs +0 -66
- package/lib/backlog.test.mjs +0 -302
- package/lib/backup/policy.test.mjs +0 -305
- package/lib/budget-escalate.test.mjs +0 -232
- package/lib/budget-guard.envelope.test.mjs +0 -476
- package/lib/budget-guard.test.mjs +0 -427
- package/lib/cadence-bus-requeue.test.mjs +0 -83
- package/lib/cadence-bus-schedule.test.mjs +0 -194
- package/lib/cadence-bus.test.mjs +0 -720
- package/lib/cadences.test.mjs +0 -230
- package/lib/capability/inventory.test.mjs +0 -232
- package/lib/capability.test.mjs +0 -78
- package/lib/channels/base-adapter.test.mjs +0 -590
- package/lib/channels/channels.test.mjs +0 -371
- package/lib/channels/contract.test.mjs +0 -162
- package/lib/channels/inbox-item.test.mjs +0 -368
- package/lib/channels/orgmail/adapter.test.mjs +0 -448
- package/lib/channels/pairing.test.mjs +0 -270
- package/lib/channels/repeat-suppressor.test.mjs +0 -134
- package/lib/channels/slack-adapter.test.mjs +0 -212
- package/lib/channels/telegram-adapter.test.mjs +0 -306
- package/lib/channels/voice/adapter.test.mjs +0 -278
- package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
- package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
- package/lib/charter.test.mjs +0 -89
- package/lib/claude-bin.test.mjs +0 -131
- package/lib/cli/board.test.mjs +0 -227
- package/lib/cli/design.test.mjs +0 -270
- package/lib/cli/doctor-checks.test.mjs +0 -336
- package/lib/cli/global-setup-extras.test.mjs +0 -462
- package/lib/cli/inbox.test.mjs +0 -230
- package/lib/cli/session-ack.test.mjs +0 -63
- package/lib/cli/session.test.mjs +0 -613
- package/lib/collective/capture.test.mjs +0 -121
- package/lib/collective/cards.test.mjs +0 -114
- package/lib/collective/config.test.mjs +0 -123
- package/lib/collective/global-config.test.mjs +0 -220
- package/lib/collective/global-skills.test.mjs +0 -126
- package/lib/collective/presence.test.mjs +0 -95
- package/lib/collective/recall.test.mjs +0 -116
- package/lib/collective/vendor-skills.test.mjs +0 -306
- package/lib/comms/send-gate.test.mjs +0 -770
- package/lib/comms.test.mjs +0 -41
- package/lib/cost/ledger-row.test.mjs +0 -183
- package/lib/design/design-md.test.mjs +0 -318
- package/lib/design/fixtures/DESIGN.golden.md +0 -238
- package/lib/design/fixtures/PRODUCT.golden.md +0 -67
- package/lib/design/fixtures/foundation.json +0 -133
- package/lib/design/refresh-gate.test.mjs +0 -144
- package/lib/design/write.test.mjs +0 -241
- package/lib/diagnostics/alerts.test.mjs +0 -318
- package/lib/diagnostics/backup-freshness.test.mjs +0 -185
- package/lib/diagnostics/counters.test.mjs +0 -206
- package/lib/diagnostics/events.test.mjs +0 -290
- package/lib/diagnostics/otel.test.mjs +0 -196
- package/lib/diagnostics/trace.test.mjs +0 -251
- package/lib/env-compat.test.mjs +0 -104
- package/lib/execution/disposition.test.mjs +0 -553
- package/lib/execution/drive.test.mjs +0 -270
- package/lib/execution/effects.test.mjs +0 -344
- package/lib/execution/intake.test.mjs +0 -389
- package/lib/execution/journal.test.mjs +0 -261
- package/lib/execution/match.test.mjs +0 -235
- package/lib/execution/pipeline.test.mjs +0 -392
- package/lib/execution/route.test.mjs +0 -186
- package/lib/execution/surface-policy.test.mjs +0 -162
- package/lib/fs-atomic.test.mjs +0 -72
- package/lib/fs-ownership.test.mjs +0 -158
- package/lib/goals/admission.test.mjs +0 -164
- package/lib/goals/classify.test.mjs +0 -167
- package/lib/goals/collaborate.test.mjs +0 -336
- package/lib/goals/gaps.test.mjs +0 -284
- package/lib/goals/loop.test.mjs +0 -845
- package/lib/hooks/bus.test.mjs +0 -387
- package/lib/identity/persona.test.mjs +0 -142
- package/lib/kpi-sensors.test.mjs +0 -278
- package/lib/kpi.test.mjs +0 -244
- package/lib/learning/config.test.mjs +0 -75
- package/lib/learning/counters.test.mjs +0 -69
- package/lib/learning/curator-consolidate.test.mjs +0 -238
- package/lib/learning/curator.test.mjs +0 -106
- package/lib/learning/reflect.test.mjs +0 -0
- package/lib/learning/session-index.test.mjs +0 -125
- package/lib/learning/skill-writer.test.mjs +0 -210
- package/lib/mandate/audit.test.mjs +0 -195
- package/lib/mandate/contract.test.mjs +0 -185
- package/lib/mandate/derive.test.mjs +0 -274
- package/lib/mandate/model.test.mjs +0 -164
- package/lib/mandate/refresh.test.mjs +0 -389
- package/lib/mcp/server.test.mjs +0 -426
- package/lib/model-router/auth-profiles.test.mjs +0 -580
- package/lib/model-router/catalog.test.mjs +0 -385
- package/lib/model-router/economics.test.mjs +0 -438
- package/lib/model-router/failover.test.mjs +0 -439
- package/lib/model-router/health.test.mjs +0 -338
- package/lib/model-router/integration-coverage.test.mjs +0 -831
- package/lib/model-router/integration.test.mjs +0 -564
- package/lib/model-router/ledger.test.mjs +0 -415
- package/lib/model-router/llm-task.test.mjs +0 -392
- package/lib/model-router/org-credentials.test.mjs +0 -265
- package/lib/model-router/pricing-refresh.test.mjs +0 -286
- package/lib/model-router/reconcile.test.mjs +0 -316
- package/lib/model-router/repair.test.mjs +0 -180
- package/lib/model-router/spawn.test.mjs +0 -446
- package/lib/model-router/taxonomy.test.mjs +0 -410
- package/lib/model-router.test.mjs +0 -1207
- package/lib/org/activity.test.mjs +0 -134
- package/lib/org/approvals.test.mjs +0 -216
- package/lib/org/awareness.test.mjs +0 -159
- package/lib/org/board-mine-cache.test.mjs +0 -53
- package/lib/org/board.test.mjs +0 -187
- package/lib/org/bootstrap-context.test.mjs +0 -153
- package/lib/org/client.test.mjs +0 -1206
- package/lib/org/cohort-client.test.mjs +0 -126
- package/lib/org/cost-sync.test.mjs +0 -153
- package/lib/org/doctor.test.mjs +0 -346
- package/lib/org/engagement-ledger.test.mjs +0 -112
- package/lib/org/engagement.test.mjs +0 -739
- package/lib/org/handoff.test.mjs +0 -269
- package/lib/org/inbound/directedness.test.mjs +0 -668
- package/lib/org/inbound/facts.test.mjs +0 -471
- package/lib/org/inbound/hydrate.test.mjs +0 -453
- package/lib/org/inbound/index.test.mjs +0 -429
- package/lib/org/inbound/project.test.mjs +0 -287
- package/lib/org/integration-tools.test.mjs +0 -160
- package/lib/org/keys.test.mjs +0 -92
- package/lib/org/knowledge.test.mjs +0 -326
- package/lib/org/leases.test.mjs +0 -235
- package/lib/org/mesh-directives.test.mjs +0 -110
- package/lib/org/mesh-integration.test.mjs +0 -127
- package/lib/org/mesh.test.mjs +0 -400
- package/lib/org/messaging.test.mjs +0 -471
- package/lib/org/param-contract.test.mjs +0 -477
- package/lib/org/policy.test.mjs +0 -237
- package/lib/org/protocol.checksum.test.mjs +0 -90
- package/lib/org/protocol.test.mjs +0 -323
- package/lib/org/push.test.mjs +0 -792
- package/lib/org/registry.test.mjs +0 -100
- package/lib/org/resource-tools.test.mjs +0 -361
- package/lib/org/tool-access.test.mjs +0 -144
- package/lib/org/tool-surface-integration.test.mjs +0 -120
- package/lib/org/tool-surface.test.mjs +0 -1268
- package/lib/org/typing.test.mjs +0 -291
- package/lib/org/ui-parity.test.mjs +0 -560
- package/lib/org/verify.test.mjs +0 -194
- package/lib/org/work-ledger.test.mjs +0 -273
- package/lib/plan/adoption-e2e.test.mjs +0 -366
- package/lib/plan/budget-enforcement.test.mjs +0 -400
- package/lib/plan/compile.test.mjs +0 -382
- package/lib/plan/emit.test.mjs +0 -269
- package/lib/plan/explain.test.mjs +0 -188
- package/lib/prompts/parallelism.test.mjs +0 -177
- package/lib/rag/rag.test.mjs +0 -505
- package/lib/rate-guard.test.mjs +0 -272
- package/lib/reactive-gate.test.mjs +0 -57
- package/lib/render.test.mjs +0 -68
- package/lib/resource-governor.test.mjs +0 -488
- package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
- package/lib/scheduling/jitter.test.mjs +0 -140
- package/lib/secrets/broker.test.mjs +0 -280
- package/lib/secrets/providers.test.mjs +0 -274
- package/lib/security/audit-engine.test.mjs +0 -424
- package/lib/security/coerce-args.test.mjs +0 -281
- package/lib/security/dangerous-tools.test.mjs +0 -68
- package/lib/security/external-content.test.mjs +0 -84
- package/lib/security/redact.test.mjs +0 -441
- package/lib/security/secret-equal.test.mjs +0 -55
- package/lib/session/config.test.mjs +0 -92
- package/lib/session/feed-core.test.mjs +0 -198
- package/lib/session/first-run.test.mjs +0 -121
- package/lib/session/frontdoor.test.mjs +0 -205
- package/lib/session/handoffs.test.mjs +0 -183
- package/lib/session/identity.test.mjs +0 -180
- package/lib/session/inbox-claims.test.mjs +0 -286
- package/lib/session/launch-args.test.mjs +0 -157
- package/lib/session/liveness.test.mjs +0 -100
- package/lib/session/status-summary.test.mjs +0 -118
- package/lib/session-permissions.test.mjs +0 -120
- package/lib/setup/claude-probe.test.mjs +0 -187
- package/lib/setup/completeness.test.mjs +0 -110
- package/lib/setup/context-pack.test.mjs +0 -89
- package/lib/setup/enrich.test.mjs +0 -115
- package/lib/setup/enroll-from-cohort.test.mjs +0 -300
- package/lib/setup/integration.test.mjs +0 -162
- package/lib/setup/io.test.mjs +0 -77
- package/lib/setup/runner.test.mjs +0 -132
- package/lib/setup/sections/identity.test.mjs +0 -234
- package/lib/setup/sections/inventory.test.mjs +0 -198
- package/lib/setup/sections/learning.test.mjs +0 -81
- package/lib/setup/sections/mandate.test.mjs +0 -388
- package/lib/setup/sections/messaging.test.mjs +0 -127
- package/lib/setup/sections/model.test.mjs +0 -240
- package/lib/setup/sections/org.test.mjs +0 -346
- package/lib/setup/sections/orgmail.test.mjs +0 -118
- package/lib/setup/sections/recovery.test.mjs +0 -98
- package/lib/setup/sections/subagents.test.mjs +0 -429
- package/lib/setup/sections/verify.test.mjs +0 -175
- package/lib/setup/sot.test.mjs +0 -81
- package/lib/setup/state.test.mjs +0 -115
- package/lib/singleton.test.mjs +0 -151
- package/lib/subagents/cli.test.mjs +0 -389
- package/lib/subagents/client.test.mjs +0 -309
- package/lib/subagents/gap.test.mjs +0 -234
- package/lib/subagents/lock.test.mjs +0 -248
- package/lib/subagents/manifest.test.mjs +0 -175
- package/lib/subagents/refs.test.mjs +0 -204
- package/lib/subagents/resolve.test.mjs +0 -422
- package/lib/subagents/schema.test.mjs +0 -328
- package/lib/telemetry/alerts.test.mjs +0 -109
- package/lib/telemetry/collect.test.mjs +0 -1274
- package/lib/tool-definitions-integration.test.mjs +0 -83
- package/lib/tool-definitions.test.mjs +0 -437
- package/lib/upgrade/global-refresh.test.mjs +0 -65
- package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
- package/lib/upgrade/post-steps.test.mjs +0 -200
- package/lib/upgrade/verify.test.mjs +0 -164
- package/lib/util/fetch-timeout.test.mjs +0 -202
- package/lib/util/reconnect.test.mjs +0 -369
- package/lib/util/unhandled.test.mjs +0 -216
- package/lib/voice/outbound.test.mjs +0 -69
- package/lib/voice/session-rotation.test.mjs +0 -114
- package/lib/voice/stt.test.mjs +0 -226
- package/lib/voice/voice.test.mjs +0 -990
- package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
- package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
- package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
- package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
- package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
- package/scripts/ci/check-skill-packs.test.mjs +0 -495
- package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
- package/scripts/ci/check.test.mjs +0 -194
- package/scripts/ci/conformance-org-api.test.mjs +0 -425
- package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
- package/scripts/collective/hook-runner.test.mjs +0 -173
- package/scripts/cost/fleet-digest.test.mjs +0 -207
- package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
- package/scripts/cost/track-claude-usage.test.mjs +0 -148
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
- package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
- package/scripts/daemon/agent-daemon.test.mjs +0 -995
- package/scripts/daemon/assurance-e2e.test.mjs +0 -613
- package/scripts/daemon/assurance.test.mjs +0 -1791
- package/scripts/daemon/board-mirror.test.mjs +0 -165
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
- package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
- package/scripts/daemon/cadence-consumer.test.mjs +0 -776
- package/scripts/daemon/cadence-handlers.test.mjs +0 -837
- package/scripts/daemon/classifier-identity.test.mjs +0 -137
- package/scripts/daemon/classifier.test.mjs +0 -266
- package/scripts/daemon/classify-kind.test.mjs +0 -40
- package/scripts/daemon/context-compiler.test.mjs +0 -300
- package/scripts/daemon/deliver.test.mjs +0 -564
- package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
- package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
- package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
- package/scripts/daemon/execution-ladder.test.mjs +0 -470
- package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
- package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
- package/scripts/daemon/inbox-deferral.test.mjs +0 -336
- package/scripts/daemon/inbox-wake.test.mjs +0 -199
- package/scripts/daemon/integration.test.mjs +0 -149
- package/scripts/daemon/lib/self-echo.test.mjs +0 -153
- package/scripts/daemon/lib/session-router.test.mjs +0 -295
- package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
- package/scripts/daemon/prompt-builder.test.mjs +0 -344
- package/scripts/daemon/responder-cost.test.mjs +0 -68
- package/scripts/daemon/responder-history.test.mjs +0 -185
- package/scripts/daemon/sdk-version.test.mjs +0 -31
- package/scripts/daemon/session-lock.test.mjs +0 -252
- package/scripts/daemon/session-outcomes.test.mjs +0 -533
- package/scripts/daemon/typing-registry.test.mjs +0 -102
- package/scripts/hooks/pre-send-audit.test.mjs +0 -354
- package/scripts/huddle/huddle-prompt.test.mjs +0 -176
- package/scripts/local-triggers/autoupdate.test.mjs +0 -518
- package/scripts/local-triggers/generate-plists.test.mjs +0 -456
- package/scripts/media-generation/brand-clause.test.mjs +0 -135
- package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
- package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
- package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
- package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
- package/scripts/poller/slack-socket-mode.test.mjs +0 -805
- package/scripts/poller-launchd/install.test.mjs +0 -243
- package/scripts/restore-from-backup.test.mjs +0 -181
- package/scripts/session/feed.test.mjs +0 -196
- package/scripts/session/supervisor-sh.test.mjs +0 -218
- package/scripts/session/supervisor.test.mjs +0 -482
- package/scripts/setup/configure-macos.test.mjs +0 -306
- package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
- package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
- package/scripts/setup/generate-capability.test.mjs +0 -134
- package/scripts/setup/init-agent.test.mjs +0 -370
- package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
- package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
- package/scripts/watchdog/memory-watchdog.test.mjs +0 -64
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/engine/permissions.mjs — may this tool call run? (pure)
|
|
3
|
+
*
|
|
4
|
+
* Modes:
|
|
5
|
+
* default rules decide; anything not allowed needs approval
|
|
6
|
+
* acceptEdits also allows Write/Edit/NotebookEdit inside the working
|
|
7
|
+
* directory (and permissions.additionalDirectories)
|
|
8
|
+
* plan denies every tool that can change anything
|
|
9
|
+
* dontAsk anything not explicitly allowed is denied
|
|
10
|
+
* bypassPermissions allows everything except deny and ask rules
|
|
11
|
+
*
|
|
12
|
+
* Rules come from `permissions.allow / deny / ask` in the settings layers and
|
|
13
|
+
* from --allowedTools / --disallowedTools. Syntax (Claude-compatible):
|
|
14
|
+
*
|
|
15
|
+
* Bash every Bash call
|
|
16
|
+
* Bash(npm run test:*) a command starting with "npm run test"
|
|
17
|
+
* Bash(git log *) `*` wildcard anywhere; otherwise an exact command
|
|
18
|
+
* Read(./src/**) path globs, gitignore style: `//abs`, `~/home`,
|
|
19
|
+
* Edit(/docs/*.md) `/from-settings-root`, `./` or bare = from cwd;
|
|
20
|
+
* a pattern with no slash matches at any depth.
|
|
21
|
+
* Read rules also govern Glob and Grep; Edit rules
|
|
22
|
+
* govern Write, Edit and NotebookEdit.
|
|
23
|
+
* WebFetch(domain:example.com)
|
|
24
|
+
* mcp__cohort every tool of that server (= mcp__cohort__*)
|
|
25
|
+
* mcp__cohort__org_rpc one MCP tool
|
|
26
|
+
*
|
|
27
|
+
* Evaluation, first match wins in this order:
|
|
28
|
+
* 1. a deny rule → deny (holds in every mode)
|
|
29
|
+
* 2. plan mode and a tool not planSafe → deny (every MCP tool counts as
|
|
30
|
+
* mutating, whatever its hint;
|
|
31
|
+
* TOOL_POSTURE names the agent
|
|
32
|
+
* and session tools, CF-47)
|
|
33
|
+
* 3. an ask rule → ask (holds in every mode)
|
|
34
|
+
* 4. an allow rule → allow (for a write to engine config,
|
|
35
|
+
* only a rule naming a path)
|
|
36
|
+
* 5. a write to engine config → ask (see below; every mode; for
|
|
37
|
+
* Bash, bashEngineConfigWrite)
|
|
38
|
+
* 6. bypassPermissions → allow
|
|
39
|
+
* 7. acceptEdits and an edit in scope→ allow
|
|
40
|
+
* 8. a read-only built-in, or a TOOL_POSTURE autoAllow tool → allow
|
|
41
|
+
* 9. dontAsk → deny
|
|
42
|
+
* 10. otherwise → ask
|
|
43
|
+
*
|
|
44
|
+
* Engine config (`isEngineConfigPath`): anything under a `.claude` directory
|
|
45
|
+
* (settings, settings.local, skills, agents, commands, hooks, memory), any
|
|
46
|
+
* `.mcp.json` or `.claude.json`, the instruction files CLAUDE.md,
|
|
47
|
+
* CLAUDE.local.md, COHORT.md and AGENTS.md, and anything under a caller-named
|
|
48
|
+
* config directory ($CLAUDE_CONFIG_DIR). A write there could plant a hook, an
|
|
49
|
+
* MCP server, an allow rule or `defaultMode: bypassPermissions` for the next
|
|
50
|
+
* run, so no mode auto-allows it and neither does a whole-tool `Write`/`Edit`
|
|
51
|
+
* allow rule; only a path-specific allow rule does.
|
|
52
|
+
*
|
|
53
|
+
* The engine has no approval prompt: the caller turns `ask` into a denial with
|
|
54
|
+
* a message that says how to allow the call (`headlessAskDenial`), unless a
|
|
55
|
+
* PreToolUse hook decided it first.
|
|
56
|
+
*
|
|
57
|
+
* Compound shell commands are split on `&&`, `||`, `;`, `|`, `&` and newlines
|
|
58
|
+
* outside quotes. An allow rule must match EVERY part; a deny or ask rule
|
|
59
|
+
* matches when ANY part does. A command with command substitution (`$(…)`,
|
|
60
|
+
* backticks, `<(…)`) never matches a Bash allow rule that has a specifier.
|
|
61
|
+
*
|
|
62
|
+
* Deny and ask rules fail closed. Each part is also tested with common
|
|
63
|
+
* wrappers stripped (`sudo`, `env A=1`, `nohup`, `time`, `command`, `exec`,
|
|
64
|
+
* `nice`, `timeout 5`, `xargs`), quotes removed from the command word, and a
|
|
65
|
+
* path on it reduced to its basename (`/bin/rm` is `rm`). A command whose
|
|
66
|
+
* real text the rules cannot see — substitution, `eval`, `sh|bash|zsh -c`,
|
|
67
|
+
* `find -exec`, a command word that is a variable — matches EVERY Bash deny
|
|
68
|
+
* and ask rule that has a specifier.
|
|
69
|
+
*
|
|
70
|
+
* Symlinks (CF-19, W5-B). With a `resolvePath` (context/real-path.mjs; the
|
|
71
|
+
* guard and the CLI always pass one), a path is judged by where it really
|
|
72
|
+
* leads: a deny or ask path rule matches the name OR the real path, an allow
|
|
73
|
+
* rule must cover the real path (the rule's literal prefix resolved too, so
|
|
74
|
+
* `/tmp/**` still covers /private/tmp), the engine-config protection holds for
|
|
75
|
+
* a link to config, and acceptEdits allows only an edit whose real path is in
|
|
76
|
+
* scope. A new file is judged by its nearest existing ancestor's real path, a
|
|
77
|
+
* dangling link by the path it names; a loop keeps its name. A link swapped
|
|
78
|
+
* between this check and the tool's open is not caught.
|
|
79
|
+
*
|
|
80
|
+
* A Bash (or Monitor) command that names engine config in a redirection, tee,
|
|
81
|
+
* cp, mv, sed -i … (`bashEngineConfigWrite`) is an `ask` in every mode, like an
|
|
82
|
+
* Edit of config: a whole-tool `Bash` allow does not cover it, only specific
|
|
83
|
+
* rules naming the file. A conservative narrowing, not a boundary.
|
|
84
|
+
*
|
|
85
|
+
* No filesystem, clock or environment access: every input is a parameter.
|
|
86
|
+
*
|
|
87
|
+
* @module lib/engine/permissions
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
import path from "node:path";
|
|
91
|
+
|
|
92
|
+
export const PERMISSION_MODES = Object.freeze(["default", "acceptEdits", "plan", "dontAsk", "bypassPermissions"]);
|
|
93
|
+
export const EDIT_TOOLS = Object.freeze(new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]));
|
|
94
|
+
export const READ_TOOLS = Object.freeze(new Set(["Read", "Glob", "Grep", "LS", "NotebookRead"]));
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* CF-47 (W4-B): the permission posture of the agent, session and front-door
|
|
98
|
+
* tools, keyed by tool name. Rationale: docs/engine/posture.md.
|
|
99
|
+
*
|
|
100
|
+
* planSafe the tool runs in plan mode
|
|
101
|
+
* autoAllow the tool needs no allow rule in default and dontAsk modes
|
|
102
|
+
*
|
|
103
|
+
* A built-in named here is judged by this table whatever its own `readOnly`
|
|
104
|
+
* flag says; the flag stays the loop's parallel-batching hint. Tools not named
|
|
105
|
+
* here use their `readOnly` flag for both. MCP tools are never either.
|
|
106
|
+
*
|
|
107
|
+
* session-state reads or manages only this run's own state planSafe, autoAllow
|
|
108
|
+
* spawns-clamped starts a child held to this run's mode and rules autoAllow
|
|
109
|
+
* spawns-work starts work the run's guard does not hold neither
|
|
110
|
+
* outbound reaches a session with its own permissions neither
|
|
111
|
+
*/
|
|
112
|
+
export const TOOL_POSTURE = Object.freeze({
|
|
113
|
+
TodoWrite: posture("session-state"),
|
|
114
|
+
TaskOutput: posture("session-state"),
|
|
115
|
+
TaskStop: posture("session-state"),
|
|
116
|
+
BashOutput: posture("session-state"),
|
|
117
|
+
KillShell: posture("session-state"),
|
|
118
|
+
ScheduleWakeup: posture("session-state"),
|
|
119
|
+
ListAgents: posture("session-state"),
|
|
120
|
+
WorkflowStatus: posture("session-state"),
|
|
121
|
+
Task: posture("spawns-clamped"),
|
|
122
|
+
Agent: posture("spawns-clamped"),
|
|
123
|
+
Workflow: posture("spawns-work"),
|
|
124
|
+
Monitor: posture("spawns-work"),
|
|
125
|
+
SendMessage: posture("outbound"),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/** @param {'session-state'|'spawns-clamped'|'spawns-work'|'outbound'} kind */
|
|
129
|
+
function posture(kind) {
|
|
130
|
+
return Object.freeze({ kind, planSafe: kind === "session-state", autoAllow: kind === "session-state" || kind === "spawns-clamped" });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The posture a call is judged by. Pure.
|
|
135
|
+
* @param {{toolName:string, readOnly:boolean, isMcp?:boolean}} p
|
|
136
|
+
* @returns {{kind:string, planSafe:boolean, autoAllow:boolean}}
|
|
137
|
+
*/
|
|
138
|
+
export function toolPosture({ toolName, readOnly, isMcp = false }) {
|
|
139
|
+
if (isMcp) return { kind: "mcp", planSafe: false, autoAllow: false };
|
|
140
|
+
const named = Object.hasOwn(TOOL_POSTURE, toolName) ? TOOL_POSTURE[/** @type {keyof typeof TOOL_POSTURE} */ (toolName)] : null;
|
|
141
|
+
if (named) return named;
|
|
142
|
+
return readOnly ? { kind: "read-only", planSafe: true, autoAllow: true } : { kind: "mutating", planSafe: false, autoAllow: false };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @typedef {{raw:string, tool:string, specifier:string|null, source:string, baseDir:string}} PermissionRule
|
|
147
|
+
* @typedef {{allow:PermissionRule[], deny:PermissionRule[], ask:PermissionRule[]}} RuleSet
|
|
148
|
+
* @typedef {{behavior:'allow'|'deny'|'ask', reason:string, rule?:string}} PermissionDecision
|
|
149
|
+
*/
|
|
150
|
+
|
|
151
|
+
/** @param {unknown} mode */
|
|
152
|
+
export function isPermissionMode(mode) {
|
|
153
|
+
return typeof mode === "string" && PERMISSION_MODES.includes(mode);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Split a --allowedTools / --disallowedTools value into rule strings: commas
|
|
158
|
+
* or whitespace separate rules, except inside parentheses.
|
|
159
|
+
* @param {string} value
|
|
160
|
+
*/
|
|
161
|
+
export function splitRuleList(value) {
|
|
162
|
+
const out = [];
|
|
163
|
+
let depth = 0;
|
|
164
|
+
let cur = "";
|
|
165
|
+
for (const ch of String(value)) {
|
|
166
|
+
if (ch === "(") depth++;
|
|
167
|
+
if (ch === ")") depth = Math.max(0, depth - 1);
|
|
168
|
+
if (depth === 0 && (ch === "," || /\s/.test(ch))) {
|
|
169
|
+
if (cur.trim()) out.push(cur.trim());
|
|
170
|
+
cur = "";
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
cur += ch;
|
|
174
|
+
}
|
|
175
|
+
if (cur.trim()) out.push(cur.trim());
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {string} text
|
|
181
|
+
* @param {{source?:string, baseDir?:string}} [o]
|
|
182
|
+
* @returns {{ok:true, rule:PermissionRule} | {ok:false, error:string}}
|
|
183
|
+
*/
|
|
184
|
+
export function parseRule(text, o = {}) {
|
|
185
|
+
const raw = String(text ?? "").trim();
|
|
186
|
+
const m = /^([A-Za-z0-9_*-]+)(?:\(([\s\S]*)\))?$/.exec(raw);
|
|
187
|
+
if (!m) return { ok: false, error: `permission rule "${raw}" is not ToolName or ToolName(specifier)` };
|
|
188
|
+
const specifier = m[2] === undefined ? null : m[2].trim();
|
|
189
|
+
return { ok: true, rule: { raw, tool: m[1], specifier: specifier === "" ? null : specifier, source: o.source ?? "", baseDir: o.baseDir ?? "" } };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {{allow?:unknown, deny?:unknown, ask?:unknown}} lists
|
|
194
|
+
* @param {{source:string, baseDir:string}} o
|
|
195
|
+
* @returns {{rules:RuleSet, errors:string[]}}
|
|
196
|
+
*/
|
|
197
|
+
export function compileRules(lists, o) {
|
|
198
|
+
/** @type {RuleSet} */
|
|
199
|
+
const rules = { allow: [], deny: [], ask: [] };
|
|
200
|
+
const errors = [];
|
|
201
|
+
for (const kind of /** @type {const} */ (["allow", "deny", "ask"])) {
|
|
202
|
+
const list = lists?.[kind];
|
|
203
|
+
if (list === undefined || list === null) continue;
|
|
204
|
+
if (!Array.isArray(list)) {
|
|
205
|
+
errors.push(`${o.source}: permissions.${kind} must be an array`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
for (const item of list) {
|
|
209
|
+
const r = parseRule(String(item), o);
|
|
210
|
+
if (r.ok) rules[kind].push(r.rule);
|
|
211
|
+
else errors.push(`${o.source}: ${r.error}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { rules, errors };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** @param {...RuleSet} sets @returns {RuleSet} */
|
|
218
|
+
export function mergeRuleSets(...sets) {
|
|
219
|
+
return {
|
|
220
|
+
allow: sets.flatMap((s) => s.allow),
|
|
221
|
+
deny: sets.flatMap((s) => s.deny),
|
|
222
|
+
ask: sets.flatMap((s) => s.ask),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** @param {string} glob `*` = any run of characters */
|
|
227
|
+
function wildcardRegex(glob) {
|
|
228
|
+
return new RegExp(`^${glob.split("*").map(escapeRe).join("[\\s\\S]*")}$`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** @param {string} s */
|
|
232
|
+
function escapeRe(s) {
|
|
233
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Does the rule's tool part cover this tool name?
|
|
238
|
+
* @param {string} ruleTool
|
|
239
|
+
* @param {string} toolName
|
|
240
|
+
*/
|
|
241
|
+
export function ruleToolMatches(ruleTool, toolName) {
|
|
242
|
+
if (ruleTool.startsWith("mcp__")) {
|
|
243
|
+
const rest = ruleTool.slice("mcp__".length);
|
|
244
|
+
if (!rest.includes("__")) return toolName.startsWith(`mcp__${rest}__`); // server-level rule
|
|
245
|
+
return ruleTool.includes("*") ? wildcardRegex(ruleTool).test(toolName) : ruleTool === toolName;
|
|
246
|
+
}
|
|
247
|
+
if ((ruleTool === "Task" || ruleTool === "Agent") && (toolName === "Task" || toolName === "Agent")) return true; // one tool, two names
|
|
248
|
+
if (ruleTool === "Read") return READ_TOOLS.has(toolName);
|
|
249
|
+
if (ruleTool === "Edit") return EDIT_TOOLS.has(toolName);
|
|
250
|
+
return ruleTool.includes("*") ? wildcardRegex(ruleTool).test(toolName) : ruleTool === toolName;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Split a shell command into simple commands, outside quotes. Pure and
|
|
255
|
+
* conservative: it does not understand every shell construct, and callers
|
|
256
|
+
* treat what it cannot see through (`hasSubstitution`) as unmatched for allow.
|
|
257
|
+
* @param {string} command
|
|
258
|
+
* @returns {{parts:string[], hasSubstitution:boolean}}
|
|
259
|
+
*/
|
|
260
|
+
export function splitShellCommand(command) {
|
|
261
|
+
const s = String(command);
|
|
262
|
+
const parts = [];
|
|
263
|
+
let cur = "";
|
|
264
|
+
let quote = /** @type {string|null} */ (null);
|
|
265
|
+
let hasSubstitution = false;
|
|
266
|
+
for (let i = 0; i < s.length; i++) {
|
|
267
|
+
const ch = s[i];
|
|
268
|
+
const next = s[i + 1];
|
|
269
|
+
if (quote) {
|
|
270
|
+
if (ch === "\\" && quote === '"' && next !== undefined) {
|
|
271
|
+
cur += ch + next;
|
|
272
|
+
i++;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (quote === '"' && (ch === "`" || (ch === "$" && next === "("))) hasSubstitution = true;
|
|
276
|
+
if (ch === quote) quote = null;
|
|
277
|
+
cur += ch;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (ch === "\\" && next !== undefined) {
|
|
281
|
+
cur += ch + next;
|
|
282
|
+
i++;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (ch === "'" || ch === '"') {
|
|
286
|
+
quote = ch;
|
|
287
|
+
cur += ch;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (ch === "`" || (ch === "$" && next === "(") || ((ch === "<" || ch === ">") && next === "(")) hasSubstitution = true;
|
|
291
|
+
const two = ch + (next ?? "");
|
|
292
|
+
if (two === "&&" || two === "||") {
|
|
293
|
+
parts.push(cur);
|
|
294
|
+
cur = "";
|
|
295
|
+
i++;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (ch === ";" || ch === "|" || ch === "&" || ch === "\n") {
|
|
299
|
+
parts.push(cur);
|
|
300
|
+
cur = "";
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
cur += ch;
|
|
304
|
+
}
|
|
305
|
+
parts.push(cur);
|
|
306
|
+
return {
|
|
307
|
+
parts: parts.map((p) => p.trim().replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\S*)\s+)+/, "").trim()).filter((p) => p !== ""),
|
|
308
|
+
hasSubstitution,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const WRAPPERS = new Set(["sudo", "env", "nohup", "time", "command", "builtin", "exec", "nice", "timeout", "xargs", "stdbuf", "doas"]);
|
|
313
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "fish", "csh", "tcsh"]);
|
|
314
|
+
|
|
315
|
+
/** @param {string} word quotes and backslashes removed, a path reduced to its basename */
|
|
316
|
+
function normaliseCommandWord(word) {
|
|
317
|
+
const unquoted = word.replace(/["'\\]/g, "");
|
|
318
|
+
return unquoted.includes("/") ? unquoted.slice(unquoted.lastIndexOf("/") + 1) : unquoted;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The texts a deny or ask rule is tested against for one simple command: the
|
|
323
|
+
* part as written, then with its command word normalised and each leading
|
|
324
|
+
* wrapper (and the wrapper's own options and assignments) stripped.
|
|
325
|
+
* @param {string} part
|
|
326
|
+
* @returns {{texts:string[], opaque:boolean}}
|
|
327
|
+
*/
|
|
328
|
+
export function denyCandidates(part) {
|
|
329
|
+
const texts = [part];
|
|
330
|
+
let words = part.split(/\s+/).filter(Boolean);
|
|
331
|
+
let opaque = false;
|
|
332
|
+
for (let guard = 0; guard < 10 && words.length > 0; guard++) {
|
|
333
|
+
const cmd = normaliseCommandWord(words[0]);
|
|
334
|
+
if (words[0].startsWith("$")) opaque = true;
|
|
335
|
+
if (cmd === "eval") opaque = true;
|
|
336
|
+
if (SHELLS.has(cmd) && words.slice(1).some((w) => /^-[A-Za-z]*c[A-Za-z]*$/.test(w))) opaque = true;
|
|
337
|
+
if (cmd === "find" && words.some((w) => /^-(exec|execdir|ok|okdir)$/.test(w))) opaque = true;
|
|
338
|
+
texts.push([cmd, ...words.slice(1)].join(" "));
|
|
339
|
+
if (!WRAPPERS.has(cmd)) break;
|
|
340
|
+
// Drop the wrapper, then its options, assignments and a numeric argument (nice -n 5, timeout 10s).
|
|
341
|
+
let i = 1;
|
|
342
|
+
while (i < words.length && (/^-/.test(words[i]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) || /^\d+[smhd]?$/.test(words[i]))) i++;
|
|
343
|
+
words = words.slice(i);
|
|
344
|
+
}
|
|
345
|
+
return { texts, opaque };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** @param {string} spec @param {string} command */
|
|
349
|
+
function bashSpecMatches(spec, command) {
|
|
350
|
+
if (spec === "*") return true;
|
|
351
|
+
if (spec.endsWith(":*")) {
|
|
352
|
+
const prefix = spec.slice(0, -2);
|
|
353
|
+
return command === prefix || command.startsWith(`${prefix} `) || (prefix.endsWith(" ") && command.startsWith(prefix));
|
|
354
|
+
}
|
|
355
|
+
if (spec.includes("*")) return wildcardRegex(spec).test(command);
|
|
356
|
+
return command === spec;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Compile a gitignore-style path pattern to an absolute-path regex.
|
|
361
|
+
* @param {string} pattern
|
|
362
|
+
* @param {{cwd:string, homedir:string|null, baseDir:string}} where
|
|
363
|
+
*/
|
|
364
|
+
export function pathPatternRegex(pattern, where) {
|
|
365
|
+
const { root, p } = patternParts(pattern, where);
|
|
366
|
+
return compilePathRegex(root, p);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* CF-19: the same pattern with its literal prefix (root plus the segments before
|
|
371
|
+
* the first glob character) resolved through symlinks, so a resolved target is
|
|
372
|
+
* compared with a resolved rule — `Edit(/tmp/**)` still covers a file whose real
|
|
373
|
+
* path is /private/tmp/…, and `./**` still covers cwd when cwd is a symlink. Pure
|
|
374
|
+
* (the resolver is a parameter).
|
|
375
|
+
* @param {string} pattern
|
|
376
|
+
* @param {{cwd:string, homedir:string|null, baseDir:string}} where
|
|
377
|
+
* @param {(p:string)=>string|null} resolvePath
|
|
378
|
+
*/
|
|
379
|
+
export function resolvedPathPatternRegex(pattern, where, resolvePath) {
|
|
380
|
+
const { root, p } = patternParts(pattern, where);
|
|
381
|
+
const segs = p === "" ? [] : p.split("/");
|
|
382
|
+
let i = 0;
|
|
383
|
+
while (i < segs.length && !/[*?[\]{}]/.test(segs[i])) i++;
|
|
384
|
+
const literalRoot = path.join(root, ...segs.slice(0, i));
|
|
385
|
+
const realRoot = resolvePath(literalRoot) ?? literalRoot;
|
|
386
|
+
const rest = segs.slice(i).join("/");
|
|
387
|
+
if (rest === "") return new RegExp(`^${escapeRe(realRoot)}(?:/.*)?$`);
|
|
388
|
+
return compilePathRegex(realRoot, rest);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The absolute root a pattern is anchored at and the glob body under it.
|
|
393
|
+
* @param {string} pattern
|
|
394
|
+
* @param {{cwd:string, homedir:string|null, baseDir:string}} where
|
|
395
|
+
*/
|
|
396
|
+
function patternParts(pattern, { cwd, homedir, baseDir }) {
|
|
397
|
+
let p = pattern;
|
|
398
|
+
let root;
|
|
399
|
+
if (p.startsWith("//")) {
|
|
400
|
+
root = "/";
|
|
401
|
+
p = p.slice(2);
|
|
402
|
+
} else if (p.startsWith("~/")) {
|
|
403
|
+
root = homedir ?? "/nonexistent-home";
|
|
404
|
+
p = p.slice(2);
|
|
405
|
+
} else if (p.startsWith("/")) {
|
|
406
|
+
root = baseDir || cwd;
|
|
407
|
+
p = p.slice(1);
|
|
408
|
+
} else {
|
|
409
|
+
root = cwd;
|
|
410
|
+
if (p.startsWith("./")) p = p.slice(2);
|
|
411
|
+
else if (!p.includes("/")) p = `**/${p}`; // gitignore: no slash matches at any depth
|
|
412
|
+
}
|
|
413
|
+
// `dir/` and `dir/**` both name a directory; the suffix below covers its
|
|
414
|
+
// contents, and the directory itself matches too (a Grep rooted there).
|
|
415
|
+
if (p.endsWith("/**")) p = p.slice(0, -3);
|
|
416
|
+
else if (p.endsWith("/")) p = p.slice(0, -1);
|
|
417
|
+
return { root, p };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** @param {string} root @param {string} p */
|
|
421
|
+
function compilePathRegex(root, p) {
|
|
422
|
+
let re = "";
|
|
423
|
+
for (let i = 0; i < p.length; i++) {
|
|
424
|
+
const ch = p[i];
|
|
425
|
+
if (ch === "*" && p[i + 1] === "*") {
|
|
426
|
+
if (p[i + 2] === "/") {
|
|
427
|
+
re += "(?:.*/)?";
|
|
428
|
+
i += 2;
|
|
429
|
+
} else {
|
|
430
|
+
re += ".*";
|
|
431
|
+
i += 1;
|
|
432
|
+
}
|
|
433
|
+
} else if (ch === "*") re += "[^/]*";
|
|
434
|
+
else if (ch === "?") re += "[^/]";
|
|
435
|
+
else re += escapeRe(ch);
|
|
436
|
+
}
|
|
437
|
+
const rootPrefix = root.endsWith("/") ? root : `${root}/`;
|
|
438
|
+
// A pattern naming a directory also covers everything under it.
|
|
439
|
+
return new RegExp(`^${escapeRe(rootPrefix)}${re}(?:/.*)?$`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The path a file tool acts on. For Glob it is the directory the pattern's
|
|
444
|
+
* literal prefix reaches — `{pattern:"/etc/**"}` reads /etc, whatever `path` is.
|
|
445
|
+
* @param {string} toolName @param {any} input @param {string} cwd
|
|
446
|
+
*/
|
|
447
|
+
export function targetPath(toolName, input, cwd) {
|
|
448
|
+
const raw = input?.file_path ?? input?.notebook_path ?? input?.path;
|
|
449
|
+
const base = typeof raw === "string" && raw !== "" ? path.resolve(cwd, raw) : READ_TOOLS.has(toolName) ? path.resolve(cwd) : null;
|
|
450
|
+
if (toolName === "Glob" && base && typeof input?.pattern === "string") {
|
|
451
|
+
const literal = [];
|
|
452
|
+
for (const seg of input.pattern.split("/")) {
|
|
453
|
+
if (/[*?[\]{}]/.test(seg)) break;
|
|
454
|
+
literal.push(seg);
|
|
455
|
+
}
|
|
456
|
+
const prefix = literal.join("/");
|
|
457
|
+
if (prefix !== "") return path.resolve(base, prefix);
|
|
458
|
+
if (input.pattern.startsWith("/")) return "/"; // "/**": the literal prefix is the root itself
|
|
459
|
+
}
|
|
460
|
+
return base;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export const INSTRUCTION_FILE_NAMES = Object.freeze(new Set(["CLAUDE.md", "CLAUDE.local.md", "COHORT.md", "AGENTS.md"]));
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Is this absolute path part of the engine's own configuration or instructions?
|
|
467
|
+
* @param {string} target absolute
|
|
468
|
+
* @param {string[]} [configDirs] extra config roots ($CLAUDE_CONFIG_DIR)
|
|
469
|
+
*/
|
|
470
|
+
export function isEngineConfigPath(target, configDirs = []) {
|
|
471
|
+
const segments = target.split("/").filter(Boolean);
|
|
472
|
+
const base = segments[segments.length - 1] ?? "";
|
|
473
|
+
if (segments.slice(0, -1).includes(".claude") || base === ".claude") return true;
|
|
474
|
+
if (base === ".mcp.json" || base === ".claude.json" || INSTRUCTION_FILE_NAMES.has(base)) return true;
|
|
475
|
+
return isWithin(target, configDirs);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* @param {PermissionRule} rule
|
|
480
|
+
* @param {{toolName:string, input:any}} call
|
|
481
|
+
* @param {{cwd:string, homedir:string|null, resolvePath?:((p:string)=>string|null)|null}} where
|
|
482
|
+
* @param {'allow'|'deny'|'ask'} kind
|
|
483
|
+
*/
|
|
484
|
+
export function ruleMatches(rule, call, where, kind) {
|
|
485
|
+
if (!ruleToolMatches(rule.tool, call.toolName)) return false;
|
|
486
|
+
if (rule.specifier === null || rule.specifier === "*") return true;
|
|
487
|
+
if (call.toolName.startsWith("mcp__")) return false; // MCP rules take no specifier
|
|
488
|
+
|
|
489
|
+
if (call.toolName === "Bash") {
|
|
490
|
+
const command = typeof call.input?.command === "string" ? call.input.command.trim() : "";
|
|
491
|
+
const { parts, hasSubstitution } = splitShellCommand(command);
|
|
492
|
+
if (kind === "allow") {
|
|
493
|
+
if (hasSubstitution || parts.length === 0) return false;
|
|
494
|
+
return parts.every((p) => bashSpecMatches(rule.specifier, p));
|
|
495
|
+
}
|
|
496
|
+
// deny / ask: fail closed on what the rule cannot see through.
|
|
497
|
+
if (hasSubstitution) return true;
|
|
498
|
+
if (bashSpecMatches(rule.specifier, command)) return true;
|
|
499
|
+
const spec = /** @type string */ (rule.specifier);
|
|
500
|
+
return parts.some((p) => {
|
|
501
|
+
const { texts, opaque } = denyCandidates(p);
|
|
502
|
+
return opaque || texts.some((t) => bashSpecMatches(spec, t));
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (READ_TOOLS.has(call.toolName) || EDIT_TOOLS.has(call.toolName)) {
|
|
507
|
+
const target = targetPath(call.toolName, call.input, where.cwd);
|
|
508
|
+
if (!target) return false;
|
|
509
|
+
const at = { cwd: where.cwd, homedir: where.homedir, baseDir: rule.baseDir };
|
|
510
|
+
const lexical = pathPatternRegex(rule.specifier, at);
|
|
511
|
+
if (!where.resolvePath) return lexical.test(target);
|
|
512
|
+
// CF-19: judged by where the path leads. Deny and ask match either the name or the real path
|
|
513
|
+
// (fail closed); an allow must cover the real path. An unresolvable path (a loop) keeps its name.
|
|
514
|
+
const real = where.resolvePath(target);
|
|
515
|
+
if (real === null) return lexical.test(target);
|
|
516
|
+
const realMatches = lexical.test(real) || resolvedPathPatternRegex(rule.specifier, at, where.resolvePath).test(real);
|
|
517
|
+
return kind === "allow" ? realMatches : realMatches || lexical.test(target);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (call.toolName === "WebFetch" && rule.specifier.startsWith("domain:")) {
|
|
521
|
+
const domain = rule.specifier.slice("domain:".length).toLowerCase();
|
|
522
|
+
try {
|
|
523
|
+
const host = new URL(String(call.input?.url)).hostname.toLowerCase();
|
|
524
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
525
|
+
} catch {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (call.toolName === "Skill") return call.input?.skill === rule.specifier;
|
|
531
|
+
if (call.toolName === "SlashCommand") return slashCommandMatches(rule.specifier, call.input?.command); // SlashCommand(/review), SlashCommand(/kit:*)
|
|
532
|
+
if (call.toolName === "Task" || call.toolName === "Agent") return subagentTypeMatches(rule.specifier, call.input?.subagent_type, kind); // Task(code-reader)
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Does `SlashCommand(<specifier>)` cover an invoked command name (W4-E2)? The
|
|
538
|
+
* leading `/` is optional on both sides. `/name` is that name exactly;
|
|
539
|
+
* `/kit:*` is `kit` and every `kit:<name>` (a plugin's commands and skills).
|
|
540
|
+
* Pure.
|
|
541
|
+
* @param {string} specifier @param {unknown} command
|
|
542
|
+
*/
|
|
543
|
+
export function slashCommandMatches(specifier, command) {
|
|
544
|
+
if (typeof command !== "string") return false;
|
|
545
|
+
const name = command.trim().replace(/^\//, "");
|
|
546
|
+
const spec = specifier.trim().replace(/^\//, "");
|
|
547
|
+
if (spec.endsWith(":*")) {
|
|
548
|
+
const prefix = spec.slice(0, -2);
|
|
549
|
+
return name === prefix || name.startsWith(`${prefix}:`);
|
|
550
|
+
}
|
|
551
|
+
return name === spec;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Does `Task(<specifier>)` cover the subagent type the model asked for? The
|
|
556
|
+
* Task tool trims the name and accepts a plugin agent's unqualified name, so a
|
|
557
|
+
* rule must see the same name the tool will run:
|
|
558
|
+
* allow the trimmed name equals the specifier, exactly;
|
|
559
|
+
* deny / ask also when one side is unqualified and equals the other side's
|
|
560
|
+
* unqualified part (`Task(kit:helper)` refuses "helper", and
|
|
561
|
+
* `Task(helper)` refuses "kit:helper") — fail closed.
|
|
562
|
+
* The Task tool re-checks the resolved definition name as well (agents/runtime.mjs).
|
|
563
|
+
* @param {string} specifier @param {unknown} requested @param {'allow'|'deny'|'ask'} kind
|
|
564
|
+
*/
|
|
565
|
+
export function subagentTypeMatches(specifier, requested, kind) {
|
|
566
|
+
if (typeof requested !== "string") return false;
|
|
567
|
+
const want = requested.trim();
|
|
568
|
+
const spec = specifier.trim();
|
|
569
|
+
if (want === spec) return true;
|
|
570
|
+
if (kind === "allow") return false;
|
|
571
|
+
const bare = (/** @type string */ n) => n.slice(n.lastIndexOf(":") + 1);
|
|
572
|
+
return (!want.includes(":") && want === bare(spec)) || (!spec.includes(":") && spec === bare(want));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* The allow rule covering a Bash call: a rule with no specifier covers any
|
|
577
|
+
* command; otherwise EVERY part of the command must be covered by some
|
|
578
|
+
* specific rule (different parts may be covered by different rules), and a
|
|
579
|
+
* command with substitution is never covered. Returns the last rule used.
|
|
580
|
+
* A command that writes engine config (`configWrite`, CF-19) is covered only by
|
|
581
|
+
* specific rules, at least one of which names the file as the command does.
|
|
582
|
+
* @param {PermissionRule[]} allowRules @param {any} input
|
|
583
|
+
* @param {{target:string, word:string}|null} [configWrite]
|
|
584
|
+
* @returns {PermissionRule|undefined}
|
|
585
|
+
*/
|
|
586
|
+
function bashAllowRule(allowRules, input, configWrite = null) {
|
|
587
|
+
const bashRules = allowRules.filter((r) => ruleToolMatches(r.tool, "Bash"));
|
|
588
|
+
const whole = bashRules.find((r) => r.specifier === null || r.specifier === "*");
|
|
589
|
+
if (whole && !configWrite) return whole;
|
|
590
|
+
const specific = bashRules.filter((r) => r.specifier !== null && r.specifier !== "*");
|
|
591
|
+
const command = typeof input?.command === "string" ? input.command.trim() : "";
|
|
592
|
+
const { parts, hasSubstitution } = splitShellCommand(command);
|
|
593
|
+
if (hasSubstitution || parts.length === 0) return undefined;
|
|
594
|
+
let last;
|
|
595
|
+
let namesTarget = false;
|
|
596
|
+
for (const part of parts) {
|
|
597
|
+
last = specific.find((r) => bashSpecMatches(/** @type string */ (r.specifier), part));
|
|
598
|
+
if (!last) return undefined;
|
|
599
|
+
if (configWrite && /** @type string */ (last.specifier).includes(configWrite.word)) namesTarget = true;
|
|
600
|
+
}
|
|
601
|
+
return configWrite && !namesTarget ? undefined : last;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/* ───────────────── CF-19: a shell command that writes engine config ───────────────── */
|
|
605
|
+
|
|
606
|
+
/** Commands whose every operand may be a destination. */
|
|
607
|
+
const COPY_WRITERS = new Set(["tee", "cp", "mv", "install", "ln", "rsync", "truncate", "touch"]);
|
|
608
|
+
/** A redirection and its target: `>f`, `>> f`, `2>f`, `&>f`, `>|f` (the target keeps its quotes). */
|
|
609
|
+
const REDIRECT_RE = /(?:^|[^<>&$\d])\d*(?:&?>>?|>\|)\s*((?:"[^"]*"|'[^']*'|[^\s;&|()<>"'])+)/g;
|
|
610
|
+
|
|
611
|
+
/** Split a simple command into words, removing quotes and backslashes. Pure. @param {string} text */
|
|
612
|
+
function shellWords(text) {
|
|
613
|
+
const out = [];
|
|
614
|
+
let cur = "";
|
|
615
|
+
let quoted = false;
|
|
616
|
+
/** @type {string|null} */
|
|
617
|
+
let q = null;
|
|
618
|
+
for (let i = 0; i < text.length; i++) {
|
|
619
|
+
const ch = text[i];
|
|
620
|
+
if (q) {
|
|
621
|
+
if (ch === q) q = null;
|
|
622
|
+
else if (ch === "\\" && q === '"' && i + 1 < text.length) cur += text[++i];
|
|
623
|
+
else cur += ch;
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
if (ch === "'" || ch === '"') {
|
|
627
|
+
q = ch;
|
|
628
|
+
quoted = true;
|
|
629
|
+
} else if (ch === "\\" && i + 1 < text.length) {
|
|
630
|
+
cur += text[++i];
|
|
631
|
+
} else if (/\s/.test(ch)) {
|
|
632
|
+
if (cur !== "" || quoted) out.push(cur);
|
|
633
|
+
cur = "";
|
|
634
|
+
quoted = false;
|
|
635
|
+
} else cur += ch;
|
|
636
|
+
}
|
|
637
|
+
if (cur !== "" || quoted) out.push(cur);
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Does a Bash command name engine configuration as a place it writes? A
|
|
643
|
+
* conservative pre-check, because a shell command cannot be path-checked: it
|
|
644
|
+
* looks at redirection targets, the operands of `tee`, `cp`, `mv`, `install`,
|
|
645
|
+
* `ln`, `rsync`, `truncate`, `touch`, the files of `sed -i` / `perl -i`,
|
|
646
|
+
* `dd of=`, `curl -o` and `wget -O`, through wrappers (`sudo`, `env A=1`, …),
|
|
647
|
+
* `cd`/`pushd` (the directory later parts resolve against), `sh|bash|zsh -c`
|
|
648
|
+
* and `eval` scripts (to depth 3). `~` and `$HOME` expand to `homedir`; with a
|
|
649
|
+
* resolver, a name is also judged by the path it really leads to. It narrows,
|
|
650
|
+
* it is not a boundary: an interpreter (`python -c`, `node -e`), `git`,
|
|
651
|
+
* `patch`, a substituted or variable path, or a copy made under another name
|
|
652
|
+
* and renamed by a program is not seen. A `cp` FROM config is flagged too.
|
|
653
|
+
* Pure (the resolver is a parameter).
|
|
654
|
+
* @param {unknown} command
|
|
655
|
+
* @param {{cwd:string, homedir?:string|null, configDirs?:string[], resolvePath?:((p:string)=>string|null)|null}} where
|
|
656
|
+
* @param {number} [depth]
|
|
657
|
+
* @returns {{target:string, word:string}|null} the first config path it names, as resolved and as written
|
|
658
|
+
*/
|
|
659
|
+
export function bashEngineConfigWrite(command, { cwd, homedir = null, configDirs = [], resolvePath = null }, depth = 0) {
|
|
660
|
+
const text = typeof command === "string" ? command : "";
|
|
661
|
+
if (text.trim() === "" || depth > 3) return null;
|
|
662
|
+
const realConfigDirs = resolvePath ? configDirs.map((d) => resolvePath(path.resolve(d)) ?? path.resolve(d)) : [];
|
|
663
|
+
const expandHome = (/** @type string */ w) => {
|
|
664
|
+
if (!homedir) return w;
|
|
665
|
+
if (w === "~" || w.startsWith("~/")) return homedir + w.slice(1);
|
|
666
|
+
return w.replace(/^\$\{?HOME\}?(?=\/|$)/, homedir);
|
|
667
|
+
};
|
|
668
|
+
/** @param {string} word @param {string} dir */
|
|
669
|
+
const configTarget = (word, dir) => {
|
|
670
|
+
const w = word.replace(/["']/g, "");
|
|
671
|
+
if (w === "" || w.startsWith("&") || w.startsWith("-")) return null;
|
|
672
|
+
const abs = path.resolve(dir, expandHome(w));
|
|
673
|
+
if (isEngineConfigPath(abs, configDirs)) return { target: abs, word: w };
|
|
674
|
+
const real = resolvePath ? resolvePath(abs) : null;
|
|
675
|
+
return real !== null && isEngineConfigPath(real, realConfigDirs) ? { target: real, word: w } : null;
|
|
676
|
+
};
|
|
677
|
+
let dir = cwd;
|
|
678
|
+
for (const part of splitShellCommand(text).parts) {
|
|
679
|
+
for (const m of part.matchAll(REDIRECT_RE)) {
|
|
680
|
+
const hit = configTarget(m[1], dir);
|
|
681
|
+
if (hit) return hit;
|
|
682
|
+
}
|
|
683
|
+
const words = shellWords(part);
|
|
684
|
+
let i = 0;
|
|
685
|
+
for (let guard = 0; guard < 10 && i < words.length; guard++) {
|
|
686
|
+
while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i])) i++;
|
|
687
|
+
if (i >= words.length || !WRAPPERS.has(normaliseCommandWord(words[i]))) break;
|
|
688
|
+
i++;
|
|
689
|
+
while (i < words.length && (/^-/.test(words[i]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) || /^\d+[smhd]?$/.test(words[i]))) i++;
|
|
690
|
+
}
|
|
691
|
+
if (i >= words.length) continue;
|
|
692
|
+
const cmd = normaliseCommandWord(words[i]);
|
|
693
|
+
const args = words.slice(i + 1).filter((a) => !/^\d*(?:&?>>?|>\||<)/.test(a));
|
|
694
|
+
/** @type {string[]} */
|
|
695
|
+
let candidates = [];
|
|
696
|
+
if (cmd === "cd" || cmd === "pushd") {
|
|
697
|
+
const d = args.find((a) => !a.startsWith("-"));
|
|
698
|
+
if (d) dir = path.resolve(dir, expandHome(d));
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
if (SHELLS.has(cmd) || cmd === "eval") {
|
|
702
|
+
const at = cmd === "eval" ? 0 : args.findIndex((a) => /^-[A-Za-z]*c[A-Za-z]*$/.test(a)) + 1;
|
|
703
|
+
const script = cmd === "eval" ? args.join(" ") : at > 0 ? args[at] : undefined;
|
|
704
|
+
const inner = script === undefined ? null : bashEngineConfigWrite(script, { cwd: dir, homedir, configDirs, resolvePath }, depth + 1);
|
|
705
|
+
if (inner) return inner;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (COPY_WRITERS.has(cmd)) candidates = args.map((a) => (a.startsWith("--target-directory=") ? a.slice("--target-directory=".length) : a));
|
|
709
|
+
else if ((cmd === "sed" && args.some((a) => /^-[A-Za-z]*i/.test(a) || a.startsWith("--in-place"))) || (cmd === "perl" && args.some((a) => /^-[A-Za-z]*i/.test(a)))) candidates = args;
|
|
710
|
+
else if (cmd === "dd") candidates = args.filter((a) => a.startsWith("of=")).map((a) => a.slice(3));
|
|
711
|
+
else if (cmd === "curl" || cmd === "wget") {
|
|
712
|
+
const letter = cmd === "curl" ? "o" : "O";
|
|
713
|
+
const long = cmd === "curl" ? "--output" : "--output-document";
|
|
714
|
+
args.forEach((a, k) => {
|
|
715
|
+
if ((a === long || new RegExp(`^-[A-Za-z]*${letter}$`).test(a)) && args[k + 1] !== undefined) candidates.push(args[k + 1]); // -o f, -so f
|
|
716
|
+
else if (a.startsWith(`${long}=`)) candidates.push(a.slice(long.length + 1));
|
|
717
|
+
else if (a.startsWith(`-${letter}`) && a.length > 2 && !a.startsWith("--")) candidates.push(a.slice(2)); // -of
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
for (const c of candidates) {
|
|
721
|
+
const hit = configTarget(c, dir);
|
|
722
|
+
if (hit) return hit;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Memoise a resolver for one decision, and make it never throw.
|
|
730
|
+
* @param {(p:string)=>string|null} fn
|
|
731
|
+
* @returns {(p:string)=>string|null}
|
|
732
|
+
*/
|
|
733
|
+
function memoResolver(fn) {
|
|
734
|
+
/** @type {Map<string, string|null>} */
|
|
735
|
+
const cache = new Map();
|
|
736
|
+
return (p) => {
|
|
737
|
+
if (!cache.has(p)) {
|
|
738
|
+
let r = null;
|
|
739
|
+
try {
|
|
740
|
+
r = fn(p);
|
|
741
|
+
} catch {
|
|
742
|
+
r = null;
|
|
743
|
+
}
|
|
744
|
+
cache.set(p, typeof r === "string" ? r : null);
|
|
745
|
+
}
|
|
746
|
+
return cache.get(p) ?? null;
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** @param {string} file @param {string[]} dirs */
|
|
751
|
+
function isWithin(file, dirs) {
|
|
752
|
+
return dirs.some((d) => {
|
|
753
|
+
const rel = path.relative(path.resolve(d), file);
|
|
754
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* @param {object} p
|
|
760
|
+
* @param {string} p.toolName
|
|
761
|
+
* @param {any} p.input
|
|
762
|
+
* @param {boolean} p.readOnly the tool cannot change anything
|
|
763
|
+
* @param {boolean} [p.isMcp] MCP tools are never auto-allowed as read-only
|
|
764
|
+
* @param {string} p.mode
|
|
765
|
+
* @param {RuleSet} p.rules
|
|
766
|
+
* @param {string} p.cwd
|
|
767
|
+
* @param {string|null} [p.homedir]
|
|
768
|
+
* @param {string[]} [p.additionalDirectories]
|
|
769
|
+
* @param {string[]} [p.configDirs] extra engine config roots ($CLAUDE_CONFIG_DIR)
|
|
770
|
+
* @param {((p:string)=>string|null)|null} [p.resolvePath] CF-19: where a path really leads
|
|
771
|
+
* (context/real-path.mjs); null judges paths by name only
|
|
772
|
+
* @returns {PermissionDecision}
|
|
773
|
+
*/
|
|
774
|
+
export function evaluatePermission({ toolName, input, readOnly, isMcp = false, mode, rules, cwd, homedir = null, additionalDirectories = [], configDirs = [], resolvePath = null }) {
|
|
775
|
+
const m = isPermissionMode(mode) ? mode : "default";
|
|
776
|
+
const call = { toolName, input };
|
|
777
|
+
const real = resolvePath ? memoResolver(resolvePath) : null;
|
|
778
|
+
const where = { cwd, homedir, resolvePath: real };
|
|
779
|
+
const find = (/** @type {'allow'|'deny'|'ask'} */ kind) => rules[kind].find((r) => ruleMatches(r, call, where, kind));
|
|
780
|
+
|
|
781
|
+
const pos = toolPosture({ toolName, readOnly, isMcp });
|
|
782
|
+
const deny = find("deny");
|
|
783
|
+
if (deny) return { behavior: "deny", reason: `denied by the rule ${deny.raw}${deny.source ? ` (${deny.source})` : ""}`, rule: deny.raw };
|
|
784
|
+
// An MCP server's readOnlyHint is its own claim: plan mode does not rely on it.
|
|
785
|
+
if (m === "plan" && !pos.planSafe) {
|
|
786
|
+
const reason = isMcp ? "plan mode does not run MCP tools" : pos.kind === "mutating" ? "plan mode allows only tools that change nothing" : `plan mode does not run ${toolName}: it ${POSTURE_REASON[pos.kind]}`;
|
|
787
|
+
return { behavior: "deny", reason };
|
|
788
|
+
}
|
|
789
|
+
const ask = find("ask");
|
|
790
|
+
if (ask) return { behavior: "ask", reason: `the rule ${ask.raw}${ask.source ? ` (${ask.source})` : ""} requires approval`, rule: ask.raw };
|
|
791
|
+
const editTarget = EDIT_TOOLS.has(toolName) ? targetPath(toolName, input, cwd) : null;
|
|
792
|
+
// CF-19: an edit is judged by the file it really reaches (a symlink's target) as well as by its name.
|
|
793
|
+
const realEdit = editTarget && real ? real(editTarget) : editTarget;
|
|
794
|
+
const realOf = (/** @type string */ d) => (real ? (real(path.resolve(d)) ?? path.resolve(d)) : d);
|
|
795
|
+
const protectedWrite = Boolean(editTarget && (isEngineConfigPath(editTarget, configDirs) || (realEdit !== null && realEdit !== editTarget && isEngineConfigPath(realEdit, configDirs.map(realOf)))));
|
|
796
|
+
const configWrite = toolName === "Bash" ? bashEngineConfigWrite(input?.command, { cwd, homedir, configDirs, resolvePath: real }) : null;
|
|
797
|
+
const allow = toolName === "Bash" ? bashAllowRule(rules.allow, input, configWrite) : protectedWrite ? rules.allow.find((r) => r.specifier !== null && r.specifier !== "*" && ruleMatches(r, call, where, "allow")) : find("allow");
|
|
798
|
+
if (allow) return { behavior: "allow", reason: `allowed by the rule ${allow.raw}`, rule: allow.raw };
|
|
799
|
+
if (protectedWrite) {
|
|
800
|
+
const via = realEdit && realEdit !== editTarget ? ` (a link to ${realEdit})` : "";
|
|
801
|
+
return { behavior: "ask", reason: `${editTarget}${via} is engine configuration or instructions, which no mode edits without approval` };
|
|
802
|
+
}
|
|
803
|
+
if (configWrite) {
|
|
804
|
+
return {
|
|
805
|
+
behavior: "ask",
|
|
806
|
+
reason: `the command writes to ${configWrite.target}, which is engine configuration or instructions; a shell command cannot be path-checked, so one that names such a file in a redirection, tee, cp, mv, sed -i or the like needs approval in every mode`,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
if (m === "bypassPermissions") return { behavior: "allow", reason: "bypassPermissions mode" };
|
|
810
|
+
const editInScope = editTarget && (real ? realEdit !== null && isWithin(realEdit, [cwd, ...additionalDirectories].map(realOf)) : isWithin(editTarget, [cwd, ...additionalDirectories]));
|
|
811
|
+
if (m === "acceptEdits" && editInScope) {
|
|
812
|
+
return { behavior: "allow", reason: "acceptEdits mode: an edit inside the working directory" };
|
|
813
|
+
}
|
|
814
|
+
if (pos.autoAllow) return { behavior: "allow", reason: pos.kind === "read-only" ? "a read-only tool" : `${toolName} needs no rule: it ${POSTURE_REASON[pos.kind]}` };
|
|
815
|
+
if (m === "dontAsk") return { behavior: "deny", reason: "dontAsk mode denies anything not allowed by a rule" };
|
|
816
|
+
return { behavior: "ask", reason: "no rule allows this tool" };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Why a posture class is judged as it is (reason text for decisions). */
|
|
820
|
+
export const POSTURE_REASON = Object.freeze({
|
|
821
|
+
"session-state": "reads or manages only this run's own state",
|
|
822
|
+
"spawns-clamped": "starts a subagent held to this run's permission mode and rules",
|
|
823
|
+
"spawns-work": "starts work this run's permission checks do not cover",
|
|
824
|
+
outbound: "reaches another session, which acts with its own permissions",
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Is the tool denied outright, whatever its input? Such tools are not offered
|
|
829
|
+
* to the model at all (a rule with no specifier, or a server-level MCP rule).
|
|
830
|
+
* @param {string} toolName @param {RuleSet} rules
|
|
831
|
+
*/
|
|
832
|
+
export function isToolFullyDenied(toolName, rules) {
|
|
833
|
+
return rules.deny.some((r) => (r.specifier === null || r.specifier === "*") && ruleToolMatches(r.tool, toolName));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* The tool_result text for an `ask` the engine cannot put to anyone.
|
|
838
|
+
* @param {string} toolName @param {PermissionDecision} decision
|
|
839
|
+
*/
|
|
840
|
+
export function headlessAskDenial(toolName, decision) {
|
|
841
|
+
return (
|
|
842
|
+
`Permission to use ${toolName} was not granted: ${decision.reason}, and this run cannot ask for approval. ` +
|
|
843
|
+
`It can be allowed with --allowedTools "${toolName}" or a permissions.allow rule in settings. Do not retry the same call; continue without it or explain what is blocked.`
|
|
844
|
+
);
|
|
845
|
+
}
|