@mmerterden/multi-agent-pipeline 20.2.1 → 20.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +919 -1
- package/README.md +104 -81
- package/README.tr.md +103 -62
- package/docs/FIGMA_PIPELINE.md +35 -35
- package/docs/adr/0006-skills-core-external-split.md +1 -1
- package/docs/adr/0007-multi-tool-adapter-framework.md +6 -0
- package/docs/adr/0008-installer-modularization-and-secret-leak-defense.md +7 -0
- package/docs/architecture.md +50 -14
- package/docs/best-practices.md +1 -1
- package/docs/ecosystem.md +56 -32
- package/docs/facts.json +10 -10
- package/docs/features.md +97 -5
- package/docs/recovery-guide.md +7 -14
- package/docs/server-readiness.md +31 -24
- package/index.js +1 -1
- package/install/_common.mjs +3 -5
- package/install/_platform-filter.mjs +23 -1
- package/install/_unattended-profile.mjs +321 -75
- package/install/claude.mjs +51 -10
- package/install/codex.mjs +2 -0
- package/install/copilot.mjs +2 -0
- package/install/index.mjs +30 -17
- package/install/templates/claude-hooks.json +16 -5
- package/install/templates/copilot-instructions.md +1 -1
- package/install/templates/multi-agent-autopilot-awake.plist.template +48 -0
- package/install/templates/multi-agent-autopilot.plist.template +12 -5
- package/install/unattended-profile-legacy.json +80 -0
- package/manifest.json +617 -483
- package/package.json +8 -3
- package/pipeline/agents/code-reviewer.md +10 -0
- package/pipeline/agents/plan-critic.md +98 -0
- package/pipeline/agents/security-auditor.md +10 -0
- package/pipeline/agents/task-clarifier.md +10 -0
- package/pipeline/commands/multi-agent/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/analysis/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +12 -1
- package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/autopilot-off/SKILL.md +36 -6
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +77 -12
- package/pipeline/commands/multi-agent/autopilot-status/SKILL.md +29 -8
- package/pipeline/commands/multi-agent/build-optimize/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/channels/SKILL.md +9 -9
- package/pipeline/commands/multi-agent/complaint-analysis/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/create-jira/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/design-check/SKILL.md +16 -11
- package/pipeline/commands/multi-agent/diff-explain/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/doctor/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/feedback/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/forget/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/graph/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/help/SKILL.md +37 -39
- package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/issue/SKILL.md +7 -1
- package/pipeline/commands/multi-agent/jira/SKILL.md +7 -1
- package/pipeline/commands/multi-agent/kill/SKILL.md +9 -3
- package/pipeline/commands/multi-agent/language/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/log/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/manual-test/SKILL.md +5 -0
- package/pipeline/commands/multi-agent/model/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/prune-logs/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/prune-prompts/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/purge/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/refactor/SKILL.md +5 -0
- package/pipeline/commands/multi-agent/research/SKILL.md +49 -0
- package/pipeline/commands/multi-agent/resume/SKILL.md +29 -6
- package/pipeline/commands/multi-agent/review/SKILL.md +28 -9
- package/pipeline/commands/multi-agent/review-analysis/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/review-issue/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/review-jira/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/route-off/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/route-on/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/route-status/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/routines/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/save/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/scaffold/SKILL.md +47 -0
- package/pipeline/commands/multi-agent/scan/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/search/SKILL.md +12 -8
- package/pipeline/commands/multi-agent/security-review/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/serve/SKILL.md +62 -0
- package/pipeline/commands/multi-agent/setup/SKILL.md +27 -36
- package/pipeline/commands/multi-agent/stack/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/status/SKILL.md +10 -9
- package/pipeline/commands/multi-agent/steer/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/store-ready/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/sync/SKILL.md +13 -13
- package/pipeline/commands/multi-agent/test/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/test-accessibility/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/test-dark-mode/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/test-dynamic-type/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/test-screenshots/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +4 -0
- package/pipeline/commands/multi-agent/uninstall/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/update/SKILL.md +4 -0
- package/pipeline/contract/CHANGELOG.md +74 -0
- package/pipeline/contract/README.md +126 -0
- package/pipeline/contract/build.mjs +427 -0
- package/pipeline/contract/fixtures/answer-result.json +11 -0
- package/pipeline/contract/fixtures/error-invalid-request.json +8 -0
- package/pipeline/contract/fixtures/error-unauthorized.json +5 -0
- package/pipeline/contract/fixtures/error-unsigned.json +5 -0
- package/pipeline/contract/fixtures/issues-empty.json +18 -0
- package/pipeline/contract/fixtures/launch-plan.json +31 -0
- package/pipeline/contract/fixtures/runs-awaiting-question.json +70 -0
- package/pipeline/contract/fixtures/runs-empty.json +6 -0
- package/pipeline/contract/fixtures/runs-failed.json +84 -0
- package/pipeline/contract/fixtures/runs-old-schema.json +70 -0
- package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +101 -0
- package/pipeline/contract/fixtures/runs-pr-opened.json +106 -0
- package/pipeline/contract/fixtures/runs-running.json +84 -0
- package/pipeline/contract/fixtures/worktrees-empty.json +4 -0
- package/pipeline/contract/frozen/toolbox.json +107 -0
- package/pipeline/contract/manifest.json +263 -0
- package/pipeline/contract/types/index.d.ts +343 -0
- package/pipeline/lib/_jira-auth.sh +6 -2
- package/pipeline/lib/account-resolver.sh +1 -1
- package/pipeline/lib/autopilot-state.sh +19 -0
- package/pipeline/lib/context-link-extractor.sh +12 -5
- package/pipeline/lib/credential-inventory.sh +12 -5
- package/pipeline/lib/credential-store.sh +116 -185
- package/pipeline/lib/fetch-confluence.sh +44 -3
- package/pipeline/lib/fetch-document.sh +3 -4
- package/pipeline/lib/fetch-fortify.sh +1 -1
- package/pipeline/lib/figma-mcp-refresh.sh +2 -2
- package/pipeline/lib/figma-token.sh +5 -1
- package/pipeline/lib/issue-fetcher.sh +233 -16
- package/pipeline/lib/json-file-lock.mjs +172 -0
- package/pipeline/lib/model-dispatch.sh +21 -12
- package/pipeline/lib/model-rung.sh +6 -1
- package/pipeline/lib/multi-repo-pipeline.sh +1 -1
- package/pipeline/lib/outbound-gate.mjs +46 -16
- package/pipeline/lib/parse-complaints.sh +14 -7
- package/pipeline/lib/plan-todos.sh +3 -3
- package/pipeline/lib/post-pr-review.sh +9 -9
- package/pipeline/lib/pr-request-location.mjs +85 -0
- package/pipeline/lib/regular-file.mjs +153 -0
- package/pipeline/lib/repo-hygiene.sh +17 -0
- package/pipeline/lib/route-state.sh +5 -1
- package/pipeline/lib/run-paths.sh +3 -2
- package/pipeline/lib/stack-detect.sh +19 -1
- package/pipeline/lib/unattended-profile-check.mjs +178 -0
- package/pipeline/lib/unattended-settings-location.mjs +28 -0
- package/pipeline/lib/unattended.mjs +76 -0
- package/pipeline/lib/unattended.sh +32 -0
- package/pipeline/lib/untrusted.mjs +76 -0
- package/pipeline/lib/usage-endpoint.mjs +33 -0
- package/pipeline/lib/user-facing.mjs +82 -0
- package/pipeline/lib/user-facing.sh +58 -0
- package/pipeline/multi-agent-refs/_dev-context.md +1 -1
- package/pipeline/multi-agent-refs/analysis/evidence.md +4 -0
- package/pipeline/multi-agent-refs/analysis/locked.md +2 -2
- package/pipeline/multi-agent-refs/analysis/render.md +4 -3
- package/pipeline/multi-agent-refs/analysis/resolve.md +3 -1
- package/pipeline/multi-agent-refs/analysis/synthesis.md +4 -5
- package/pipeline/multi-agent-refs/analysis-template.md +10 -17
- package/pipeline/multi-agent-refs/channels/jira.md +1 -1
- package/pipeline/multi-agent-refs/component-dispatch.md +3 -3
- package/pipeline/multi-agent-refs/conventions-defaults.md +32 -32
- package/pipeline/multi-agent-refs/cross-cli-contract.md +10 -17
- package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +37 -9
- package/pipeline/multi-agent-refs/features/autopilot-operations.md +277 -0
- package/pipeline/multi-agent-refs/features/constitution.md +196 -0
- package/pipeline/multi-agent-refs/features/doctor.md +6 -3
- package/pipeline/multi-agent-refs/features/external-context-injection.md +6 -1
- package/pipeline/multi-agent-refs/features/jira-context.md +1 -1
- package/pipeline/multi-agent-refs/features/maturity-followup.md +43 -23
- package/pipeline/multi-agent-refs/features/model-fallback.md +9 -0
- package/pipeline/multi-agent-refs/features/phone-api.md +306 -0
- package/pipeline/multi-agent-refs/features/plan-critic.md +159 -0
- package/pipeline/multi-agent-refs/features/research.md +150 -0
- package/pipeline/multi-agent-refs/features/review-decision.md +185 -0
- package/pipeline/multi-agent-refs/features/scaffold.md +160 -0
- package/pipeline/multi-agent-refs/features/security-audit.md +6 -2
- package/pipeline/multi-agent-refs/features/skill-conformance.md +4 -1
- package/pipeline/multi-agent-refs/features/stack-adapters.md +116 -0
- package/pipeline/multi-agent-refs/features/unattended-gates.md +316 -0
- package/pipeline/multi-agent-refs/features/unattended-security.md +731 -0
- package/pipeline/multi-agent-refs/features/url-enrichment.md +2 -2
- package/pipeline/multi-agent-refs/features/usage-reporting.md +127 -35
- package/pipeline/multi-agent-refs/features/verify-by-test.md +1 -1
- package/pipeline/multi-agent-refs/features/visual-evidence.md +7 -7
- package/pipeline/multi-agent-refs/keychain.md +6 -11
- package/pipeline/multi-agent-refs/payload-contracts.md +2 -2
- package/pipeline/multi-agent-refs/phases/log-format.md +2 -3
- package/pipeline/multi-agent-refs/phases/modes.md +10 -12
- package/pipeline/multi-agent-refs/phases/operations.md +11 -5
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +73 -81
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +22 -22
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +42 -66
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +45 -79
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +28 -38
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +27 -25
- package/pipeline/multi-agent-refs/phases.md +1 -1
- package/pipeline/multi-agent-refs/picker-contract.md +1 -1
- package/pipeline/multi-agent-refs/progress-contract.md +13 -16
- package/pipeline/multi-agent-refs/readiness-review.md +1 -1
- package/pipeline/multi-agent-refs/research/engine.md +91 -0
- package/pipeline/multi-agent-refs/rules.md +6 -4
- package/pipeline/multi-agent-refs/setup/repo-discovery.md +2 -2
- package/pipeline/multi-agent-refs/tracker-contract.md +2 -2
- package/pipeline/multi-agent-refs/unattended-contract.md +68 -16
- package/pipeline/rules/figma-pipeline.md +12 -12
- package/pipeline/schemas/agent-state.schema.json +480 -18
- package/pipeline/schemas/analysis-spec.schema.json +4 -4
- package/pipeline/schemas/answer-request.schema.json +24 -0
- package/pipeline/schemas/answer-result.schema.json +28 -0
- package/pipeline/schemas/autopilot-config.schema.json +111 -13
- package/pipeline/schemas/command-parameters.schema.json +99 -0
- package/pipeline/schemas/constitution.schema.json +56 -0
- package/pipeline/schemas/contract-error.schema.json +52 -0
- package/pipeline/schemas/design-check-config.schema.json +5 -1
- package/pipeline/schemas/issues.schema.json +61 -0
- package/pipeline/schemas/launch-plan.schema.json +46 -0
- package/pipeline/schemas/launch-request.schema.json +45 -0
- package/pipeline/schemas/launch.json +61 -0
- package/pipeline/schemas/launch.schema.json +84 -0
- package/pipeline/schemas/phases.json +2 -2
- package/pipeline/schemas/phases.schema.json +68 -0
- package/pipeline/schemas/phone-devices.schema.json +61 -0
- package/pipeline/schemas/phone-signed-request.schema.json +67 -0
- package/pipeline/schemas/plan-critique.schema.json +99 -0
- package/pipeline/schemas/plan-todos.schema.json +7 -7
- package/pipeline/schemas/planning-output.schema.json +5 -0
- package/pipeline/schemas/pr-request.schema.json +46 -0
- package/pipeline/schemas/prefs.schema.json +79 -8
- package/pipeline/schemas/research-output.schema.json +118 -0
- package/pipeline/schemas/review-file-exclusions.schema.json +25 -0
- package/pipeline/schemas/reviewer-output.schema.json +40 -4
- package/pipeline/schemas/run-questions.json +392 -0
- package/pipeline/schemas/run-questions.schema.json +118 -0
- package/pipeline/schemas/runs-index.schema.json +189 -0
- package/pipeline/schemas/scaffold-manifest.schema.json +43 -0
- package/pipeline/schemas/secret-patterns.schema.json +28 -0
- package/pipeline/schemas/stack-adapters.json +527 -0
- package/pipeline/schemas/stack-adapters.schema.json +184 -0
- package/pipeline/schemas/token-budget.json +1 -1
- package/pipeline/schemas/token-budget.schema.json +26 -0
- package/pipeline/schemas/triage-output.schema.json +64 -3
- package/pipeline/schemas/unattended-policy.json +139 -0
- package/pipeline/schemas/unattended-policy.schema.json +73 -0
- package/pipeline/schemas/unattended-profile.json +248 -0
- package/pipeline/schemas/unattended-profile.schema.json +198 -0
- package/pipeline/schemas/worktrees.schema.json +51 -0
- package/pipeline/scripts/README.md +1 -0
- package/pipeline/scripts/_autopilot-config.mjs +130 -0
- package/pipeline/scripts/_autopilot-ops.mjs +567 -0
- package/pipeline/scripts/_autopilot-outcomes.mjs +174 -0
- package/pipeline/scripts/_command-contract.mjs +384 -0
- package/pipeline/scripts/_cost.mjs +40 -0
- package/pipeline/scripts/_notices.mjs +160 -0
- package/pipeline/scripts/_phone-auth.mjs +485 -0
- package/pipeline/scripts/_pre-existing.mjs +294 -0
- package/pipeline/scripts/_redact.mjs +77 -0
- package/pipeline/scripts/_run-paths.mjs +4 -2
- package/pipeline/scripts/_stack-adapter.mjs +678 -0
- package/pipeline/scripts/_stack-routing.mjs +1 -1
- package/pipeline/scripts/agent-guard.py +348 -37
- package/pipeline/scripts/agent-guard.sh +41 -13
- package/pipeline/scripts/analysis-story-tree.mjs +79 -3
- package/pipeline/scripts/answer-question.mjs +181 -0
- package/pipeline/scripts/audit-log-rotate.sh +1 -4
- package/pipeline/scripts/audit-log.sh +4 -4
- package/pipeline/scripts/autopilot-arming.mjs +389 -21
- package/pipeline/scripts/autopilot-awake.mjs +255 -0
- package/pipeline/scripts/autopilot-intake.mjs +137 -36
- package/pipeline/scripts/autopilot-menubar.swift +156 -44
- package/pipeline/scripts/autopilot-publish.mjs +1625 -0
- package/pipeline/scripts/autopilot-runner.mjs +1678 -222
- package/pipeline/scripts/autopilot-status.sh +198 -33
- package/pipeline/scripts/build-lock.sh +120 -0
- package/pipeline/scripts/build-references.mjs +4 -1
- package/pipeline/scripts/build-stack-plugins.mjs +59 -22
- package/pipeline/scripts/capture-flush.sh +1 -1
- package/pipeline/scripts/capture-resume.sh +13 -9
- package/pipeline/scripts/check-derived-drift.mjs +52 -11
- package/pipeline/scripts/commands.mjs +88 -0
- package/pipeline/scripts/constitution.mjs +362 -0
- package/pipeline/scripts/contract-server.mjs +776 -0
- package/pipeline/scripts/cost-analyze.mjs +89 -39
- package/pipeline/scripts/diff-explain.mjs +12 -1
- package/pipeline/scripts/doctor.mjs +77 -28
- package/pipeline/scripts/evidence-gate.mjs +192 -12
- package/pipeline/scripts/feedback-send.mjs +4 -2
- package/pipeline/scripts/gate-ledger.mjs +449 -0
- package/pipeline/scripts/gc-abandoned.sh +132 -13
- package/pipeline/scripts/gen-facts.mjs +31 -15
- package/pipeline/scripts/gen-mode-dispatch.mjs +3 -3
- package/pipeline/scripts/github-ssh-setup.sh +140 -29
- package/pipeline/scripts/graph-mermaid.mjs +4 -1
- package/pipeline/scripts/issues.mjs +236 -0
- package/pipeline/scripts/jira-attach.sh +6 -2
- package/pipeline/scripts/jira-search.sh +4 -3
- package/pipeline/scripts/keychain-save.sh +125 -24
- package/pipeline/scripts/keychain.py +63 -93
- package/pipeline/scripts/launch-request.mjs +747 -0
- package/pipeline/scripts/localize-commands.mjs +4 -10
- package/pipeline/scripts/log-metric.sh +6 -5
- package/pipeline/scripts/maturity-followup.mjs +13 -4
- package/pipeline/scripts/memory-save.sh +25 -0
- package/pipeline/scripts/migrate-prefs.mjs +4 -3
- package/pipeline/scripts/open-questions-gate.mjs +276 -0
- package/pipeline/scripts/phase-tracker.sh +41 -27
- package/pipeline/scripts/phase0-exit-gate.mjs +22 -4
- package/pipeline/scripts/phone-devices.mjs +224 -0
- package/pipeline/scripts/plan-coverage-gate.mjs +200 -66
- package/pipeline/scripts/plan-critique-gate.mjs +591 -0
- package/pipeline/scripts/pr-request.mjs +188 -0
- package/pipeline/scripts/pre-commit-check.sh +115 -4
- package/pipeline/scripts/probe-evidence-capability.sh +44 -5
- package/pipeline/scripts/record-phase.mjs +71 -0
- package/pipeline/scripts/render-agent-log-cost.sh +17 -2
- package/pipeline/scripts/render-cost-summary.sh +1 -1
- package/pipeline/scripts/render-work-summary.sh +1 -1
- package/pipeline/scripts/require-supported-version.sh +4 -1
- package/pipeline/scripts/research-gate.mjs +704 -0
- package/pipeline/scripts/review-decision-gate.mjs +403 -0
- package/pipeline/scripts/routine-registry.mjs +5 -2
- package/pipeline/scripts/runs-index.mjs +135 -27
- package/pipeline/scripts/scaffold-gate.mjs +393 -0
- package/pipeline/scripts/skill-conformance.mjs +25 -8
- package/pipeline/scripts/skill-siblings.mjs +2 -1
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +32 -6
- package/pipeline/scripts/smoke-schema-validation.sh +6 -2
- package/pipeline/scripts/spec-consistency-gate.mjs +469 -0
- package/pipeline/scripts/symbol-existence-gate.mjs +450 -0
- package/pipeline/scripts/test-gap-scan.mjs +40 -2
- package/pipeline/scripts/test-integrity-gate.mjs +20 -4
- package/pipeline/scripts/test-strength.mjs +484 -0
- package/pipeline/scripts/test-summary.mjs +651 -0
- package/pipeline/scripts/triage-memory.mjs +49 -9
- package/pipeline/scripts/unattended_policy.py +2786 -0
- package/pipeline/scripts/uninstall.mjs +10 -10
- package/pipeline/scripts/update-issue-progress.sh +1 -1
- package/pipeline/scripts/usage-identity.mjs +288 -0
- package/pipeline/scripts/usage-register.mjs +187 -67
- package/pipeline/scripts/usage-report.mjs +232 -67
- package/pipeline/scripts/validate-complaint-doc.mjs +28 -10
- package/pipeline/scripts/validate-planning.mjs +6 -0
- package/pipeline/scripts/verify-citations.mjs +151 -38
- package/pipeline/scripts/verify.mjs +58 -18
- package/pipeline/scripts/worktree-prepare.sh +126 -0
- package/pipeline/scripts/worktrees.mjs +124 -0
- package/pipeline/scripts/write-state.mjs +48 -17
- package/pipeline/skills/.skill-manifest.json +222 -226
- package/pipeline/skills/.skills-index.json +77 -88
- package/pipeline/skills/shared/README.md +44 -45
- package/pipeline/skills/shared/core/apple-archive-compliance/SKILL.md +3 -2
- package/pipeline/skills/shared/core/google-play-compliance/SKILL.md +3 -2
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +10 -9
- package/pipeline/skills/shared/core/multi-agent-analysis/SKILL.md +6 -5
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +11 -3
- package/pipeline/skills/shared/core/multi-agent-analysis-resolve/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-autopilot-off/SKILL.md +32 -6
- package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +66 -8
- package/pipeline/skills/shared/core/multi-agent-autopilot-status/SKILL.md +27 -9
- package/pipeline/skills/shared/core/multi-agent-build-optimize/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-channels/SKILL.md +7 -4
- package/pipeline/skills/shared/core/multi-agent-complaint-analysis/SKILL.md +3 -2
- package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-design-check/SKILL.md +7 -5
- package/pipeline/skills/shared/core/multi-agent-diff-explain/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-doctor/SKILL.md +3 -2
- package/pipeline/skills/shared/core/multi-agent-feedback/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-forget/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-garbage-collect/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-graph/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-help/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-issue/SKILL.md +3 -2
- package/pipeline/skills/shared/core/multi-agent-jira/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-kill/SKILL.md +7 -4
- package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-log/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-manual-test/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-model/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-prune-logs/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-prune-prompts/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-purge/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-refactor/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-research/SKILL.md +35 -0
- package/pipeline/skills/shared/core/multi-agent-resume/SKILL.md +7 -3
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +7 -5
- package/pipeline/skills/shared/core/multi-agent-review-analysis/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-review-issue/SKILL.md +3 -2
- package/pipeline/skills/shared/core/multi-agent-review-jira/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-route-off/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-route-on/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-route-status/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-routines/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-save/SKILL.md +3 -2
- package/pipeline/skills/shared/core/multi-agent-scaffold/SKILL.md +30 -0
- package/pipeline/skills/shared/core/multi-agent-scan/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-search/SKILL.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-security-review/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +60 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +8 -8
- package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-status/SKILL.md +7 -8
- package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-store-ready/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +10 -10
- package/pipeline/skills/shared/core/multi-agent-test/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-test-accessibility/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-test-dark-mode/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-test-dynamic-type/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-test-screenshots/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-uninstall/SKILL.md +3 -2
- package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +2 -1
- package/pipeline/skills/shared/external/NOTICE-avdlee-swiftui-agent-skill.md +46 -0
- package/pipeline/skills/shared/external/NOTICE-dimillian-skills.md +9 -3
- package/pipeline/skills/shared/external/NOTICE-paul-hudson-skills.md +54 -0
- package/pipeline/skills/shared/external/NOTICE-swift-ios-skills.md +9 -11
- package/pipeline/skills/shared/external/NOTICE-vibeship-spawner-skills.md +203 -0
- package/pipeline/skills/shared/external/NOTICE-xcode-build-skills.md +10 -3
- package/pipeline/skills/shared/external/accessibility-compliance-accessibility-audit/SKILL.md +131 -25
- package/pipeline/skills/shared/external/agent-introspection-debugging/SKILL.md +4 -3
- package/pipeline/skills/shared/external/alarmkit/SKILL.md +2 -1
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +85 -76
- package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +51 -3
- package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +9 -0
- package/pipeline/skills/shared/external/android-datastore/SKILL.md +4 -3
- package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +4 -3
- package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +106 -181
- package/pipeline/skills/shared/external/android-jetpack-compose-expert/references/patterns.md +147 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +45 -4
- package/pipeline/skills/shared/external/android-performance/SKILL.md +27 -2
- package/pipeline/skills/shared/external/android-performance/references/patterns.md +37 -37
- package/pipeline/skills/shared/external/api-patterns/SKILL.md +111 -67
- package/pipeline/skills/shared/external/api-patterns/references/contract-details.md +128 -0
- package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +136 -186
- package/pipeline/skills/shared/external/api-security-best-practices/references/abuse-controls.md +140 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/identity-and-access.md +207 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/operations.md +66 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/request-handling.md +175 -0
- package/pipeline/skills/shared/external/app-clips/SKILL.md +2 -0
- package/pipeline/skills/shared/external/app-intents/SKILL.md +2 -0
- package/pipeline/skills/shared/external/app-store-changelog/SKILL.md +4 -3
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +2 -0
- package/pipeline/skills/shared/external/app-store-review/SKILL.md +2 -0
- package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +2 -0
- package/pipeline/skills/shared/external/architecture/SKILL.md +106 -39
- package/pipeline/skills/shared/external/architecture/references/adr-and-review.md +76 -0
- package/pipeline/skills/shared/external/authentication/SKILL.md +2 -0
- package/pipeline/skills/shared/external/avkit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/background-processing/SKILL.md +2 -1
- package/pipeline/skills/shared/external/backlog/SKILL.md +3 -2
- package/pipeline/skills/shared/external/callkit-voip/SKILL.md +2 -0
- package/pipeline/skills/shared/external/callkit-voip/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/ci-cd-pipelines/SKILL.md +3 -2
- package/pipeline/skills/shared/external/clean-code/SKILL.md +192 -90
- package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +2 -1
- package/pipeline/skills/shared/external/cloudkit-sync/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/compose-components/SKILL.md +4 -4
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +22 -22
- package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +10 -10
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +13 -6
- package/pipeline/skills/shared/external/compose-testing/references/patterns.md +59 -59
- package/pipeline/skills/shared/external/contacts-framework/SKILL.md +2 -0
- package/pipeline/skills/shared/external/context-compression/SKILL.md +118 -250
- package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +2 -0
- package/pipeline/skills/shared/external/core-data/SKILL.md +2 -0
- package/pipeline/skills/shared/external/core-motion/SKILL.md +2 -0
- package/pipeline/skills/shared/external/core-nfc/SKILL.md +2 -0
- package/pipeline/skills/shared/external/coreml/SKILL.md +2 -1
- package/pipeline/skills/shared/external/council/SKILL.md +2 -1
- package/pipeline/skills/shared/external/cryptokit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/css-modern/SKILL.md +3 -2
- package/pipeline/skills/shared/external/database-patterns/SKILL.md +3 -2
- package/pipeline/skills/shared/external/debugging-instruments/SKILL.md +2 -0
- package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +130 -20
- package/pipeline/skills/shared/external/debugging-strategies/references/hard-cases.md +54 -0
- package/pipeline/skills/shared/external/device-integrity/SKILL.md +2 -0
- package/pipeline/skills/shared/external/docker-expert/SKILL.md +137 -381
- package/pipeline/skills/shared/external/docker-expert/references/patterns.md +98 -0
- package/pipeline/skills/shared/external/energykit/SKILL.md +2 -1
- package/pipeline/skills/shared/external/eventkit-calendar/SKILL.md +3 -1
- package/pipeline/skills/shared/external/eventkit-calendar/evals/evals.json +2 -2
- package/pipeline/skills/shared/external/fastapi-pro/SKILL.md +116 -185
- package/pipeline/skills/shared/external/fastapi-pro/references/app-structure.md +235 -0
- package/pipeline/skills/shared/external/fastapi-pro/references/testing-and-deployment.md +69 -0
- package/pipeline/skills/shared/external/firebase/SKILL.md +4 -3
- package/pipeline/skills/shared/external/github-actions-templates/SKILL.md +151 -293
- package/pipeline/skills/shared/external/github-actions-templates/references/patterns.md +96 -0
- package/pipeline/skills/shared/external/healthkit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/hig-components-content/SKILL.md +69 -72
- package/pipeline/skills/shared/external/hig-components-content/references/content-views.md +111 -0
- package/pipeline/skills/shared/external/hig-components-layout/SKILL.md +77 -86
- package/pipeline/skills/shared/external/hig-components-layout/references/containers.md +98 -0
- package/pipeline/skills/shared/external/hig-components-status/SKILL.md +150 -78
- package/pipeline/skills/shared/external/hig-components-system/SKILL.md +76 -97
- package/pipeline/skills/shared/external/hig-components-system/references/surfaces.md +98 -0
- package/pipeline/skills/shared/external/hig-foundations/SKILL.md +48 -76
- package/pipeline/skills/shared/external/hig-foundations/references/foundations-detail.md +132 -0
- package/pipeline/skills/shared/external/hig-inputs/SKILL.md +147 -106
- package/pipeline/skills/shared/external/hig-patterns/SKILL.md +61 -77
- package/pipeline/skills/shared/external/hig-patterns/references/patterns.md +92 -0
- package/pipeline/skills/shared/external/hig-platforms/SKILL.md +147 -77
- package/pipeline/skills/shared/external/hig-technologies/SKILL.md +61 -121
- package/pipeline/skills/shared/external/hig-technologies/references/technologies.md +108 -0
- package/pipeline/skills/shared/external/homekit-matter/SKILL.md +2 -0
- package/pipeline/skills/shared/external/homekit-matter/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/html-semantic/SKILL.md +3 -2
- package/pipeline/skills/shared/external/humanizer/SKILL.md +2 -1
- package/pipeline/skills/shared/external/ios-accessibility/SKILL.md +2 -0
- package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +2 -1
- package/pipeline/skills/shared/external/ios-coding-standard/references/STANDARD.md +14 -2
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
- package/pipeline/skills/shared/external/ios-debugger-agent/SKILL.md +4 -3
- package/pipeline/skills/shared/external/ios-localization/SKILL.md +8 -6
- package/pipeline/skills/shared/external/ios-localization/evals/evals.json +2 -2
- package/pipeline/skills/shared/external/ios-localization/references/string-catalogs.md +11 -11
- package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +2 -1
- package/pipeline/skills/shared/external/ios-networking/SKILL.md +2 -0
- package/pipeline/skills/shared/external/ios-simulator/SKILL.md +2 -0
- package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +102 -193
- package/pipeline/skills/shared/external/kotlin-coroutines-expert/references/patterns.md +103 -0
- package/pipeline/skills/shared/external/live-activities/SKILL.md +4 -2
- package/pipeline/skills/shared/external/live-activities/evals/evals.json +2 -2
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +7 -9
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +3 -3
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +2 -2
- package/pipeline/skills/shared/external/macos-menubar-tuist-app/SKILL.md +4 -3
- package/pipeline/skills/shared/external/macos-spm-app-packaging/SKILL.md +5 -3
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +2 -0
- package/pipeline/skills/shared/external/mapkit-location/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/metrickit-diagnostics/SKILL.md +2 -0
- package/pipeline/skills/shared/external/metrickit-diagnostics/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/monorepo-architect/SKILL.md +149 -46
- package/pipeline/skills/shared/external/monorepo-architect/references/patterns.md +70 -0
- package/pipeline/skills/shared/external/musickit-audio/SKILL.md +2 -0
- package/pipeline/skills/shared/external/musickit-audio/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/natural-language/SKILL.md +2 -0
- package/pipeline/skills/shared/external/nextjs-app-router/SKILL.md +3 -2
- package/pipeline/skills/shared/external/nodejs-backend-patterns/SKILL.md +91 -21
- package/pipeline/skills/shared/external/nodejs-backend-patterns/references/runtime-patterns.md +247 -0
- package/pipeline/skills/shared/external/nodejs-backend-patterns/references/testing-and-frameworks.md +57 -0
- package/pipeline/skills/shared/external/observability-engineer/SKILL.md +155 -232
- package/pipeline/skills/shared/external/observability-engineer/references/patterns.md +75 -0
- package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +2 -0
- package/pipeline/skills/shared/external/passkit-wallet/evals/evals.json +2 -2
- package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +18 -18
- package/pipeline/skills/shared/external/pdfkit/SKILL.md +2 -1
- package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -0
- package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/permissionkit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/photos-camera-media/SKILL.md +2 -1
- package/pipeline/skills/shared/external/push-notifications/SKILL.md +2 -0
- package/pipeline/skills/shared/external/python-patterns/SKILL.md +3 -2
- package/pipeline/skills/shared/external/react-best-practices/SKILL.md +3 -2
- package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +2 -2
- package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/rest-api-design/SKILL.md +3 -2
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +7 -7
- package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +69 -69
- package/pipeline/skills/shared/external/room-database/references/patterns.md +126 -126
- package/pipeline/skills/shared/external/search-first/SKILL.md +2 -1
- package/pipeline/skills/shared/external/security-review/SKILL.md +4 -3
- package/pipeline/skills/shared/external/shareplay-activities/SKILL.md +2 -2
- package/pipeline/skills/shared/external/signal-community/SKILL.md +1 -1
- package/pipeline/skills/shared/external/skill-creator/SKILL.md +2 -1
- package/pipeline/skills/shared/external/speech-recognition/SKILL.md +2 -1
- package/pipeline/skills/shared/external/spm-build-analysis/SKILL.md +2 -0
- package/pipeline/skills/shared/external/storekit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swift-api-design-guidelines/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swift-architecture/SKILL.md +2 -1
- package/pipeline/skills/shared/external/swift-charts/SKILL.md +2 -1
- package/pipeline/skills/shared/external/swift-codable/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +3 -1
- package/pipeline/skills/shared/external/swift-concurrency-expert/SKILL.md +5 -4
- package/pipeline/skills/shared/external/swift-concurrency-pro/SKILL.md +2 -1
- package/pipeline/skills/shared/external/swift-formatstyle/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swift-language/SKILL.md +3 -1
- package/pipeline/skills/shared/external/swift-security/SKILL.md +2 -1
- package/pipeline/skills/shared/external/swift-testing/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swift-testing-pro/SKILL.md +2 -1
- package/pipeline/skills/shared/external/swift-testing-pro/references/new-features.md +10 -0
- package/pipeline/skills/shared/external/swiftdata/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swiftdata-pro/SKILL.md +2 -1
- package/pipeline/skills/shared/external/swiftlint/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swiftui-animation/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swiftui-expert-skill/SKILL.md +3 -1
- package/pipeline/skills/shared/external/swiftui-gestures/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swiftui-layout-components/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swiftui-liquid-glass/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swiftui-patterns/SKILL.md +3 -1
- package/pipeline/skills/shared/external/swiftui-performance/SKILL.md +3 -1
- package/pipeline/skills/shared/external/swiftui-performance-audit/SKILL.md +5 -4
- package/pipeline/skills/shared/external/swiftui-pro/SKILL.md +2 -1
- package/pipeline/skills/shared/external/swiftui-ui-patterns/SKILL.md +5 -4
- package/pipeline/skills/shared/external/swiftui-uikit-interop/SKILL.md +2 -0
- package/pipeline/skills/shared/external/swiftui-view-refactor/SKILL.md +4 -3
- package/pipeline/skills/shared/external/swiftui-webkit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/tailwind-css/SKILL.md +3 -2
- package/pipeline/skills/shared/external/testing-backend/SKILL.md +3 -2
- package/pipeline/skills/shared/external/tipkit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/typescript-patterns/SKILL.md +3 -2
- package/pipeline/skills/shared/external/vision-framework/SKILL.md +2 -0
- package/pipeline/skills/shared/external/vue-composition/SKILL.md +3 -2
- package/pipeline/skills/shared/external/weatherkit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/web-accessibility/SKILL.md +3 -2
- package/pipeline/skills/shared/external/web-performance/SKILL.md +3 -2
- package/pipeline/skills/shared/external/web-testing/SKILL.md +3 -2
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +2 -0
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +1 -1
- package/pipeline/skills/shared/external/xcode-build-benchmark/SKILL.md +2 -0
- package/pipeline/skills/shared/external/xcode-build-fixer/SKILL.md +2 -0
- package/pipeline/skills/shared/external/xcode-build-orchestrator/SKILL.md +2 -0
- package/pipeline/skills/shared/external/xcode-compilation-analyzer/SKILL.md +2 -0
- package/pipeline/skills/shared/external/xcode-project-analyzer/SKILL.md +2 -0
- package/pipeline/skills/skills-index.md +40 -41
- package/docs/token-budget-history.md +0 -24
- package/pipeline/skills/shared/external/agentflow/SKILL.md +0 -199
- package/pipeline/skills/shared/external/android-ui-verification/SKILL.md +0 -66
- package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +0 -299
- package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +0 -255
- package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +0 -167
- package/pipeline/skills/shared/external/closed-loop-delivery/SKILL.md +0 -116
- package/pipeline/skills/shared/external/ios-developer/SKILL.md +0 -216
|
@@ -0,0 +1,2786 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Unattended policy for agent-guard.py: what a run nobody is watching may not do.
|
|
3
|
+
|
|
4
|
+
Consulted only when MULTI_AGENT_UNATTENDED=1 is in the hook's own environment,
|
|
5
|
+
which the autopilot runner sets on the `claude` process it spawns. It is never
|
|
6
|
+
read from agent-state.json: the run writes that file, and a rule the run can
|
|
7
|
+
switch off is not a rule. With the variable unset this module is not imported.
|
|
8
|
+
|
|
9
|
+
The model is refuse-what-it-cannot-parse. A Bash command is accepted only when
|
|
10
|
+
it reduces to simple commands joined by `;`, `&&`, `||` and plain pipes between
|
|
11
|
+
non-interpreter commands. Anything that hides a command from a line reader -
|
|
12
|
+
a subshell, a group, a substitution, a heredoc, a background job, a shell
|
|
13
|
+
keyword, a pipe into an interpreter, a `-c` on a shell - is refused outright,
|
|
14
|
+
because the guard reads the command line and cannot judge what it cannot parse.
|
|
15
|
+
The command families it can parse (git, gh, curl, installs, writes) are then
|
|
16
|
+
checked against the lists in ../schemas/unattended-policy.json.
|
|
17
|
+
|
|
18
|
+
Every check returns None (allowed) or a short reason string (blocked). Nothing
|
|
19
|
+
here executes the inspected command; the only processes it starts are read-only
|
|
20
|
+
`git` reads, memoized per invocation. Contract:
|
|
21
|
+
multi-agent-refs/features/unattended-security.md.
|
|
22
|
+
"""
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shlex
|
|
27
|
+
import stat
|
|
28
|
+
from urllib.parse import urlsplit
|
|
29
|
+
|
|
30
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
31
|
+
POLICY_CANDIDATES = [
|
|
32
|
+
os.path.join(HERE, "..", "schemas", "unattended-policy.json"),
|
|
33
|
+
os.path.join(HERE, "..", "pipeline", "schemas", "unattended-policy.json"),
|
|
34
|
+
]
|
|
35
|
+
PR_REQUESTS_SUBDIR = "pr-requests"
|
|
36
|
+
PR_VERDICT_SUFFIX = ".verdict.json"
|
|
37
|
+
UNATTENDED_RUN_DIR = ".multi-agent-unattended"
|
|
38
|
+
|
|
39
|
+
DARWIN = os.uname().sysname == "Darwin" if hasattr(os, "uname") else False
|
|
40
|
+
MAX_SEGMENTS = 50
|
|
41
|
+
|
|
42
|
+
# --- shell structure the guard refuses to reason about -----------------------
|
|
43
|
+
|
|
44
|
+
# Command position tokens that are not simple commands.
|
|
45
|
+
SHELL_KEYWORDS = {
|
|
46
|
+
"if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done",
|
|
47
|
+
"case", "esac", "function", "select", "eval", "exec", "source", ".",
|
|
48
|
+
"export", "trap", "set", "unset", "alias", "unalias", "declare", "typeset",
|
|
49
|
+
"readonly", "let", "return", "coproc", "time",
|
|
50
|
+
}
|
|
51
|
+
SHELLS = {"sh", "bash", "zsh", "dash", "ksh", "fish"}
|
|
52
|
+
INTERPRETERS = SHELLS | {"node", "perl", "ruby", "osascript", "php", "lua", "tclsh", "wish", "rscript"}
|
|
53
|
+
# Wrappers that run the rest of the line as a command; their option values are
|
|
54
|
+
# consumed so the wrapped command is what gets judged.
|
|
55
|
+
WRAPPER_VALUE_OPTS = {
|
|
56
|
+
"env": {"-u", "-P", "--unset", "--block-signal", "--default-signal", "--ignore-signal"},
|
|
57
|
+
"nice": {"-n", "--adjustment"},
|
|
58
|
+
"timeout": {"-s", "--signal", "-k", "--kill-after"},
|
|
59
|
+
"gtimeout": {"-s", "--signal", "-k", "--kill-after"},
|
|
60
|
+
"ionice": {"-c", "-n", "-p"},
|
|
61
|
+
"nohup": set(),
|
|
62
|
+
"caffeinate": set(),
|
|
63
|
+
"command": set(),
|
|
64
|
+
"builtin": set(),
|
|
65
|
+
"stdbuf": {"-i", "-o", "-e"},
|
|
66
|
+
"xcrun": {"-sdk", "--sdk", "-toolchain", "--toolchain"},
|
|
67
|
+
"arch": {"-arch", "-d"},
|
|
68
|
+
"sandbox-exec": {"-f", "-n", "-p", "-D"},
|
|
69
|
+
}
|
|
70
|
+
# Wrappers whose command line the guard does not model: `script` takes an
|
|
71
|
+
# output file before the command, `watch` hands its arguments to `sh -c`, a
|
|
72
|
+
# debugger runs its target under its own control (and takes commands that spawn
|
|
73
|
+
# a shell), and a job runner (parallel, xargs) or a locker (flock) hands the
|
|
74
|
+
# rest to a shell or repeats it per input. `chroot` and `unbuffer` and the
|
|
75
|
+
# traces run their target too. Each is refused rather than parsed.
|
|
76
|
+
OPAQUE_WRAPPERS = {
|
|
77
|
+
"script", "watch",
|
|
78
|
+
"lldb", "gdb", "lldb-mi",
|
|
79
|
+
"parallel", "flock", "unbuffer", "chroot", "dtruss", "dtrace", "ktrace",
|
|
80
|
+
"strace", "ltrace", "rlwrap", "expect",
|
|
81
|
+
"automator", "shortcuts", "osacompile",
|
|
82
|
+
}
|
|
83
|
+
ENV_REFUSED_OPTS = ("-S", "--split-string", "-C", "--chdir")
|
|
84
|
+
XCRUN_LOOKUPS = ("-f", "--find", "--show-sdk-path", "--show-sdk-version", "--show-sdk-build-version",
|
|
85
|
+
"--show-sdk-platform-path", "--show-sdk-platform-version", "--version", "-version", "-h", "--help")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_interpreter(base):
|
|
89
|
+
return base in INTERPRETERS or base.startswith("python")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _casefold(s):
|
|
93
|
+
return s.casefold() if DARWIN else s
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _strip_quotes(cmd, keep_double):
|
|
97
|
+
"""cmd with quoted spans removed. keep_double keeps the CONTENT of
|
|
98
|
+
double-quoted spans (where `$(` and backticks stay active) while dropping
|
|
99
|
+
single-quoted spans (inert). A backslash escapes the next char outside
|
|
100
|
+
single quotes."""
|
|
101
|
+
out = []
|
|
102
|
+
q = None
|
|
103
|
+
i = 0
|
|
104
|
+
n = len(cmd)
|
|
105
|
+
while i < n:
|
|
106
|
+
c = cmd[i]
|
|
107
|
+
if q is None:
|
|
108
|
+
if c == "\\" and i + 1 < n:
|
|
109
|
+
i += 2
|
|
110
|
+
continue
|
|
111
|
+
if c == "'":
|
|
112
|
+
q = "'"
|
|
113
|
+
elif c == '"':
|
|
114
|
+
q = '"'
|
|
115
|
+
else:
|
|
116
|
+
out.append(c)
|
|
117
|
+
elif q == "'":
|
|
118
|
+
if c == "'":
|
|
119
|
+
q = None
|
|
120
|
+
else: # double quote
|
|
121
|
+
if c == "\\" and i + 1 < n:
|
|
122
|
+
if keep_double:
|
|
123
|
+
out.append(cmd[i + 1])
|
|
124
|
+
i += 2
|
|
125
|
+
continue
|
|
126
|
+
if c == '"':
|
|
127
|
+
q = None
|
|
128
|
+
elif keep_double:
|
|
129
|
+
out.append(c)
|
|
130
|
+
i += 1
|
|
131
|
+
return "".join(out)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Refused(Exception):
|
|
135
|
+
"""The line uses shell syntax the guard does not analyse."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
PLACEHOLDER_RX = re.compile(r"__MA_SUBST_(\d+)__", re.I)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _placeholder(k):
|
|
142
|
+
return f"__MA_SUBST_{k}__"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _has_ph(s):
|
|
146
|
+
return bool(s) and bool(PLACEHOLDER_RX.search(s))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _match_paren(cmd, start):
|
|
150
|
+
"""Index of the `)` closing a `$(` whose body starts at `start`. Quote-aware
|
|
151
|
+
and balanced; a nested `$(` or an unterminated body is refused."""
|
|
152
|
+
depth = 1
|
|
153
|
+
q = None
|
|
154
|
+
i = start
|
|
155
|
+
n = len(cmd)
|
|
156
|
+
while i < n:
|
|
157
|
+
c = cmd[i]
|
|
158
|
+
if q == "'":
|
|
159
|
+
if c == "'":
|
|
160
|
+
q = None
|
|
161
|
+
i += 1
|
|
162
|
+
continue
|
|
163
|
+
if c == "\\" and i + 1 < n:
|
|
164
|
+
i += 2
|
|
165
|
+
continue
|
|
166
|
+
if q == '"':
|
|
167
|
+
if c == '"':
|
|
168
|
+
q = None
|
|
169
|
+
elif c == "$" and cmd.startswith("$(", i):
|
|
170
|
+
raise Refused("a command substitution nested inside another is not analysed")
|
|
171
|
+
elif c == "`":
|
|
172
|
+
raise Refused("a backtick substitution hides a command from the guard")
|
|
173
|
+
i += 1
|
|
174
|
+
continue
|
|
175
|
+
if c in ("'", '"'):
|
|
176
|
+
q = c
|
|
177
|
+
elif c == "$" and cmd.startswith("$(", i):
|
|
178
|
+
raise Refused("a command substitution nested inside another is not analysed")
|
|
179
|
+
elif c == "(":
|
|
180
|
+
depth += 1
|
|
181
|
+
elif c == ")":
|
|
182
|
+
depth -= 1
|
|
183
|
+
if depth == 0:
|
|
184
|
+
return i
|
|
185
|
+
i += 1
|
|
186
|
+
raise Refused("an unterminated command substitution cannot be parsed")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
HEREDOC_TOKEN = "__MA_HEREDOC__"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _heredoc_delim(cmd, i):
|
|
193
|
+
"""At an unquoted `<<` (not `<<<`): (end, delimiter, dash, quoted)."""
|
|
194
|
+
j = i + 2
|
|
195
|
+
dash = j < len(cmd) and cmd[j] == "-"
|
|
196
|
+
if dash:
|
|
197
|
+
j += 1
|
|
198
|
+
while j < len(cmd) and cmd[j] in " \t":
|
|
199
|
+
j += 1
|
|
200
|
+
if j < len(cmd) and cmd[j] in ("'", '"'):
|
|
201
|
+
qc = cmd[j]
|
|
202
|
+
k = cmd.find(qc, j + 1)
|
|
203
|
+
if k < 0:
|
|
204
|
+
raise Refused("an unterminated heredoc delimiter cannot be parsed")
|
|
205
|
+
return k + 1, cmd[j + 1:k], dash, True
|
|
206
|
+
quoted = j < len(cmd) and cmd[j] == "\\"
|
|
207
|
+
if quoted:
|
|
208
|
+
j += 1
|
|
209
|
+
m = re.match(r"[A-Za-z0-9_.-]+", cmd[j:])
|
|
210
|
+
if not m:
|
|
211
|
+
raise Refused("a heredoc without a readable delimiter cannot be parsed")
|
|
212
|
+
return j + m.end(), m.group(0), dash, quoted
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def strip_heredocs(cmd):
|
|
216
|
+
"""cmd with every heredoc body removed and its `<<'DELIM'` replaced by an
|
|
217
|
+
input redirect from a placeholder. A body behind a quoted delimiter is
|
|
218
|
+
literal data. An unquoted delimiter expands `$(...)` inside the body, which
|
|
219
|
+
the guard does not analyse, so it is refused. Quote-aware, and `$(` opens a
|
|
220
|
+
fresh quoting context the way the shell does."""
|
|
221
|
+
out = []
|
|
222
|
+
i = 0
|
|
223
|
+
n = len(cmd)
|
|
224
|
+
q = None
|
|
225
|
+
stack = []
|
|
226
|
+
depth = 0
|
|
227
|
+
pending = []
|
|
228
|
+
while i < n:
|
|
229
|
+
c = cmd[i]
|
|
230
|
+
if q == "'":
|
|
231
|
+
out.append(c)
|
|
232
|
+
if c == "'":
|
|
233
|
+
q = None
|
|
234
|
+
i += 1
|
|
235
|
+
continue
|
|
236
|
+
if c == "\\" and i + 1 < n:
|
|
237
|
+
out.append(cmd[i:i + 2])
|
|
238
|
+
i += 2
|
|
239
|
+
continue
|
|
240
|
+
if q == '"':
|
|
241
|
+
if c == '"':
|
|
242
|
+
q = None
|
|
243
|
+
elif cmd.startswith("$(", i):
|
|
244
|
+
stack.append((q, depth))
|
|
245
|
+
q, depth = None, 0
|
|
246
|
+
out.append("$(")
|
|
247
|
+
i += 2
|
|
248
|
+
continue
|
|
249
|
+
out.append(c)
|
|
250
|
+
i += 1
|
|
251
|
+
continue
|
|
252
|
+
if c in ("'", '"'):
|
|
253
|
+
q = c
|
|
254
|
+
out.append(c)
|
|
255
|
+
i += 1
|
|
256
|
+
continue
|
|
257
|
+
if cmd.startswith("$(", i):
|
|
258
|
+
stack.append((None, depth))
|
|
259
|
+
depth = 0
|
|
260
|
+
out.append("$(")
|
|
261
|
+
i += 2
|
|
262
|
+
continue
|
|
263
|
+
if c == "(":
|
|
264
|
+
depth += 1
|
|
265
|
+
elif c == ")":
|
|
266
|
+
if depth == 0 and stack:
|
|
267
|
+
q, depth = stack.pop()
|
|
268
|
+
out.append(c)
|
|
269
|
+
i += 1
|
|
270
|
+
continue
|
|
271
|
+
depth -= 1
|
|
272
|
+
if cmd.startswith("<<", i) and not cmd.startswith("<<<", i) and not (i > 0 and cmd[i - 1] == "<"):
|
|
273
|
+
end, delim, dash, quoted = _heredoc_delim(cmd, i)
|
|
274
|
+
if not quoted:
|
|
275
|
+
raise Refused("a heredoc with an unquoted delimiter expands its body, which the guard does not analyse")
|
|
276
|
+
out.append(f"< {HEREDOC_TOKEN} ")
|
|
277
|
+
pending.append((delim, dash))
|
|
278
|
+
i = end
|
|
279
|
+
continue
|
|
280
|
+
if c == "\n" and pending:
|
|
281
|
+
out.append("\n")
|
|
282
|
+
i += 1
|
|
283
|
+
for delim, dash in pending:
|
|
284
|
+
while True:
|
|
285
|
+
if i >= n:
|
|
286
|
+
raise Refused("an unterminated heredoc cannot be parsed")
|
|
287
|
+
k = cmd.find("\n", i)
|
|
288
|
+
line = cmd[i:] if k < 0 else cmd[i:k]
|
|
289
|
+
i = n if k < 0 else k + 1
|
|
290
|
+
if (line.lstrip("\t") if dash else line) == delim:
|
|
291
|
+
break
|
|
292
|
+
pending = []
|
|
293
|
+
continue
|
|
294
|
+
out.append(c)
|
|
295
|
+
i += 1
|
|
296
|
+
if pending:
|
|
297
|
+
raise Refused("an unterminated heredoc cannot be parsed")
|
|
298
|
+
return "".join(out)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def extract_substitutions(cmd):
|
|
302
|
+
"""(outer, inners): every top-level `$(...)` replaced by an indexed
|
|
303
|
+
placeholder, and the inner command texts. Single-quoted text is literal.
|
|
304
|
+
`$((` arithmetic and an unbalanced quote are refused."""
|
|
305
|
+
out = []
|
|
306
|
+
inners = []
|
|
307
|
+
q = None
|
|
308
|
+
i = 0
|
|
309
|
+
n = len(cmd)
|
|
310
|
+
while i < n:
|
|
311
|
+
c = cmd[i]
|
|
312
|
+
if q == "'":
|
|
313
|
+
out.append(c)
|
|
314
|
+
if c == "'":
|
|
315
|
+
q = None
|
|
316
|
+
i += 1
|
|
317
|
+
continue
|
|
318
|
+
if c == "\\" and i + 1 < n:
|
|
319
|
+
out.append(cmd[i:i + 2])
|
|
320
|
+
i += 2
|
|
321
|
+
continue
|
|
322
|
+
if c == '"':
|
|
323
|
+
q = None if q == '"' else '"'
|
|
324
|
+
out.append(c)
|
|
325
|
+
i += 1
|
|
326
|
+
continue
|
|
327
|
+
if c == "'" and q is None:
|
|
328
|
+
q = "'"
|
|
329
|
+
out.append(c)
|
|
330
|
+
i += 1
|
|
331
|
+
continue
|
|
332
|
+
if c == "$" and cmd.startswith("$((", i):
|
|
333
|
+
raise Refused("arithmetic expansion $((...)) is not analysed")
|
|
334
|
+
if c == "$" and cmd.startswith("$(", i):
|
|
335
|
+
j = _match_paren(cmd, i + 2)
|
|
336
|
+
inners.append(cmd[i + 2:j])
|
|
337
|
+
out.append(_placeholder(len(inners) - 1))
|
|
338
|
+
i = j + 1
|
|
339
|
+
continue
|
|
340
|
+
out.append(c)
|
|
341
|
+
i += 1
|
|
342
|
+
if q is not None:
|
|
343
|
+
raise Refused("an unbalanced quote leaves the command unparseable")
|
|
344
|
+
return "".join(out), inners
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
BACKGROUND = re.compile(r"(?<![&>0-9])&(?![&>])")
|
|
348
|
+
SUBST = re.compile(r"\$\(|`|<\(|>\(")
|
|
349
|
+
HEREDOC = re.compile(r"(?<!<)<<(?!<)")
|
|
350
|
+
GROUPING = re.compile(r"[(){}]")
|
|
351
|
+
OP_SPLIT = re.compile(r"\|\||&&|;|\n|\|")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def split_ops(cmd):
|
|
355
|
+
"""The simple-command segments of a line, split on unquoted `;`, `&&`,
|
|
356
|
+
`||`, `|`, `|&` and newlines - the way the shell splits them, so an
|
|
357
|
+
operator inside a quoted sed, jq or awk program is not a separator."""
|
|
358
|
+
segs = []
|
|
359
|
+
cur = []
|
|
360
|
+
q = None
|
|
361
|
+
i = 0
|
|
362
|
+
n = len(cmd)
|
|
363
|
+
while i < n:
|
|
364
|
+
c = cmd[i]
|
|
365
|
+
if q == "'":
|
|
366
|
+
cur.append(c)
|
|
367
|
+
if c == "'":
|
|
368
|
+
q = None
|
|
369
|
+
i += 1
|
|
370
|
+
continue
|
|
371
|
+
if c == "\\" and i + 1 < n:
|
|
372
|
+
cur.append(cmd[i:i + 2])
|
|
373
|
+
i += 2
|
|
374
|
+
continue
|
|
375
|
+
if q == '"':
|
|
376
|
+
cur.append(c)
|
|
377
|
+
if c == '"':
|
|
378
|
+
q = None
|
|
379
|
+
i += 1
|
|
380
|
+
continue
|
|
381
|
+
if c in ("'", '"'):
|
|
382
|
+
q = c
|
|
383
|
+
cur.append(c)
|
|
384
|
+
i += 1
|
|
385
|
+
continue
|
|
386
|
+
two = cmd[i:i + 2]
|
|
387
|
+
if two in ("&&", "||", "|&"):
|
|
388
|
+
segs.append("".join(cur))
|
|
389
|
+
cur = []
|
|
390
|
+
i += 2
|
|
391
|
+
continue
|
|
392
|
+
if c == "|" and cur and cur[-1] == ">":
|
|
393
|
+
cur.append(c)
|
|
394
|
+
i += 1
|
|
395
|
+
continue
|
|
396
|
+
if c in (";", "|", "\n"):
|
|
397
|
+
segs.append("".join(cur))
|
|
398
|
+
cur = []
|
|
399
|
+
i += 1
|
|
400
|
+
continue
|
|
401
|
+
cur.append(c)
|
|
402
|
+
i += 1
|
|
403
|
+
segs.append("".join(cur))
|
|
404
|
+
return segs
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
WRITE_REDIRECTS = {">", ">>", ">|", "&>", "&>>", "<>"}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def redirect_targets(seg):
|
|
411
|
+
"""Files a segment's redirections write, read with a quote-aware lexer so a
|
|
412
|
+
`>` inside quotes is text and a quoted target is still seen."""
|
|
413
|
+
try:
|
|
414
|
+
lex = shlex.shlex(seg, posix=True, punctuation_chars=True)
|
|
415
|
+
lex.whitespace_split = True
|
|
416
|
+
toks = list(lex)
|
|
417
|
+
except ValueError:
|
|
418
|
+
raise Refused("an unbalanced quote leaves the redirections unparseable")
|
|
419
|
+
out = []
|
|
420
|
+
for i, t in enumerate(toks):
|
|
421
|
+
if i + 1 >= len(toks):
|
|
422
|
+
continue
|
|
423
|
+
nxt = toks[i + 1]
|
|
424
|
+
if t in WRITE_REDIRECTS:
|
|
425
|
+
out.append(nxt)
|
|
426
|
+
elif t == ">&" and not (nxt.isdigit() or nxt == "-"):
|
|
427
|
+
out.append(nxt)
|
|
428
|
+
return out
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
SIMPLE_PARAM = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_]*\}")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def structural_refusal(cmd):
|
|
435
|
+
"""A reason the command cannot be reduced to simple commands, or None.
|
|
436
|
+
Called after heredoc bodies are stripped and `$(...)` extracted, so what is
|
|
437
|
+
left of `<<` is a here-string and any `$(` left is unanalysable."""
|
|
438
|
+
meta = SIMPLE_PARAM.sub("$P", _strip_quotes(cmd, keep_double=False))
|
|
439
|
+
subst = _strip_quotes(cmd, keep_double=True)
|
|
440
|
+
if SUBST.search(subst):
|
|
441
|
+
return "a backtick or process substitution hides a command from the guard"
|
|
442
|
+
if HEREDOC.search(meta):
|
|
443
|
+
return "a heredoc the guard could not strip feeds a command it cannot see"
|
|
444
|
+
if GROUPING.search(meta):
|
|
445
|
+
return "a subshell, brace group or non-trivial ${...} expansion hides a command from the guard"
|
|
446
|
+
if BACKGROUND.search(meta):
|
|
447
|
+
return "a background job (&) runs a command the guard cannot wait on"
|
|
448
|
+
segs = split_ops(cmd)
|
|
449
|
+
if len(segs) > MAX_SEGMENTS:
|
|
450
|
+
return f"the command has more than {MAX_SEGMENTS} segments and is refused unparsed"
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def tokenize(text):
|
|
455
|
+
try:
|
|
456
|
+
return shlex.split(text)
|
|
457
|
+
except ValueError:
|
|
458
|
+
return text.split()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
ASSIGN_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _walk_wrappers(toks):
|
|
465
|
+
"""(assignments, command, refusal) for a token list: the VAR=value
|
|
466
|
+
assignments that apply to the command (leading ones, those after `env`,
|
|
467
|
+
and `arch -e` values), the wrapped command with every wrapper and its
|
|
468
|
+
option values consumed, and a reason when a wrapper hides the command."""
|
|
469
|
+
assigns = []
|
|
470
|
+
i = 0
|
|
471
|
+
while i < len(toks):
|
|
472
|
+
t = toks[i]
|
|
473
|
+
if ASSIGN_NAME.match(t):
|
|
474
|
+
assigns.append(t)
|
|
475
|
+
i += 1
|
|
476
|
+
continue
|
|
477
|
+
base = os.path.basename(t).lower()
|
|
478
|
+
if base in OPAQUE_WRAPPERS:
|
|
479
|
+
return assigns, toks[i:], f"{base} runs the rest of the line in a way the guard does not model"
|
|
480
|
+
if base not in WRAPPER_VALUE_OPTS:
|
|
481
|
+
break
|
|
482
|
+
value_opts = WRAPPER_VALUE_OPTS[base]
|
|
483
|
+
i += 1
|
|
484
|
+
while i < len(toks) and toks[i].startswith("-"):
|
|
485
|
+
opt = toks[i]
|
|
486
|
+
i += 1
|
|
487
|
+
if base == "env" and any(opt == o or opt.startswith(o + ("=" if o.startswith("--") else "")) for o in ENV_REFUSED_OPTS):
|
|
488
|
+
return assigns, toks[i:], "env -S / -C re-splits the command or moves it to another directory"
|
|
489
|
+
if base == "arch" and opt == "-e":
|
|
490
|
+
if i < len(toks):
|
|
491
|
+
assigns.append(toks[i])
|
|
492
|
+
i += 1
|
|
493
|
+
continue
|
|
494
|
+
if base == "xcrun" and (opt in XCRUN_LOOKUPS or opt.startswith("--show-sdk")):
|
|
495
|
+
return assigns, [], None
|
|
496
|
+
if opt in value_opts and i < len(toks):
|
|
497
|
+
i += 1
|
|
498
|
+
# timeout takes a bare DURATION positional before the command.
|
|
499
|
+
if base in ("timeout", "gtimeout") and i < len(toks) and re.match(r"^[0-9]", toks[i]):
|
|
500
|
+
i += 1
|
|
501
|
+
return assigns, toks[i:], None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _strip_wrappers(toks):
|
|
505
|
+
"""Drop env assignments and wrappers that run the rest as a command,
|
|
506
|
+
consuming each wrapper's option VALUES so the wrapped command survives
|
|
507
|
+
intact. sudo, doas, xargs-with-a-command and shell keywords are NOT
|
|
508
|
+
stripped here; they are refused by simple_command_refusal."""
|
|
509
|
+
return _walk_wrappers(toks)[1]
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
REDIR_TOKEN = re.compile(r"^\d*(>>|>\||>&|>|<<<|<>|<&|<|&>>|&>)(.*)$")
|
|
513
|
+
INTERP_VALUE_OPTS = {"-r", "--require", "--import", "--loader", "--experimental-loader", "--input-type",
|
|
514
|
+
"-C", "--conditions", "-W", "-X", "-I", "-M"}
|
|
515
|
+
INTERP_INLINE = {"-e", "-E", "-p", "--eval", "--print"}
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _operands(args):
|
|
519
|
+
"""args with redirection operators and their targets removed."""
|
|
520
|
+
out = []
|
|
521
|
+
i = 0
|
|
522
|
+
while i < len(args):
|
|
523
|
+
m = REDIR_TOKEN.match(args[i])
|
|
524
|
+
if m:
|
|
525
|
+
i += 1 if m.group(2) else 2
|
|
526
|
+
continue
|
|
527
|
+
out.append(args[i])
|
|
528
|
+
i += 1
|
|
529
|
+
return out
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _program_source(low, args):
|
|
533
|
+
"""What an interpreter runs: "script" (a named file or module), "inline"
|
|
534
|
+
(code on the command line), or "stdin" (no program named, so it reads the
|
|
535
|
+
program from its input - a pipe, here-string or heredoc)."""
|
|
536
|
+
ops = _operands(args)
|
|
537
|
+
j = 0
|
|
538
|
+
while j < len(ops):
|
|
539
|
+
a = ops[j]
|
|
540
|
+
if a == "--":
|
|
541
|
+
return ("script", ops[j + 1]) if j + 1 < len(ops) else ("stdin", "")
|
|
542
|
+
if a == "-":
|
|
543
|
+
return "stdin", ""
|
|
544
|
+
if a.startswith("-"):
|
|
545
|
+
if low.startswith("python") and a == "-m":
|
|
546
|
+
return ("module", ops[j + 1]) if j + 1 < len(ops) else ("stdin", "")
|
|
547
|
+
if a in INTERP_INLINE:
|
|
548
|
+
return "inline", ""
|
|
549
|
+
if low in ("sh", "bash", "zsh", "dash", "ksh", "fish") and a == "-s":
|
|
550
|
+
return "stdin", ""
|
|
551
|
+
j += 2 if a in INTERP_VALUE_OPTS else 1
|
|
552
|
+
continue
|
|
553
|
+
return "script", a
|
|
554
|
+
return "stdin", ""
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
_TRACK_CACHE = {}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _run_base_sha(env=None):
|
|
561
|
+
env = os.environ if env is None else env
|
|
562
|
+
sha = (env.get("MA_RUN_BASE_SHA") or "").strip()
|
|
563
|
+
return sha if re.match(r"^[0-9a-f]{7,40}$", sha) else None
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _worktree_top(d):
|
|
567
|
+
try:
|
|
568
|
+
import subprocess
|
|
569
|
+
|
|
570
|
+
r = subprocess.run(["git", "-C", d, "rev-parse", "--show-toplevel"],
|
|
571
|
+
capture_output=True, text=True, timeout=3)
|
|
572
|
+
return os.path.realpath(r.stdout.strip()) if r.returncode == 0 and r.stdout.strip() else None
|
|
573
|
+
except Exception:
|
|
574
|
+
return None
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _tracked_clean(path, env=None, cwd=None):
|
|
578
|
+
"""path is a regular file the run may run as a program: it is tracked by
|
|
579
|
+
git, unchanged in the working tree, AND present unchanged at the run's BASE
|
|
580
|
+
commit (MA_RUN_BASE_SHA). A file the run committed itself is clean against
|
|
581
|
+
HEAD but is not in the base, so it does not count. The path must sit inside
|
|
582
|
+
the RUN's worktree (the one cwd is in); a file in another clone that happens
|
|
583
|
+
to share the base commit does not count. Without a base sha the guard cannot
|
|
584
|
+
prove provenance, so it refuses."""
|
|
585
|
+
p = os.path.realpath(path)
|
|
586
|
+
key = (p, _run_base_sha(env) or "", os.path.realpath(cwd) if cwd else "")
|
|
587
|
+
if key in _TRACK_CACHE:
|
|
588
|
+
return _TRACK_CACHE[key]
|
|
589
|
+
ok = False
|
|
590
|
+
base = _run_base_sha(env)
|
|
591
|
+
run_top = _worktree_top(cwd) if cwd else None
|
|
592
|
+
if base and os.path.isfile(p):
|
|
593
|
+
d, name = os.path.split(p)
|
|
594
|
+
if run_top is not None and _worktree_top(d) != run_top:
|
|
595
|
+
_TRACK_CACHE[key] = False
|
|
596
|
+
return False
|
|
597
|
+
try:
|
|
598
|
+
import subprocess
|
|
599
|
+
|
|
600
|
+
def g(*a):
|
|
601
|
+
return subprocess.run(["git", "-C", d, *a], capture_output=True, text=True, timeout=3)
|
|
602
|
+
|
|
603
|
+
# The file's path relative to the worktree root, so the base lookup
|
|
604
|
+
# names it correctly and a file outside this worktree is rejected.
|
|
605
|
+
top = g("rev-parse", "--show-toplevel")
|
|
606
|
+
ls = g("ls-files", "--error-unmatch", "--", name)
|
|
607
|
+
st = g("status", "--porcelain", "--untracked-files=all", "--", name)
|
|
608
|
+
in_worktree = top.returncode == 0 and bool(top.stdout.strip())
|
|
609
|
+
rel = os.path.relpath(p, os.path.realpath(top.stdout.strip())) if in_worktree else None
|
|
610
|
+
at_base = (
|
|
611
|
+
g("cat-file", "-e", f"{base}:{rel}").returncode == 0
|
|
612
|
+
and g("diff", "--quiet", base, "--", name).returncode == 0
|
|
613
|
+
if rel and not rel.startswith("..")
|
|
614
|
+
else False
|
|
615
|
+
)
|
|
616
|
+
ok = (
|
|
617
|
+
in_worktree
|
|
618
|
+
and ls.returncode == 0
|
|
619
|
+
and st.returncode == 0
|
|
620
|
+
and not st.stdout.strip()
|
|
621
|
+
and at_base
|
|
622
|
+
)
|
|
623
|
+
except Exception:
|
|
624
|
+
ok = False
|
|
625
|
+
_TRACK_CACHE[key] = ok
|
|
626
|
+
return ok
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
GLOB_CHARS = "*?["
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _program_refusal(what, raw, cwd, variables, env):
|
|
633
|
+
"""Reason `what` may not run the file `raw`, or None. A run may execute the
|
|
634
|
+
installed pipeline (~/.claude/scripts, ~/.claude/lib), a file it cannot
|
|
635
|
+
write, a binary npm linked into node_modules/.bin, or a file committed at
|
|
636
|
+
HEAD and left unmodified - never a file the run itself wrote or edited."""
|
|
637
|
+
e = expand(raw, variables or {}, env)
|
|
638
|
+
if e is None:
|
|
639
|
+
return f"{what} runs a file whose name is built at run time"
|
|
640
|
+
if any(ch in e for ch in GLOB_CHARS):
|
|
641
|
+
return f"{what} runs a file named by a glob pattern"
|
|
642
|
+
e = os.path.expanduser(re.sub(r"^\$\{?HOME\}?(?=/|$)", home(), e))
|
|
643
|
+
if not os.path.isabs(e) and cwd is None:
|
|
644
|
+
return f"{what} runs a file relative to a directory the guard could not resolve"
|
|
645
|
+
literal = os.path.normpath(e if os.path.isabs(e) else os.path.join(cwd, e))
|
|
646
|
+
real = os.path.realpath(literal)
|
|
647
|
+
# A program under /dev (/dev/stdin, /dev/fd/N) or any non-regular file
|
|
648
|
+
# (a FIFO, a device) is the program the guard cannot see: its bytes are the
|
|
649
|
+
# command's own input, not a file on disk it can judge.
|
|
650
|
+
if literal == "/dev" or literal.startswith("/dev/") or real == "/dev" or real.startswith("/dev/"):
|
|
651
|
+
return f"{what} runs a program read from {raw[:40]}, which the guard cannot see"
|
|
652
|
+
try:
|
|
653
|
+
st = os.stat(real)
|
|
654
|
+
if not stat.S_ISREG(st.st_mode):
|
|
655
|
+
return f"{what} runs {raw[:40]}, which is not a regular file (a FIFO, device or socket)"
|
|
656
|
+
except OSError:
|
|
657
|
+
pass
|
|
658
|
+
for h in {home(), os.path.expanduser("~")}:
|
|
659
|
+
for sub in ("scripts", "lib"):
|
|
660
|
+
base = os.path.join(h, ".claude", sub)
|
|
661
|
+
if _under(literal, base) or _under(real, os.path.realpath(base)):
|
|
662
|
+
return None
|
|
663
|
+
if (os.sep + os.path.join("node_modules", ".bin") + os.sep) in literal:
|
|
664
|
+
return None
|
|
665
|
+
if os.path.exists(real) and not os.access(real, os.W_OK) and not os.access(os.path.dirname(real), os.W_OK):
|
|
666
|
+
return None
|
|
667
|
+
if _tracked_clean(real, env, cwd):
|
|
668
|
+
return None
|
|
669
|
+
return (f"{what} runs {raw[:60]}, which is neither an installed pipeline script nor a file "
|
|
670
|
+
"committed unmodified at the run's base commit")
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
# Interpreter options that put the program on the command line, by interpreter:
|
|
674
|
+
# (short letters that mean "inline code", short letters that take a value and
|
|
675
|
+
# end the cluster). python -c and sh -c are covered by the `-c` rule.
|
|
676
|
+
INLINE_SHORT = {
|
|
677
|
+
"node": ("ep", "r"),
|
|
678
|
+
"bun": ("ep", "r"),
|
|
679
|
+
"ruby": ("e", "IrCEFKTWx0"),
|
|
680
|
+
"perl": ("eE", "IMmixCdD"),
|
|
681
|
+
"lua": ("e", "l"),
|
|
682
|
+
"osascript": ("e", "l"),
|
|
683
|
+
"swift": ("e", ""),
|
|
684
|
+
"rscript": ("e", ""),
|
|
685
|
+
"php": ("rRBE", "cdzfF"),
|
|
686
|
+
}
|
|
687
|
+
INLINE_LONG = {"--eval", "--print"}
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _inline_flag(low, args):
|
|
691
|
+
if low not in INLINE_SHORT:
|
|
692
|
+
return None
|
|
693
|
+
inline, valued = INLINE_SHORT[low]
|
|
694
|
+
for a in args:
|
|
695
|
+
if a == "--":
|
|
696
|
+
return None
|
|
697
|
+
if a.split("=", 1)[0] in INLINE_LONG and low in ("node", "bun"):
|
|
698
|
+
return a
|
|
699
|
+
if a.startswith("-") and not a.startswith("--") and len(a) > 1:
|
|
700
|
+
for ch in a[1:]:
|
|
701
|
+
if ch in inline:
|
|
702
|
+
return a
|
|
703
|
+
if ch in valued:
|
|
704
|
+
break
|
|
705
|
+
elif not a.startswith("-"):
|
|
706
|
+
return None
|
|
707
|
+
return None
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
SCRIPT_EXT = re.compile(r"\.(m?js|cjs|ts|tsx|jsx|mts|cts)$")
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _module_refusal(what, name, cwd, variables, env):
|
|
714
|
+
"""python -m NAME imports NAME from the cwd first: a module the run wrote
|
|
715
|
+
there is a file the run executes."""
|
|
716
|
+
if cwd is None:
|
|
717
|
+
return f"{what} imports a module relative to a directory the guard could not resolve"
|
|
718
|
+
first = name.split(".", 1)[0]
|
|
719
|
+
for cand in (first + ".py", first):
|
|
720
|
+
if os.path.exists(os.path.join(cwd, cand)):
|
|
721
|
+
return _program_refusal(what, cand, cwd, variables, env)
|
|
722
|
+
return None
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
NODE_PRELOAD_OPTS = ("-r", "--require", "--import", "--loader", "--experimental-loader")
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _node_preload_refusal(base, args, cwd, variables, env):
|
|
729
|
+
"""node -r / --require / --import / --loader FILE runs FILE before the main
|
|
730
|
+
script; a file the run wrote there executes just as a script argument would.
|
|
731
|
+
The value is checked with _program_refusal."""
|
|
732
|
+
i = 0
|
|
733
|
+
while i < len(args):
|
|
734
|
+
a = args[i]
|
|
735
|
+
if a == "--":
|
|
736
|
+
break
|
|
737
|
+
val = None
|
|
738
|
+
for opt in NODE_PRELOAD_OPTS:
|
|
739
|
+
if a == opt and i + 1 < len(args):
|
|
740
|
+
val = args[i + 1]
|
|
741
|
+
i += 1
|
|
742
|
+
break
|
|
743
|
+
if a.startswith(opt + "="):
|
|
744
|
+
val = a.split("=", 1)[1]
|
|
745
|
+
break
|
|
746
|
+
if val is not None:
|
|
747
|
+
if "://" in val:
|
|
748
|
+
return f"{base} {a.split('=', 1)[0]} preloads a remote module"
|
|
749
|
+
r = _program_refusal(f"{base} {a.split('=', 1)[0]}", val, cwd, variables, env)
|
|
750
|
+
if r:
|
|
751
|
+
return r
|
|
752
|
+
i += 1
|
|
753
|
+
return None
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def interpreter_refusal(stripped, cwd, variables=None, env=None):
|
|
757
|
+
"""What an interpreter runs must be a file the run did not write: inline
|
|
758
|
+
code (-e, --eval, -p, php -r, deno eval) is refused, a script file is
|
|
759
|
+
checked with _program_refusal, and deno, bun and swift are judged by
|
|
760
|
+
their subcommand."""
|
|
761
|
+
base = os.path.basename(stripped[0])
|
|
762
|
+
low = base.lower()
|
|
763
|
+
args = stripped[1:]
|
|
764
|
+
flag = _inline_flag(low, args)
|
|
765
|
+
if flag:
|
|
766
|
+
return f"{base} {flag} runs an inline program the guard cannot see"
|
|
767
|
+
plains = [a for a in _operands(args) if not a.startswith("-")]
|
|
768
|
+
if low == "deno":
|
|
769
|
+
sub = plains[0] if plains else ""
|
|
770
|
+
if sub in ("eval", "repl"):
|
|
771
|
+
return f"deno {sub} runs an inline program the guard cannot see"
|
|
772
|
+
target = plains[1] if sub == "run" and len(plains) > 1 else (sub if SCRIPT_EXT.search(sub) or "/" in sub else "")
|
|
773
|
+
if target:
|
|
774
|
+
if "://" in target:
|
|
775
|
+
return "deno runs a remote module"
|
|
776
|
+
return _program_refusal(base, target, cwd, variables, env)
|
|
777
|
+
return None
|
|
778
|
+
if low == "bun":
|
|
779
|
+
sub = plains[0] if plains else ""
|
|
780
|
+
target = plains[1] if sub == "run" and len(plains) > 1 else ("" if sub == "run" else sub)
|
|
781
|
+
if target and (SCRIPT_EXT.search(target) or "/" in target):
|
|
782
|
+
return _program_refusal(base, target, cwd, variables, env)
|
|
783
|
+
return None
|
|
784
|
+
if low == "swift":
|
|
785
|
+
sub = plains[0] if plains else ""
|
|
786
|
+
if sub and (sub.endswith(".swift") or "/" in sub):
|
|
787
|
+
return _program_refusal(base, sub, cwd, variables, env)
|
|
788
|
+
return None
|
|
789
|
+
if low in ("node", "nodejs"):
|
|
790
|
+
r = _node_preload_refusal(base, args, cwd, variables, env)
|
|
791
|
+
if r:
|
|
792
|
+
return r
|
|
793
|
+
if not _is_interpreter(low):
|
|
794
|
+
return None
|
|
795
|
+
for a in args:
|
|
796
|
+
if a.startswith("-") and not a.startswith("--") and "c" in a[1:]:
|
|
797
|
+
return f"{base} -c runs an inline program the guard cannot see"
|
|
798
|
+
kind, script = _program_source(low, args)
|
|
799
|
+
if kind == "stdin":
|
|
800
|
+
return f"{base} with no script reads its program from stdin (a pipe, here-string or heredoc)"
|
|
801
|
+
if kind == "inline" and not low.startswith("python"):
|
|
802
|
+
return f"{base} runs an inline program the guard cannot see"
|
|
803
|
+
if _has_ph(script):
|
|
804
|
+
return f"{base} runs a script whose name is built at run time"
|
|
805
|
+
if kind == "module":
|
|
806
|
+
return _module_refusal(base, script, cwd, variables, env)
|
|
807
|
+
if kind == "script":
|
|
808
|
+
return _program_refusal(base, script, cwd, variables, env)
|
|
809
|
+
return None
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
AWKS = {"awk", "gawk", "mawk", "nawk"}
|
|
813
|
+
AWK_DANGER = re.compile(r"\bsystem\s*\(|(?<!\|)\|(?!\|)|\bgetline\b|\bprintf?\b[^;{}]*>|@load|@include")
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def awk_refusal(stripped, cwd, variables=None, env=None):
|
|
817
|
+
"""An awk program may not start commands (system, a pipe, getline) or
|
|
818
|
+
write files (print > file); a program file must be one the run did not
|
|
819
|
+
write."""
|
|
820
|
+
args = stripped[1:]
|
|
821
|
+
progs, files = [], []
|
|
822
|
+
i = 0
|
|
823
|
+
while i < len(args):
|
|
824
|
+
a = args[i]
|
|
825
|
+
if a == "--":
|
|
826
|
+
i += 1
|
|
827
|
+
break
|
|
828
|
+
if not a.startswith("-") or a == "-":
|
|
829
|
+
break
|
|
830
|
+
key = a.split("=", 1)[0]
|
|
831
|
+
if key in ("-i", "--include", "-l", "--load", "-E", "--exec"):
|
|
832
|
+
return f"awk {key} loads code the guard cannot see"
|
|
833
|
+
if a in ("-f", "--file", "-e", "--source") and i + 1 < len(args):
|
|
834
|
+
(files if a in ("-f", "--file") else progs).append(args[i + 1])
|
|
835
|
+
i += 2
|
|
836
|
+
continue
|
|
837
|
+
if key in ("--file", "--source") and "=" in a:
|
|
838
|
+
(files if key == "--file" else progs).append(a.split("=", 1)[1])
|
|
839
|
+
elif a.startswith("-f") and len(a) > 2:
|
|
840
|
+
files.append(a[2:])
|
|
841
|
+
elif a in ("-F", "-v") and i + 1 < len(args):
|
|
842
|
+
i += 1
|
|
843
|
+
i += 1
|
|
844
|
+
if not files and not progs and i < len(args):
|
|
845
|
+
progs.append(args[i])
|
|
846
|
+
for f in files:
|
|
847
|
+
r = _program_refusal("awk -f", f, cwd, variables, env)
|
|
848
|
+
if r:
|
|
849
|
+
return r
|
|
850
|
+
for p in progs:
|
|
851
|
+
if AWK_DANGER.search(p):
|
|
852
|
+
return "an awk program with system, a pipe, getline or an output redirect runs or writes what the guard cannot see"
|
|
853
|
+
return None
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _skip_sed_regex(s, i, delim):
|
|
857
|
+
n = len(s)
|
|
858
|
+
while i < n and s[i] != delim:
|
|
859
|
+
if s[i] == "\\":
|
|
860
|
+
i += 1
|
|
861
|
+
elif s[i] == "\n":
|
|
862
|
+
break
|
|
863
|
+
i += 1
|
|
864
|
+
return i + 1
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def sed_program_refusal(script):
|
|
868
|
+
"""A sed program that writes a file (w, W, the s///w flag) or runs a
|
|
869
|
+
command (e, the s///e flag). A light scanner: it walks addresses and
|
|
870
|
+
commands, skipping regex and replacement text."""
|
|
871
|
+
s = script
|
|
872
|
+
n = len(s)
|
|
873
|
+
i = 0
|
|
874
|
+
reason = "a sed program with a w or e command writes a file or runs a command the guard cannot see"
|
|
875
|
+
while i < n:
|
|
876
|
+
if s[i] in " \t\n;{}!":
|
|
877
|
+
i += 1
|
|
878
|
+
continue
|
|
879
|
+
for _ in range(2):
|
|
880
|
+
if i < n and s[i].isdigit():
|
|
881
|
+
while i < n and (s[i].isdigit() or s[i] == "~"):
|
|
882
|
+
i += 1
|
|
883
|
+
elif i < n and s[i] == "$":
|
|
884
|
+
i += 1
|
|
885
|
+
elif i < n and s[i] == "/":
|
|
886
|
+
i = _skip_sed_regex(s, i + 1, "/")
|
|
887
|
+
while i < n and s[i] in "IM":
|
|
888
|
+
i += 1
|
|
889
|
+
elif i < n - 1 and s[i] == "\\":
|
|
890
|
+
i = _skip_sed_regex(s, i + 2, s[i + 1])
|
|
891
|
+
else:
|
|
892
|
+
break
|
|
893
|
+
while i < n and s[i] in " \t":
|
|
894
|
+
i += 1
|
|
895
|
+
if i < n and s[i] == ",":
|
|
896
|
+
i += 1
|
|
897
|
+
while i < n and s[i] in " \t":
|
|
898
|
+
i += 1
|
|
899
|
+
continue
|
|
900
|
+
break
|
|
901
|
+
while i < n and s[i] in " \t!":
|
|
902
|
+
i += 1
|
|
903
|
+
if i >= n:
|
|
904
|
+
break
|
|
905
|
+
c = s[i]
|
|
906
|
+
if c in "wWe":
|
|
907
|
+
return reason
|
|
908
|
+
if c in "sy" and i + 1 < n:
|
|
909
|
+
d = s[i + 1]
|
|
910
|
+
i = _skip_sed_regex(s, i + 2, d)
|
|
911
|
+
i = _skip_sed_regex(s, i, d)
|
|
912
|
+
if c == "s":
|
|
913
|
+
j = i
|
|
914
|
+
while j < n and s[j] not in ";\n} \t":
|
|
915
|
+
j += 1
|
|
916
|
+
if "w" in s[i:j] or "e" in s[i:j]:
|
|
917
|
+
return reason
|
|
918
|
+
i = j
|
|
919
|
+
continue
|
|
920
|
+
if c in "aicrR":
|
|
921
|
+
k = s.find("\n", i)
|
|
922
|
+
i = n if k < 0 else k + 1
|
|
923
|
+
continue
|
|
924
|
+
if c in "btT:":
|
|
925
|
+
while i < n and s[i] not in ";\n":
|
|
926
|
+
i += 1
|
|
927
|
+
continue
|
|
928
|
+
i += 1
|
|
929
|
+
return None
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def sed_refusal(stripped, cwd, variables=None, env=None):
|
|
933
|
+
args = stripped[1:]
|
|
934
|
+
progs, files, ops = [], [], []
|
|
935
|
+
i = 0
|
|
936
|
+
while i < len(args):
|
|
937
|
+
a = args[i]
|
|
938
|
+
if a in ("-e", "--expression") and i + 1 < len(args):
|
|
939
|
+
progs.append(args[i + 1])
|
|
940
|
+
i += 2
|
|
941
|
+
continue
|
|
942
|
+
if a.startswith("--expression="):
|
|
943
|
+
progs.append(a.split("=", 1)[1])
|
|
944
|
+
elif a.startswith("-e") and len(a) > 2 and not a.startswith("--"):
|
|
945
|
+
progs.append(a[2:])
|
|
946
|
+
elif a in ("-f", "--file") and i + 1 < len(args):
|
|
947
|
+
files.append(args[i + 1])
|
|
948
|
+
i += 2
|
|
949
|
+
continue
|
|
950
|
+
elif a.startswith("--file="):
|
|
951
|
+
files.append(a.split("=", 1)[1])
|
|
952
|
+
elif a == "-i" and i + 1 < len(args) and (args[i + 1] == "" or (args[i + 1].startswith(".") and "/" not in args[i + 1])):
|
|
953
|
+
i += 2
|
|
954
|
+
continue
|
|
955
|
+
elif a in ("-l", "--line-length") and i + 1 < len(args):
|
|
956
|
+
i += 2
|
|
957
|
+
continue
|
|
958
|
+
elif not a.startswith("-") or a == "-":
|
|
959
|
+
ops.append(a)
|
|
960
|
+
i += 1
|
|
961
|
+
if not progs and not files and ops:
|
|
962
|
+
progs.append(ops[0])
|
|
963
|
+
for f in files:
|
|
964
|
+
r = _program_refusal("sed -f", f, cwd, variables, env)
|
|
965
|
+
if r:
|
|
966
|
+
return r
|
|
967
|
+
for p in progs:
|
|
968
|
+
r = sed_program_refusal(p)
|
|
969
|
+
if r:
|
|
970
|
+
return r
|
|
971
|
+
return None
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
MAKEFILES = ("GNUmakefile", "makefile", "Makefile")
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def make_refusal(stripped, cwd, variables=None, env=None):
|
|
978
|
+
"""make runs the recipes of a Makefile: it has to be one committed
|
|
979
|
+
unmodified at HEAD, and the command line may not add code (--eval) or
|
|
980
|
+
override a variable a recipe expands (CC=...)."""
|
|
981
|
+
args = stripped[1:]
|
|
982
|
+
files, directory = [], None
|
|
983
|
+
i = 0
|
|
984
|
+
while i < len(args):
|
|
985
|
+
a = args[i]
|
|
986
|
+
key = a.split("=", 1)[0]
|
|
987
|
+
if key in ("-E", "--eval"):
|
|
988
|
+
return "make --eval adds rules the guard cannot see"
|
|
989
|
+
if a in ("-f", "--file", "--makefile") and i + 1 < len(args):
|
|
990
|
+
files.append(args[i + 1])
|
|
991
|
+
i += 2
|
|
992
|
+
continue
|
|
993
|
+
if key in ("--file", "--makefile") and "=" in a:
|
|
994
|
+
files.append(a.split("=", 1)[1])
|
|
995
|
+
elif a.startswith("-f") and len(a) > 2:
|
|
996
|
+
files.append(a[2:])
|
|
997
|
+
elif a in ("-C", "--directory") and i + 1 < len(args):
|
|
998
|
+
directory = args[i + 1]
|
|
999
|
+
i += 2
|
|
1000
|
+
continue
|
|
1001
|
+
elif key == "--directory" and "=" in a:
|
|
1002
|
+
directory = a.split("=", 1)[1]
|
|
1003
|
+
elif a.startswith("-C") and len(a) > 2:
|
|
1004
|
+
directory = a[2:]
|
|
1005
|
+
elif a in ("-I", "--include-dir", "-o", "-W", "--old-file", "--what-if", "--new-file", "--assume-old", "--assume-new") and i + 1 < len(args):
|
|
1006
|
+
i += 2
|
|
1007
|
+
continue
|
|
1008
|
+
elif not a.startswith("-") and "=" in a:
|
|
1009
|
+
return f"make {key}=... overrides a variable a recipe runs"
|
|
1010
|
+
i += 1
|
|
1011
|
+
base = cwd
|
|
1012
|
+
if directory is not None:
|
|
1013
|
+
e = expand(directory, variables or {}, env)
|
|
1014
|
+
if e is None or (cwd is None and not os.path.isabs(e)):
|
|
1015
|
+
return "make -C into a directory the guard could not resolve"
|
|
1016
|
+
base = os.path.join(cwd or "/", os.path.expanduser(e))
|
|
1017
|
+
if base is None:
|
|
1018
|
+
return "make in a directory the guard could not resolve"
|
|
1019
|
+
if not files:
|
|
1020
|
+
try:
|
|
1021
|
+
entries = os.listdir(base)
|
|
1022
|
+
except OSError:
|
|
1023
|
+
entries = []
|
|
1024
|
+
found = [e for m in MAKEFILES for e in entries if _casefold(e) == _casefold(m)]
|
|
1025
|
+
files = found[:1]
|
|
1026
|
+
for f in files:
|
|
1027
|
+
r = _program_refusal("make", f, base, variables, env)
|
|
1028
|
+
if r:
|
|
1029
|
+
return r
|
|
1030
|
+
return None
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def simple_command_refusal(cmd, toks, variables=None, env=None, cwd=None):
|
|
1034
|
+
"""Refuse a segment that is not a simple command the guard can judge:
|
|
1035
|
+
a shell keyword, a runtime-built command name, sudo/doas, a shell with a
|
|
1036
|
+
`-c` cluster, an interpreter reading its program from stdin or from the
|
|
1037
|
+
command line, a program file the run wrote, find -exec, or xargs with a
|
|
1038
|
+
command."""
|
|
1039
|
+
variables = variables or {}
|
|
1040
|
+
raw = _strip_leading_assignments_for_check(tokenize(cmd))
|
|
1041
|
+
if raw and _has_ph(raw[0]):
|
|
1042
|
+
return "a command substitution in command position builds the command name at run time"
|
|
1043
|
+
if raw and "$" in raw[0]:
|
|
1044
|
+
e = expand(raw[0], variables, env)
|
|
1045
|
+
if e is None:
|
|
1046
|
+
return "the command name is built at run time and cannot be judged"
|
|
1047
|
+
raw = [e] + raw[1:]
|
|
1048
|
+
if raw:
|
|
1049
|
+
base = os.path.basename(raw[0])
|
|
1050
|
+
low = base.lower()
|
|
1051
|
+
if low in SHELL_KEYWORDS or base in SHELL_KEYWORDS:
|
|
1052
|
+
return f"{base} is a shell keyword, not a simple command"
|
|
1053
|
+
if low in ("sudo", "doas"):
|
|
1054
|
+
return f"{low} escalates privilege and is refused"
|
|
1055
|
+
stripped = _strip_wrappers(tokenize(cmd))
|
|
1056
|
+
if not stripped:
|
|
1057
|
+
return None
|
|
1058
|
+
if _has_ph(stripped[0]):
|
|
1059
|
+
return "the command name is built at run time and cannot be judged"
|
|
1060
|
+
if "$" in stripped[0]:
|
|
1061
|
+
e = expand(stripped[0], variables, env)
|
|
1062
|
+
if e is None:
|
|
1063
|
+
return "the command name is built at run time and cannot be judged"
|
|
1064
|
+
stripped = [e] + stripped[1:]
|
|
1065
|
+
base = os.path.basename(stripped[0])
|
|
1066
|
+
low = base.lower()
|
|
1067
|
+
if low in SHELL_KEYWORDS or base in SHELL_KEYWORDS:
|
|
1068
|
+
return f"{base} is a shell keyword, not a simple command"
|
|
1069
|
+
if low in ("sudo", "doas"):
|
|
1070
|
+
return f"{low} escalates privilege and is refused"
|
|
1071
|
+
if "/" in stripped[0]:
|
|
1072
|
+
r = _program_refusal("the command", stripped[0], cwd, variables, env)
|
|
1073
|
+
if r:
|
|
1074
|
+
return r
|
|
1075
|
+
for judge in (interpreter_refusal,
|
|
1076
|
+
awk_refusal if low in AWKS else None,
|
|
1077
|
+
sed_refusal if low in ("sed", "gsed") else None,
|
|
1078
|
+
make_refusal if low in ("make", "gmake") else None):
|
|
1079
|
+
if judge:
|
|
1080
|
+
r = judge(stripped, cwd, variables, env)
|
|
1081
|
+
if r:
|
|
1082
|
+
return r
|
|
1083
|
+
if low in ("sort", "gsort", "rg", "grep", "ggrep"):
|
|
1084
|
+
r = _helper_program_refusal(low, stripped[1:], cwd, variables, env)
|
|
1085
|
+
if r:
|
|
1086
|
+
return r
|
|
1087
|
+
if low == "find":
|
|
1088
|
+
for a in stripped[1:]:
|
|
1089
|
+
if a in ("-exec", "-execdir", "-ok", "-okdir"):
|
|
1090
|
+
return "find -exec runs a command the guard cannot see"
|
|
1091
|
+
if low == "xargs":
|
|
1092
|
+
rest = stripped[1:]
|
|
1093
|
+
j = 0
|
|
1094
|
+
while j < len(rest) and rest[j].startswith("-"):
|
|
1095
|
+
j += 1
|
|
1096
|
+
if j < len(rest):
|
|
1097
|
+
return "xargs with a command runs it the guard cannot judge"
|
|
1098
|
+
return None
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
# Options whose value names a helper program a tool runs: sort's external merge
|
|
1102
|
+
# compressor, ripgrep's and grep's preprocessor. A file the run wrote there
|
|
1103
|
+
# executes.
|
|
1104
|
+
HELPER_PROGRAM_OPTS = {
|
|
1105
|
+
"sort": ("--compress-program",),
|
|
1106
|
+
"gsort": ("--compress-program",),
|
|
1107
|
+
"rg": ("--pre",),
|
|
1108
|
+
"grep": (),
|
|
1109
|
+
"ggrep": (),
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _helper_program_refusal(low, args, cwd, variables, env):
|
|
1114
|
+
opts = HELPER_PROGRAM_OPTS.get(low, ())
|
|
1115
|
+
i = 0
|
|
1116
|
+
while i < len(args):
|
|
1117
|
+
a = args[i]
|
|
1118
|
+
val = None
|
|
1119
|
+
for opt in opts:
|
|
1120
|
+
if a == opt and i + 1 < len(args):
|
|
1121
|
+
val = args[i + 1]
|
|
1122
|
+
i += 1
|
|
1123
|
+
break
|
|
1124
|
+
if a.startswith(opt + "="):
|
|
1125
|
+
val = a.split("=", 1)[1]
|
|
1126
|
+
break
|
|
1127
|
+
if val is not None:
|
|
1128
|
+
r = _program_refusal(f"{low} {a.split('=', 1)[0]}", val, cwd, variables, env)
|
|
1129
|
+
if r:
|
|
1130
|
+
return r
|
|
1131
|
+
i += 1
|
|
1132
|
+
return None
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _strip_leading_assignments_for_check(toks):
|
|
1136
|
+
"""Leading VAR=value assignments: refuse a dangerous one, else drop them and
|
|
1137
|
+
return the rest so the command can be judged."""
|
|
1138
|
+
i = 0
|
|
1139
|
+
while i < len(toks) and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", toks[i]):
|
|
1140
|
+
i += 1
|
|
1141
|
+
return toks[i:]
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
# Environment variables that make a later program run code, load a library,
|
|
1145
|
+
# read another config or resolve paths differently.
|
|
1146
|
+
BLOCKED_ENV = {
|
|
1147
|
+
"PATH", "CDPATH", "BASH_ENV", "ENV", "PROMPT_COMMAND", "SHELLOPTS", "BASHOPTS", "ZDOTDIR", "IFS",
|
|
1148
|
+
"HOME", "XDG_CONFIG_HOME", "NODE_OPTIONS", "PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP",
|
|
1149
|
+
"PERL5OPT", "PERL5LIB", "RUBYOPT", "RUBYLIB", "SSH_ASKPASS", "LESSOPEN", "LESSCLOSE",
|
|
1150
|
+
# Proxies redirect every request somewhere the allowlist cannot check.
|
|
1151
|
+
"HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY",
|
|
1152
|
+
"https_proxy", "http_proxy", "all_proxy", "no_proxy",
|
|
1153
|
+
# These point a fetcher, git or a toolchain at another config, host or CA.
|
|
1154
|
+
"CURL_HOME", "WGETRC", "GH_HOST", "GH_CONFIG_DIR", "BROWSER", "GH_BROWSER",
|
|
1155
|
+
"DEVELOPER_DIR", "TOOLCHAINS", "SDKROOT", "GRADLE_USER_HOME", "JAVA_TOOL_OPTIONS",
|
|
1156
|
+
"NODE_PATH", "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR", "CURL_CA_BUNDLE",
|
|
1157
|
+
"REQUESTS_CA_BUNDLE", "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_CONFIG_FILE",
|
|
1158
|
+
"GEM_HOME", "GEM_PATH", "BUNDLE_GEMFILE",
|
|
1159
|
+
}
|
|
1160
|
+
BLOCKED_ENV_PREFIXES = ("MULTI_AGENT", "LD_", "DYLD_", "npm_config_", "NPM_CONFIG_", "GH_",
|
|
1161
|
+
"PYTHON", "GRADLE_", "JAVA_", "MAVEN_")
|
|
1162
|
+
# Variables that name a program git or a pager runs; only the no-op values pass.
|
|
1163
|
+
PROGRAM_ENV = {"EDITOR", "VISUAL", "PAGER", "MANPAGER", "GIT_EDITOR", "GIT_SEQUENCE_EDITOR", "GIT_PAGER"}
|
|
1164
|
+
PROGRAM_ENV_SAFE = {"", "cat", "true", ":", "less"}
|
|
1165
|
+
# The GIT_* variables that only label a commit or turn off a prompt. Every
|
|
1166
|
+
# other GIT_* is refused: GIT_CONFIG_COUNT/KEY_n/VALUE_n, GIT_CONFIG_GLOBAL,
|
|
1167
|
+
# GIT_EXTERNAL_DIFF, GIT_SSH(_COMMAND), GIT_ASKPASS, GIT_EXEC_PATH, GIT_DIR and
|
|
1168
|
+
# the rest reconfigure what git runs or where it looks.
|
|
1169
|
+
SAFE_GIT_ENV = re.compile(
|
|
1170
|
+
r"^GIT_((AUTHOR|COMMITTER)_(NAME|EMAIL|DATE)|TERMINAL_PROMPT|OPTIONAL_LOCKS|LFS_SKIP_SMUDGE|FLUSH|ADVICE)$")
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def assignment_refusal(assignment):
|
|
1174
|
+
key, _, value = assignment.partition("=")
|
|
1175
|
+
if key in PROGRAM_ENV:
|
|
1176
|
+
if value.strip("'\"") in PROGRAM_ENV_SAFE:
|
|
1177
|
+
return None
|
|
1178
|
+
return f"setting {key} names a program git or a pager runs"
|
|
1179
|
+
if key in BLOCKED_ENV or key.startswith(BLOCKED_ENV_PREFIXES):
|
|
1180
|
+
return f"setting {key} changes what the run executes or how the guard sees it"
|
|
1181
|
+
if key.startswith("GIT_") and not SAFE_GIT_ENV.match(key):
|
|
1182
|
+
return f"setting {key} reconfigures what git runs or where it looks"
|
|
1183
|
+
return None
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def dangerous_assignment(toks):
|
|
1187
|
+
assigns, _, refusal = _walk_wrappers(toks)
|
|
1188
|
+
if refusal:
|
|
1189
|
+
return refusal
|
|
1190
|
+
for t in assigns:
|
|
1191
|
+
r = assignment_refusal(t)
|
|
1192
|
+
if r:
|
|
1193
|
+
return r
|
|
1194
|
+
return None
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
# --- policy data --------------------------------------------------------------
|
|
1198
|
+
|
|
1199
|
+
def load_policy():
|
|
1200
|
+
for p in POLICY_CANDIDATES:
|
|
1201
|
+
try:
|
|
1202
|
+
with open(p, encoding="utf-8") as f:
|
|
1203
|
+
return json.load(f)
|
|
1204
|
+
except (OSError, ValueError):
|
|
1205
|
+
continue
|
|
1206
|
+
raise RuntimeError("unattended-policy.json not found")
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def home():
|
|
1210
|
+
return os.path.realpath(os.path.expanduser("~"))
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def autopilot_root(env=None):
|
|
1214
|
+
env = os.environ if env is None else env
|
|
1215
|
+
root = env.get("MA_AUTOPILOT_ROOT") or os.path.join(home(), ".claude", "autopilot")
|
|
1216
|
+
return os.path.expanduser(root)
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def unattended_run_root(env=None):
|
|
1220
|
+
env = os.environ if env is None else env
|
|
1221
|
+
return os.path.expanduser(env.get("MA_UNATTENDED_RUN_ROOT") or os.path.join(home(), UNATTENDED_RUN_DIR))
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def pr_request_path(env=None):
|
|
1225
|
+
env = os.environ if env is None else env
|
|
1226
|
+
sid = env.get("MULTI_AGENT_SESSION_ID", "")
|
|
1227
|
+
if not re.match(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", sid):
|
|
1228
|
+
return None
|
|
1229
|
+
return os.path.join(unattended_run_root(env), PR_REQUESTS_SUBDIR, sid + ".json")
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def resolve(path, cwd):
|
|
1233
|
+
p = path.strip()
|
|
1234
|
+
p = re.sub(r"^\$\{?HOME\}?(?=/|$)", home(), p)
|
|
1235
|
+
p = os.path.expanduser(p)
|
|
1236
|
+
if not os.path.isabs(p):
|
|
1237
|
+
p = os.path.join(cwd or os.getcwd(), p)
|
|
1238
|
+
return os.path.realpath(os.path.normpath(p))
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
# --- paths --------------------------------------------------------------------
|
|
1242
|
+
|
|
1243
|
+
def _under(target, base):
|
|
1244
|
+
"""target == base or target is under base, casefolded on darwin."""
|
|
1245
|
+
t, b = _casefold(target), _casefold(base)
|
|
1246
|
+
return t == b or t.startswith(b + os.sep)
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def check_path(path, cwd, policy, env=None):
|
|
1250
|
+
if not path:
|
|
1251
|
+
return None
|
|
1252
|
+
target = resolve(path, cwd)
|
|
1253
|
+
tcf = _casefold(target)
|
|
1254
|
+
exempt = pr_request_path(env)
|
|
1255
|
+
if exempt and tcf == _casefold(os.path.realpath(exempt)):
|
|
1256
|
+
return None
|
|
1257
|
+
requests = os.path.realpath(os.path.join(unattended_run_root(env), PR_REQUESTS_SUBDIR))
|
|
1258
|
+
if _under(target, requests):
|
|
1259
|
+
return "another session's PR request (or the requests directory itself) is not this run's to write"
|
|
1260
|
+
h = home()
|
|
1261
|
+
# The home directory itself, or an ancestor of it (rm -rf ~, mv ~ /tmp),
|
|
1262
|
+
# contains every host root and credential file below; writing it writes them.
|
|
1263
|
+
if tcf == _casefold(h) or _under(h, target):
|
|
1264
|
+
return "$HOME (or a directory above it) holds every host root and credential the guard protects"
|
|
1265
|
+
ap = os.path.realpath(autopilot_root(env))
|
|
1266
|
+
if _under(target, ap):
|
|
1267
|
+
return "the autopilot runner's own files (queue, config, requests, PR verification) are not the run's to write"
|
|
1268
|
+
for name in policy.get("protectedHomeFiles", []):
|
|
1269
|
+
if tcf == _casefold(os.path.join(h, name)):
|
|
1270
|
+
return f"~/{name} is host configuration"
|
|
1271
|
+
for d in policy.get("protectedHomeDirs", []):
|
|
1272
|
+
if _under(target, os.path.join(h, d)):
|
|
1273
|
+
return f"~/{d}/ is host configuration"
|
|
1274
|
+
# The whole host root is protected, not an enumerated list of entries: an
|
|
1275
|
+
# unattended run writes its logs and PR request under the unattended run
|
|
1276
|
+
# directory (lib/pr-request-location.mjs), never here, so nothing under
|
|
1277
|
+
# ~/.claude, ~/.copilot or ~/.codex is the run's to write.
|
|
1278
|
+
for root in policy.get("protectedHostRoots", []):
|
|
1279
|
+
base = os.path.join(h, root)
|
|
1280
|
+
if tcf == _casefold(base) or tcf.startswith(_casefold(base) + os.sep):
|
|
1281
|
+
return f"~/{root}/ is host configuration and the installed pipeline; an unattended run writes under ~/{UNATTENDED_RUN_DIR}"
|
|
1282
|
+
segs = target.split(os.sep)
|
|
1283
|
+
segs_cf = [_casefold(s) for s in segs]
|
|
1284
|
+
repo = policy.get("protectedRepoPaths", {})
|
|
1285
|
+
for d in repo.get("dirs", []):
|
|
1286
|
+
parts = [_casefold(p) for p in d.split("/")]
|
|
1287
|
+
for i in range(len(segs_cf) - len(parts) + 1):
|
|
1288
|
+
if segs_cf[i:i + len(parts)] == parts:
|
|
1289
|
+
return f"{d}/ is protected: workflows, hooks and agent config change what runs next"
|
|
1290
|
+
# A directory target that is an ancestor of a protected path (mv evil .github).
|
|
1291
|
+
for k in range(1, len(parts)):
|
|
1292
|
+
if segs_cf[-k:] == parts[:k]:
|
|
1293
|
+
return f"{'/'.join(d.split('/')[:k])} is protected: it holds {d}/"
|
|
1294
|
+
name = segs[-1]
|
|
1295
|
+
ncf = _casefold(name)
|
|
1296
|
+
if any(ncf == _casefold(f) for f in repo.get("files", [])):
|
|
1297
|
+
return f"{name} is protected"
|
|
1298
|
+
man = policy.get("manifests", {})
|
|
1299
|
+
if any(ncf == _casefold(f) for f in man.get("files", [])) or any(re.search(rx, name) for rx in man.get("patterns", [])):
|
|
1300
|
+
return f"{name} is a dependency manifest or lockfile; adding a package is a person's decision"
|
|
1301
|
+
return None
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def _short_cluster_has(tok, ch):
|
|
1305
|
+
return tok.startswith("-") and not tok.startswith("--") and ch in tok[1:]
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
# Options of cp/mv/rsync/install/ditto whose next token is a VALUE, not a path.
|
|
1309
|
+
COPY_VALUE_OPTS = {
|
|
1310
|
+
"cp": set(),
|
|
1311
|
+
"ln": set(),
|
|
1312
|
+
"install": {"-m", "-o", "-g", "-B", "-S"},
|
|
1313
|
+
"ditto": {"--rsrc", "--arch", "--bom"},
|
|
1314
|
+
"mv": set(),
|
|
1315
|
+
"rsync": {"--exclude", "--include", "--exclude-from", "--include-from", "--files-from",
|
|
1316
|
+
"-e", "--rsh", "--chmod", "--chown", "--rsync-path", "--compress-level",
|
|
1317
|
+
"--filter", "-f", "--out-format", "--log-file", "--bwlimit", "--timeout",
|
|
1318
|
+
"--partial-dir", "--backup-dir", "--suffix", "--max-size", "--min-size",
|
|
1319
|
+
"--block-size", "-B", "--modify-window", "-T", "--temp-dir", "--link-dest",
|
|
1320
|
+
"--copy-dest", "--compare-dest", "--port", "--sockopts"},
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def _copy_operands(cmd, args):
|
|
1325
|
+
"""The positional path operands of a copy-like command, with option values
|
|
1326
|
+
consumed so `rsync --exclude X src dst` does not treat X as a path."""
|
|
1327
|
+
value_opts = COPY_VALUE_OPTS.get(cmd, set())
|
|
1328
|
+
out, i = [], 0
|
|
1329
|
+
while i < len(args):
|
|
1330
|
+
a = args[i]
|
|
1331
|
+
if a == "--":
|
|
1332
|
+
out.extend(args[i + 1:])
|
|
1333
|
+
break
|
|
1334
|
+
if a.startswith("-") and a != "-":
|
|
1335
|
+
key = a.split("=", 1)[0]
|
|
1336
|
+
if key in value_opts and "=" not in a:
|
|
1337
|
+
i += 2
|
|
1338
|
+
continue
|
|
1339
|
+
i += 1
|
|
1340
|
+
continue
|
|
1341
|
+
out.append(a)
|
|
1342
|
+
i += 1
|
|
1343
|
+
return out
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def _is_dir_dest(cmd, args, dest):
|
|
1347
|
+
"""A copy into a directory names files inside it by their source basenames.
|
|
1348
|
+
Recursive (cp -R, rsync -a, ditto) or an existing directory destination, or
|
|
1349
|
+
a destination written with a trailing slash."""
|
|
1350
|
+
if dest.endswith("/"):
|
|
1351
|
+
return True
|
|
1352
|
+
recursive = any(
|
|
1353
|
+
_short_cluster_has(a, "R") or _short_cluster_has(a, "r") or _short_cluster_has(a, "a")
|
|
1354
|
+
or a in ("--recursive", "--archive")
|
|
1355
|
+
for a in args
|
|
1356
|
+
)
|
|
1357
|
+
d = os.path.expanduser(re.sub(r"^\$\{?HOME\}?(?=/|$)", home(), dest.strip("'\"")))
|
|
1358
|
+
if recursive or cmd == "rsync" or cmd == "ditto":
|
|
1359
|
+
return True
|
|
1360
|
+
try:
|
|
1361
|
+
return os.path.isdir(d)
|
|
1362
|
+
except OSError:
|
|
1363
|
+
return False
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def dest_write_targets(cmd, args, sources, dest):
|
|
1367
|
+
"""The paths a copy writes: the destination, plus, when it is a directory,
|
|
1368
|
+
the destination joined with each source's basename (cp -R dir ~/ writes
|
|
1369
|
+
~/dir)."""
|
|
1370
|
+
out = [dest]
|
|
1371
|
+
if _is_dir_dest(cmd, args, dest):
|
|
1372
|
+
for src in sources:
|
|
1373
|
+
name = os.path.basename(src.rstrip("/"))
|
|
1374
|
+
if name:
|
|
1375
|
+
out.append(dest.rstrip("/") + "/" + name)
|
|
1376
|
+
return out
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def write_targets(toks):
|
|
1380
|
+
"""Files a simple shell command writes."""
|
|
1381
|
+
if not toks:
|
|
1382
|
+
return []
|
|
1383
|
+
cmd = os.path.basename(toks[0]).lower()
|
|
1384
|
+
args = toks[1:]
|
|
1385
|
+
plain = [a for a in args if not a.startswith("-")]
|
|
1386
|
+
if cmd == "tee":
|
|
1387
|
+
return plain
|
|
1388
|
+
if cmd in ("sed", "gsed"):
|
|
1389
|
+
inplace = any(a == "-i" or a.startswith("-i") or a.startswith("--in-place") or _short_cluster_has(a, "i") for a in args)
|
|
1390
|
+
return plain[1:] if inplace and len(plain) >= 2 else []
|
|
1391
|
+
if cmd == "perl":
|
|
1392
|
+
if not any(_short_cluster_has(a, "i") or a.startswith("-i") for a in args):
|
|
1393
|
+
return []
|
|
1394
|
+
files, skip = [], False
|
|
1395
|
+
for a in args:
|
|
1396
|
+
if skip:
|
|
1397
|
+
skip = False
|
|
1398
|
+
continue
|
|
1399
|
+
if a in ("-e", "-E"):
|
|
1400
|
+
skip = True
|
|
1401
|
+
continue
|
|
1402
|
+
if a.startswith("-"):
|
|
1403
|
+
continue
|
|
1404
|
+
files.append(a)
|
|
1405
|
+
return files
|
|
1406
|
+
if cmd in ("cp", "mv", "ln", "install", "rsync", "ditto"):
|
|
1407
|
+
operands = _copy_operands(cmd, args)
|
|
1408
|
+
for i, a in enumerate(args):
|
|
1409
|
+
if a in ("-t", "--target-directory") and i + 1 < len(args):
|
|
1410
|
+
return [args[i + 1]]
|
|
1411
|
+
if a.startswith("--target-directory="):
|
|
1412
|
+
return [a.split("=", 1)[1]]
|
|
1413
|
+
if a.startswith("-t") and len(a) > 2 and not a.startswith("--"):
|
|
1414
|
+
return [a[2:]]
|
|
1415
|
+
if cmd == "ln":
|
|
1416
|
+
return operands
|
|
1417
|
+
if len(operands) < 2:
|
|
1418
|
+
return []
|
|
1419
|
+
sources, dest = operands[:-1], operands[-1]
|
|
1420
|
+
out = list(dest_write_targets(cmd, args, sources, dest))
|
|
1421
|
+
# mv removes its sources; rsync with --remove-source-files does too.
|
|
1422
|
+
if cmd == "mv" or (cmd == "rsync" and any(a == "--remove-source-files" for a in args)):
|
|
1423
|
+
out.extend(sources)
|
|
1424
|
+
return out
|
|
1425
|
+
if cmd in ("rm", "unlink", "shred"):
|
|
1426
|
+
# rm removes what it names: the paths are writes, and a home entry named
|
|
1427
|
+
# as an ancestor (rm -rf ~) is a write to everything under it.
|
|
1428
|
+
return plain
|
|
1429
|
+
if cmd in ("touch", "truncate", "mkdir", "rmdir", "mkfifo", "mknod", "mktemp"):
|
|
1430
|
+
return plain
|
|
1431
|
+
if cmd in ("chflags", "xattr"):
|
|
1432
|
+
# Metadata changes to a protected file are refused the same as a write.
|
|
1433
|
+
return plain[1:] if len(plain) >= 2 else plain
|
|
1434
|
+
if cmd in ("chmod", "chown"):
|
|
1435
|
+
return plain[1:]
|
|
1436
|
+
if cmd == "dd":
|
|
1437
|
+
return [a[3:] for a in args if a.startswith("of=")]
|
|
1438
|
+
if cmd in ("sort", "gsort"):
|
|
1439
|
+
out = []
|
|
1440
|
+
for i, a in enumerate(args):
|
|
1441
|
+
if a in ("-o", "--output") and i + 1 < len(args):
|
|
1442
|
+
out.append(args[i + 1])
|
|
1443
|
+
elif a.startswith("--output="):
|
|
1444
|
+
out.append(a.split("=", 1)[1])
|
|
1445
|
+
elif a.startswith("-o") and len(a) > 2 and not a.startswith("--"):
|
|
1446
|
+
out.append(a[2:])
|
|
1447
|
+
return out
|
|
1448
|
+
if cmd in ("find", "gfind"):
|
|
1449
|
+
out = []
|
|
1450
|
+
for i, a in enumerate(args):
|
|
1451
|
+
if a in ("-fprint", "-fprint0", "-fprintf", "-fls") and i + 1 < len(args):
|
|
1452
|
+
out.append(args[i + 1])
|
|
1453
|
+
if "-delete" in args:
|
|
1454
|
+
for a in args:
|
|
1455
|
+
if a.startswith("-") or a in ("(", "!"):
|
|
1456
|
+
break
|
|
1457
|
+
out.append(a)
|
|
1458
|
+
return out
|
|
1459
|
+
return []
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def _opt_value(args, i, names):
|
|
1463
|
+
"""Value of args[i] when it is one of names: `--opt value`, `--opt=value`,
|
|
1464
|
+
or `-oVALUE` attached. Returns (value, consumed) or (None, 1)."""
|
|
1465
|
+
a = args[i]
|
|
1466
|
+
for n in names:
|
|
1467
|
+
if a == n:
|
|
1468
|
+
return (args[i + 1] if i + 1 < len(args) else None), 2
|
|
1469
|
+
if n.startswith("--") and a.startswith(n + "="):
|
|
1470
|
+
return a.split("=", 1)[1], 1
|
|
1471
|
+
if not n.startswith("--") and len(n) == 2 and a.startswith(n) and len(a) > 2 and not a.startswith("--"):
|
|
1472
|
+
return a[2:], 1
|
|
1473
|
+
return None, 1
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def extractor_write_targets(toks):
|
|
1477
|
+
"""Write destinations of curl -o, tar -C, unzip -d, git checkout -- / restore,
|
|
1478
|
+
patch -o - the tools whose write target is an option value, not a positional."""
|
|
1479
|
+
if not toks:
|
|
1480
|
+
return []
|
|
1481
|
+
base = os.path.basename(toks[0]).lower()
|
|
1482
|
+
args = toks[1:]
|
|
1483
|
+
out = []
|
|
1484
|
+
if base == "curl":
|
|
1485
|
+
i = 0
|
|
1486
|
+
while i < len(args):
|
|
1487
|
+
v, step = _opt_value(args, i, ("-o", "--output", "--output-dir"))
|
|
1488
|
+
if v is not None and (args[i] in ("-o", "--output", "--output-dir") or args[i].startswith("--output") or (args[i].startswith("-o") and len(args[i]) > 2)):
|
|
1489
|
+
out.append(v)
|
|
1490
|
+
i += step
|
|
1491
|
+
return out
|
|
1492
|
+
if base in ("tar", "gtar", "bsdtar"):
|
|
1493
|
+
i = 0
|
|
1494
|
+
while i < len(args):
|
|
1495
|
+
v, step = _opt_value(args, i, ("-C", "--directory"))
|
|
1496
|
+
if v is not None and (args[i] in ("-C", "--directory") or args[i].startswith("--directory=") or (args[i].startswith("-C") and len(args[i]) > 2)):
|
|
1497
|
+
out.append(v)
|
|
1498
|
+
i += step
|
|
1499
|
+
return out
|
|
1500
|
+
if base == "unzip":
|
|
1501
|
+
for i, a in enumerate(args):
|
|
1502
|
+
if a == "-d" and i + 1 < len(args):
|
|
1503
|
+
out.append(args[i + 1])
|
|
1504
|
+
elif a.startswith("-d") and len(a) > 2:
|
|
1505
|
+
out.append(a[2:])
|
|
1506
|
+
return out
|
|
1507
|
+
if base in ("7z", "7za", "7zr"):
|
|
1508
|
+
for a in args:
|
|
1509
|
+
if a.startswith("-o") and len(a) > 2:
|
|
1510
|
+
out.append(a[2:])
|
|
1511
|
+
return out
|
|
1512
|
+
if base in ("diff", "gdiff"):
|
|
1513
|
+
for a in args:
|
|
1514
|
+
if a.startswith("--to-file="):
|
|
1515
|
+
out.append(a.split("=", 1)[1])
|
|
1516
|
+
return out
|
|
1517
|
+
if base == "xcodebuild":
|
|
1518
|
+
opts = ("-derivedDataPath", "-resultBundlePath", "-exportPath", "-archivePath")
|
|
1519
|
+
i = 0
|
|
1520
|
+
while i < len(args):
|
|
1521
|
+
if args[i] in opts and i + 1 < len(args):
|
|
1522
|
+
out.append(args[i + 1])
|
|
1523
|
+
i += 2
|
|
1524
|
+
continue
|
|
1525
|
+
i += 1
|
|
1526
|
+
return out
|
|
1527
|
+
if base == "swift":
|
|
1528
|
+
opts = ("--scratch-path", "--build-path", "--cache-path", "--package-path")
|
|
1529
|
+
i = 0
|
|
1530
|
+
while i < len(args):
|
|
1531
|
+
a = args[i]
|
|
1532
|
+
for o in opts:
|
|
1533
|
+
if a == o and i + 1 < len(args):
|
|
1534
|
+
out.append(args[i + 1])
|
|
1535
|
+
i += 1
|
|
1536
|
+
elif a.startswith(o + "="):
|
|
1537
|
+
out.append(a.split("=", 1)[1])
|
|
1538
|
+
i += 1
|
|
1539
|
+
return out
|
|
1540
|
+
if base == "patch":
|
|
1541
|
+
for i, a in enumerate(args):
|
|
1542
|
+
if a in ("-o", "--output") and i + 1 < len(args):
|
|
1543
|
+
out.append(args[i + 1])
|
|
1544
|
+
elif a.startswith("--output="):
|
|
1545
|
+
out.append(a.split("=", 1)[1])
|
|
1546
|
+
plain = [a for a in args if not a.startswith("-")]
|
|
1547
|
+
out.extend(plain)
|
|
1548
|
+
return out
|
|
1549
|
+
if base == "git":
|
|
1550
|
+
sub, rest = _git_sub(toks)
|
|
1551
|
+
plain = [a for a in rest if not a.startswith("-")]
|
|
1552
|
+
if sub == "restore":
|
|
1553
|
+
return plain
|
|
1554
|
+
if sub == "checkout" and "--" in rest:
|
|
1555
|
+
k = rest.index("--")
|
|
1556
|
+
return [a for a in rest[k + 1:] if not a.startswith("-")]
|
|
1557
|
+
# checkout <ref> <path...>: everything after the first plain (the ref).
|
|
1558
|
+
if sub == "checkout" and len(plain) >= 2:
|
|
1559
|
+
out.extend(plain[1:])
|
|
1560
|
+
if sub == "mv" and len(plain) >= 2:
|
|
1561
|
+
out.append(plain[-1])
|
|
1562
|
+
# clone / init / worktree add write into a destination directory the run
|
|
1563
|
+
# would then own; refusing a protected destination stops seeding .claude.
|
|
1564
|
+
if sub == "clone" and len(plain) >= 2:
|
|
1565
|
+
out.append(plain[-1])
|
|
1566
|
+
if sub == "init" and plain:
|
|
1567
|
+
out.append(plain[-1])
|
|
1568
|
+
if sub == "worktree" and len(plain) >= 2 and plain[0] == "add":
|
|
1569
|
+
out.append(plain[1])
|
|
1570
|
+
if sub == "read-tree" and "--prefix" in " ".join(rest):
|
|
1571
|
+
for a in rest:
|
|
1572
|
+
if a.startswith("--prefix="):
|
|
1573
|
+
out.append(a.split("=", 1)[1])
|
|
1574
|
+
if sub == "checkout-index":
|
|
1575
|
+
for a in rest:
|
|
1576
|
+
if a.startswith("--prefix="):
|
|
1577
|
+
out.append(a.split("=", 1)[1])
|
|
1578
|
+
elif a == "--prefix":
|
|
1579
|
+
j = rest.index(a)
|
|
1580
|
+
if j + 1 < len(rest):
|
|
1581
|
+
out.append(rest[j + 1])
|
|
1582
|
+
if sub in ("apply", "am"):
|
|
1583
|
+
for a in rest:
|
|
1584
|
+
if a.startswith("--directory="):
|
|
1585
|
+
out.append(a.split("=", 1)[1])
|
|
1586
|
+
elif a == "--directory":
|
|
1587
|
+
j = rest.index(a)
|
|
1588
|
+
if j + 1 < len(rest):
|
|
1589
|
+
out.append(rest[j + 1])
|
|
1590
|
+
if sub in ("archive", "bundle"):
|
|
1591
|
+
for a in rest:
|
|
1592
|
+
if a.startswith("--output="):
|
|
1593
|
+
out.append(a.split("=", 1)[1])
|
|
1594
|
+
elif a in ("-o", "--output"):
|
|
1595
|
+
j = rest.index(a)
|
|
1596
|
+
if j + 1 < len(rest):
|
|
1597
|
+
out.append(rest[j + 1])
|
|
1598
|
+
if sub == "bundle" and plain and plain[0] == "create" and len(plain) >= 2:
|
|
1599
|
+
out.append(plain[1])
|
|
1600
|
+
names = ("--output", "--output-directory") + (("-o",) if sub == "format-patch" else ())
|
|
1601
|
+
i = 0
|
|
1602
|
+
while i < len(rest):
|
|
1603
|
+
v, step = _opt_value(rest, i, names)
|
|
1604
|
+
if v is not None:
|
|
1605
|
+
out.append(v)
|
|
1606
|
+
i += step
|
|
1607
|
+
return out
|
|
1608
|
+
return []
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
# --- commands -----------------------------------------------------------------
|
|
1612
|
+
|
|
1613
|
+
def _git_sub(toks):
|
|
1614
|
+
"""The git subcommand and its remaining args, skipping git's global options.
|
|
1615
|
+
Refusal of dangerous `-c` values is handled by git_config_refusal."""
|
|
1616
|
+
i = 1
|
|
1617
|
+
while i < len(toks):
|
|
1618
|
+
t = toks[i]
|
|
1619
|
+
if t in ("-C", "-c", "--git-dir", "--work-tree", "--exec-path", "--namespace"):
|
|
1620
|
+
i += 2
|
|
1621
|
+
elif t.startswith("-"):
|
|
1622
|
+
i += 1
|
|
1623
|
+
else:
|
|
1624
|
+
return t.lower(), toks[i + 1:]
|
|
1625
|
+
return "", []
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
# Config keys that make git run a program, load more config, or send somewhere
|
|
1629
|
+
# else. One list for `git -c`, `git --config-env`, `git clone -c` and
|
|
1630
|
+
# `git config`.
|
|
1631
|
+
GIT_EXEC_CONFIG = re.compile(
|
|
1632
|
+
r"^(alias\..*"
|
|
1633
|
+
r"|core\.(hookspath|sshcommand|fsmonitor|editor|pager|askpass|gitproxy|alternaterefscommand|worktree|attributesfile|excludesfile)"
|
|
1634
|
+
r"|sequence\.editor|diff\.external|diff\..*\.(command|textconv)|difftool\..*|mergetool\..*|merge\..*\.driver"
|
|
1635
|
+
r"|filter\..*|credential\..*|gpg\..*|url\..*\.(insteadof|pushinsteadof)|include\..*|includeif\..*"
|
|
1636
|
+
r"|http\..*|remote\..*|branch\..*\.(remote|pushremote)|protocol\..*|pager\..*|interactive\.difffilter"
|
|
1637
|
+
r"|uploadpack\..*|receive\..*|ssh\.variant|init\.templatedir|submodule\..*|trailer\..*|hook\..*"
|
|
1638
|
+
r"|man\..*|browser\..*|web\.browser|instaweb\..*|sendemail\..*|safe\.directory|color\.pager)$",
|
|
1639
|
+
re.I,
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
def _git_exec_key(val):
|
|
1644
|
+
key = val.split("=", 1)[0]
|
|
1645
|
+
return key if GIT_EXEC_CONFIG.match(key) else None
|
|
1646
|
+
|
|
1647
|
+
|
|
1648
|
+
def git_config_refusal(toks):
|
|
1649
|
+
"""git -c KEY=VALUE / --config-env=KEY=ENV with a key that runs a program
|
|
1650
|
+
or redirects git, before the subcommand."""
|
|
1651
|
+
i = 1
|
|
1652
|
+
while i < len(toks):
|
|
1653
|
+
t = toks[i]
|
|
1654
|
+
val = None
|
|
1655
|
+
if t == "-c" and i + 1 < len(toks):
|
|
1656
|
+
val = toks[i + 1]
|
|
1657
|
+
i += 2
|
|
1658
|
+
elif t.startswith("-c") and len(t) > 2:
|
|
1659
|
+
val = t[2:]
|
|
1660
|
+
i += 1
|
|
1661
|
+
elif t == "--config-env" and i + 1 < len(toks):
|
|
1662
|
+
val = toks[i + 1]
|
|
1663
|
+
i += 2
|
|
1664
|
+
elif t.startswith("--config-env="):
|
|
1665
|
+
val = t.split("=", 1)[1]
|
|
1666
|
+
i += 1
|
|
1667
|
+
else:
|
|
1668
|
+
if t.startswith("-"):
|
|
1669
|
+
i += 1
|
|
1670
|
+
if t in ("-C", "--git-dir", "--work-tree", "--exec-path", "--namespace"):
|
|
1671
|
+
i += 1
|
|
1672
|
+
continue
|
|
1673
|
+
break
|
|
1674
|
+
if val is None:
|
|
1675
|
+
continue
|
|
1676
|
+
key = _git_exec_key(val)
|
|
1677
|
+
if key:
|
|
1678
|
+
return f"git -c {key} reconfigures git for the whole run"
|
|
1679
|
+
return None
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
GIT_CONFIG_READS = {"--get", "--get-all", "--get-regexp", "--get-urlmatch", "--list", "-l", "--show-origin",
|
|
1683
|
+
"--show-scope", "--name-only", "get", "list"}
|
|
1684
|
+
GIT_CONFIG_WRITE_SUBS = {"set", "unset", "rename-section", "remove-section"}
|
|
1685
|
+
GIT_CONFIG_WRITE_OPTS = {"--unset", "--unset-all", "--add", "--replace-all", "--rename-section", "--remove-section"}
|
|
1686
|
+
|
|
1687
|
+
|
|
1688
|
+
def _git_config(rest):
|
|
1689
|
+
if any(a in ("-e", "--edit", "edit") for a in rest):
|
|
1690
|
+
return "git config --edit opens an editor on the config"
|
|
1691
|
+
if any(a in GIT_CONFIG_READS for a in rest):
|
|
1692
|
+
return None
|
|
1693
|
+
keys = [a for a in rest if not a.startswith("-")]
|
|
1694
|
+
writes = bool(keys and keys[0] in GIT_CONFIG_WRITE_SUBS)
|
|
1695
|
+
if writes:
|
|
1696
|
+
keys = keys[1:]
|
|
1697
|
+
writes = writes or len(keys) >= 2 or any(a in GIT_CONFIG_WRITE_OPTS for a in rest)
|
|
1698
|
+
if not writes:
|
|
1699
|
+
return None
|
|
1700
|
+
if any(a in ("--global", "--system") for a in rest):
|
|
1701
|
+
return "git config --global / --system writes this machine's git configuration"
|
|
1702
|
+
for k in keys[:2]:
|
|
1703
|
+
if GIT_EXEC_CONFIG.match(k):
|
|
1704
|
+
return f"git config {k} changes what git runs or where it sends"
|
|
1705
|
+
return None
|
|
1706
|
+
|
|
1707
|
+
|
|
1708
|
+
# git subcommands that write to a remote or another version-control system.
|
|
1709
|
+
GIT_OUTWARD_SUBS = {
|
|
1710
|
+
"send-pack": "git send-pack writes to a remote",
|
|
1711
|
+
"http-push": "git http-push writes to a remote",
|
|
1712
|
+
"upload-archive": "git upload-archive serves this repository to a remote",
|
|
1713
|
+
"svn": "git svn writes to a Subversion remote",
|
|
1714
|
+
"p4": "git p4 writes to a Perforce remote",
|
|
1715
|
+
"imap-send": "git imap-send sends mail",
|
|
1716
|
+
"request-pull": "git request-pull is a publish step",
|
|
1717
|
+
"web--browse": "git web--browse opens a browser on a URL",
|
|
1718
|
+
"instaweb": "git instaweb starts a web server and opens a browser",
|
|
1719
|
+
"daemon": "git daemon serves this repository to the network",
|
|
1720
|
+
}
|
|
1721
|
+
# Global flags before the subcommand that move where git reads or writes, or
|
|
1722
|
+
# what it runs. --git-dir / --work-tree / --exec-path / --namespace are refused;
|
|
1723
|
+
# -C and -c are handled elsewhere (-c by git_config_refusal).
|
|
1724
|
+
GIT_GLOBAL_REFUSED = ("--git-dir", "--work-tree", "--exec-path", "--namespace")
|
|
1725
|
+
|
|
1726
|
+
|
|
1727
|
+
def git_global_flag_refusal(toks):
|
|
1728
|
+
"""A git global flag before the subcommand that redirects git's directories
|
|
1729
|
+
or its helper path."""
|
|
1730
|
+
for t in toks[1:]:
|
|
1731
|
+
if not t.startswith("-"):
|
|
1732
|
+
break
|
|
1733
|
+
key = t.split("=", 1)[0]
|
|
1734
|
+
if key in GIT_GLOBAL_REFUSED:
|
|
1735
|
+
return f"git {key} redirects where git reads, writes or finds its programs"
|
|
1736
|
+
return None
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
def git_credential_refusal(sub, rest):
|
|
1740
|
+
"""git credential / credential-<helper>: reading or writing the store."""
|
|
1741
|
+
if sub == "credential" or sub.startswith("credential-"):
|
|
1742
|
+
return f"git {sub} reads or writes the credential store"
|
|
1743
|
+
if sub == "lfs" and rest and rest[0] == "push":
|
|
1744
|
+
return "git lfs push writes to a remote"
|
|
1745
|
+
return None
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
def git_exec_refusal(sub, rest, toks):
|
|
1749
|
+
"""git subcommands and options that run a command line or a transport the
|
|
1750
|
+
guard cannot see."""
|
|
1751
|
+
if any(t.startswith(("ext::", "fd::")) for t in toks):
|
|
1752
|
+
return "an ext:: or fd:: git transport runs a command"
|
|
1753
|
+
if sub in GIT_OUTWARD_SUBS:
|
|
1754
|
+
return GIT_OUTWARD_SUBS[sub]
|
|
1755
|
+
r = git_credential_refusal(sub, rest)
|
|
1756
|
+
if r:
|
|
1757
|
+
return r
|
|
1758
|
+
if sub == "rebase":
|
|
1759
|
+
for a in rest:
|
|
1760
|
+
if a in ("--exec", "-x") or a.startswith("--exec=") or _short_cluster_has(a, "x"):
|
|
1761
|
+
return "git rebase --exec runs a command after each commit"
|
|
1762
|
+
if sub == "bisect" and rest and rest[0] == "run":
|
|
1763
|
+
return "git bisect run runs a command"
|
|
1764
|
+
if sub == "submodule" and "foreach" in rest:
|
|
1765
|
+
return "git submodule foreach runs a command in each submodule"
|
|
1766
|
+
if sub == "difftool":
|
|
1767
|
+
for a in rest:
|
|
1768
|
+
if a in ("-x", "--extcmd") or a.startswith("--extcmd=") or _short_cluster_has(a, "x"):
|
|
1769
|
+
return "git difftool --extcmd runs a command"
|
|
1770
|
+
if sub in ("filter-branch", "filter-repo"):
|
|
1771
|
+
return f"git {sub} runs filter commands"
|
|
1772
|
+
if sub == "grep":
|
|
1773
|
+
for a in rest:
|
|
1774
|
+
if a.startswith("-O") or a.startswith("--open-files-in-pager"):
|
|
1775
|
+
return "git grep -O runs a pager command"
|
|
1776
|
+
if sub == "maintenance" and rest and rest[0] in ("start", "register"):
|
|
1777
|
+
return f"git maintenance {rest[0]} installs a background job that runs git"
|
|
1778
|
+
if sub == "help" and any(a in ("-w", "--web") for a in rest):
|
|
1779
|
+
return "git help -w opens a browser"
|
|
1780
|
+
for a in rest:
|
|
1781
|
+
key = a.split("=", 1)[0]
|
|
1782
|
+
if key in ("--upload-pack", "--receive-pack", "--exec", "--template") or (sub == "clone" and a == "-u"):
|
|
1783
|
+
return f"git {sub} {key} runs a program or installs hooks"
|
|
1784
|
+
if sub == "clone":
|
|
1785
|
+
for i, a in enumerate(rest):
|
|
1786
|
+
val = None
|
|
1787
|
+
if a in ("-c", "--config") and i + 1 < len(rest):
|
|
1788
|
+
val = rest[i + 1]
|
|
1789
|
+
elif a.startswith("--config="):
|
|
1790
|
+
val = a.split("=", 1)[1]
|
|
1791
|
+
if val and _git_exec_key(val):
|
|
1792
|
+
return f"git clone -c {_git_exec_key(val)} reconfigures the new repository"
|
|
1793
|
+
return None
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
# git subcommands that reach a remote host: the URL argument (or the configured
|
|
1797
|
+
# remote) must be on the allowlist, the same rule curl and gh follow.
|
|
1798
|
+
GIT_NETWORK_SUBS = {"fetch", "clone", "pull", "ls-remote", "archive", "bundle", "remote-https", "remote-http"}
|
|
1799
|
+
|
|
1800
|
+
|
|
1801
|
+
def git_url_refusal(sub, rest, toks, policy, env):
|
|
1802
|
+
"""A git subcommand that contacts a remote, checked against the network
|
|
1803
|
+
allowlist. A literal URL argument is host-checked; a bare remote name or no
|
|
1804
|
+
argument (fetch origin, pull) is left to the configured remote, which the
|
|
1805
|
+
guard does not resolve, so it passes here and the sandbox confines it."""
|
|
1806
|
+
urls = []
|
|
1807
|
+
if sub in GIT_NETWORK_SUBS:
|
|
1808
|
+
for a in rest:
|
|
1809
|
+
if "://" in a or a.startswith("git@") or re.match(r"^[a-z]+::", a):
|
|
1810
|
+
urls.append(a)
|
|
1811
|
+
if sub == "submodule" and rest and rest[0] == "add":
|
|
1812
|
+
for a in rest[1:]:
|
|
1813
|
+
if "://" in a or a.startswith("git@"):
|
|
1814
|
+
urls.append(a)
|
|
1815
|
+
if sub == "remote" and rest and rest[0] in ("add", "set-url"):
|
|
1816
|
+
for a in rest[1:]:
|
|
1817
|
+
if "://" in a or a.startswith("git@"):
|
|
1818
|
+
urls.append(a)
|
|
1819
|
+
for a in rest:
|
|
1820
|
+
if a.startswith("--remote="):
|
|
1821
|
+
v = a.split("=", 1)[1]
|
|
1822
|
+
if "://" in v or v.startswith("git@"):
|
|
1823
|
+
urls.append(v)
|
|
1824
|
+
for u in urls:
|
|
1825
|
+
if u.startswith("git@") or re.match(r"^[a-z]+::", u):
|
|
1826
|
+
return f"git {sub} over ssh or a custom transport ({u[:40]}) cannot be host-checked; refused unattended"
|
|
1827
|
+
r = check_url_host(u, policy, env, f"git {sub}")
|
|
1828
|
+
if r:
|
|
1829
|
+
return r
|
|
1830
|
+
return None
|
|
1831
|
+
|
|
1832
|
+
|
|
1833
|
+
GH_WRITES = {
|
|
1834
|
+
"pr": {"create", "merge", "ready", "edit", "comment", "review", "close", "reopen", "lock", "unlock"},
|
|
1835
|
+
"issue": {"create", "close", "edit", "comment", "reopen", "delete", "transfer", "lock", "unlock", "pin", "unpin", "develop"},
|
|
1836
|
+
"release": {"create", "edit", "delete", "upload", "delete-asset"},
|
|
1837
|
+
"repo": {"create", "edit", "delete", "fork", "rename", "archive", "unarchive", "sync", "deploy-key", "set-default"},
|
|
1838
|
+
"workflow": {"run", "enable", "disable"},
|
|
1839
|
+
"run": {"rerun", "cancel", "delete"},
|
|
1840
|
+
"label": {"create", "edit", "delete", "clone"},
|
|
1841
|
+
"gist": {"create", "edit", "delete", "clone"},
|
|
1842
|
+
"secret": None,
|
|
1843
|
+
"variable": None,
|
|
1844
|
+
"ruleset": None,
|
|
1845
|
+
"alias": None,
|
|
1846
|
+
"ssh-key": None,
|
|
1847
|
+
"gpg-key": None,
|
|
1848
|
+
"extension": None,
|
|
1849
|
+
"codespace": None,
|
|
1850
|
+
"cache": {"delete"},
|
|
1851
|
+
"project": {"create", "edit", "delete", "close", "copy", "field-create", "field-delete", "item-add", "item-archive", "item-create", "item-delete", "item-edit", "link", "unlink", "mark-template"},
|
|
1852
|
+
}
|
|
1853
|
+
GH_API_FIELD_FLAGS = {"-f", "-F", "--field", "--raw-field", "--input"}
|
|
1854
|
+
|
|
1855
|
+
|
|
1856
|
+
def _is_field_flag(t):
|
|
1857
|
+
if t in GH_API_FIELD_FLAGS:
|
|
1858
|
+
return True
|
|
1859
|
+
if any(t.startswith(f + "=") for f in GH_API_FIELD_FLAGS if f.startswith("--")):
|
|
1860
|
+
return True
|
|
1861
|
+
# attached short forms: -ftitle=x, -Fbody=@f
|
|
1862
|
+
return bool(re.match(r"^-[fF].", t))
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
# gh's built-in command aliases, so `gh cs` is judged as `gh codespace`.
|
|
1866
|
+
GH_ALIASES = {
|
|
1867
|
+
"cs": "codespace", "rs": "release", "ext": "extension", "co": "pr",
|
|
1868
|
+
"pr": "pr", "i": "issue", "r": "repo", "w": "workflow",
|
|
1869
|
+
}
|
|
1870
|
+
# gh config keys that hold or point at a credential; reading one prints a secret.
|
|
1871
|
+
GH_TOKEN_KEYS = {"oauth_token", "token"}
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
def _gh(toks, policy=None, env=None):
|
|
1875
|
+
args = toks[1:]
|
|
1876
|
+
# Capture -R/--repo (dropped) and --hostname (dropped from the verb scan but
|
|
1877
|
+
# host-checked). A --hostname off the allowlist is an exfiltration target.
|
|
1878
|
+
cleaned, hostname, i = [], None, 0
|
|
1879
|
+
while i < len(args):
|
|
1880
|
+
a = args[i]
|
|
1881
|
+
if a in ("-R", "--repo"):
|
|
1882
|
+
i += 2
|
|
1883
|
+
continue
|
|
1884
|
+
if a.startswith("--repo="):
|
|
1885
|
+
i += 1
|
|
1886
|
+
continue
|
|
1887
|
+
if a == "--hostname" and i + 1 < len(args):
|
|
1888
|
+
hostname = args[i + 1]
|
|
1889
|
+
i += 2
|
|
1890
|
+
continue
|
|
1891
|
+
if a.startswith("--hostname="):
|
|
1892
|
+
hostname = a.split("=", 1)[1]
|
|
1893
|
+
i += 1
|
|
1894
|
+
continue
|
|
1895
|
+
cleaned.append(a)
|
|
1896
|
+
i += 1
|
|
1897
|
+
if hostname and policy is not None:
|
|
1898
|
+
r = check_url_host(hostname, policy, env, "gh --hostname")
|
|
1899
|
+
if r:
|
|
1900
|
+
return r
|
|
1901
|
+
plains = [a for a in cleaned if not a.startswith("-")]
|
|
1902
|
+
if not plains:
|
|
1903
|
+
return None
|
|
1904
|
+
group = GH_ALIASES.get(plains[0], plains[0])
|
|
1905
|
+
if group == "api":
|
|
1906
|
+
return _gh_api(cleaned[cleaned.index(plains[0]) + 1:], policy, env)
|
|
1907
|
+
if group == "auth":
|
|
1908
|
+
sub = plains[1] if len(plains) > 1 else ""
|
|
1909
|
+
if sub == "token" or "--show-token" in cleaned or "-t" in cleaned:
|
|
1910
|
+
return "gh auth token prints a credential"
|
|
1911
|
+
if sub == "status":
|
|
1912
|
+
return None
|
|
1913
|
+
return f"gh auth {sub}".rstrip() + " reads or changes this machine's GitHub credentials"
|
|
1914
|
+
if group == "config":
|
|
1915
|
+
sub = plains[1] if len(plains) > 1 else ""
|
|
1916
|
+
if sub == "set":
|
|
1917
|
+
return "gh config set changes the GitHub CLI's configuration"
|
|
1918
|
+
if sub == "get":
|
|
1919
|
+
if any(k in GH_TOKEN_KEYS for k in plains[2:]):
|
|
1920
|
+
return "gh config get of a token prints a credential"
|
|
1921
|
+
return None
|
|
1922
|
+
if group not in GH_WRITES:
|
|
1923
|
+
return None
|
|
1924
|
+
verbs = GH_WRITES[group]
|
|
1925
|
+
sub = plains[1] if len(plains) > 1 else ""
|
|
1926
|
+
if verbs is None or sub in verbs:
|
|
1927
|
+
return f"gh {group} {sub}".strip() + " writes to GitHub; the runner is the only writer"
|
|
1928
|
+
return None
|
|
1929
|
+
|
|
1930
|
+
|
|
1931
|
+
def _gh_api(rest, policy=None, env=None):
|
|
1932
|
+
method = "GET"
|
|
1933
|
+
fields = False
|
|
1934
|
+
endpoint = ""
|
|
1935
|
+
field_values = []
|
|
1936
|
+
i = 0
|
|
1937
|
+
while i < len(rest):
|
|
1938
|
+
t = rest[i]
|
|
1939
|
+
if t in ("-X", "--method"):
|
|
1940
|
+
method = (rest[i + 1] if i + 1 < len(rest) else "").upper()
|
|
1941
|
+
i += 2
|
|
1942
|
+
continue
|
|
1943
|
+
if t.startswith("--method="):
|
|
1944
|
+
method = t.split("=", 1)[1].upper()
|
|
1945
|
+
elif t.startswith("-X") and len(t) > 2:
|
|
1946
|
+
method = t[2:].upper()
|
|
1947
|
+
elif _is_field_flag(t):
|
|
1948
|
+
fields = True
|
|
1949
|
+
if t in GH_API_FIELD_FLAGS:
|
|
1950
|
+
if i + 1 < len(rest):
|
|
1951
|
+
field_values.append(rest[i + 1])
|
|
1952
|
+
i += 2
|
|
1953
|
+
continue
|
|
1954
|
+
field_values.append(t.split("=", 1)[1] if "=" in t else t[2:])
|
|
1955
|
+
elif not t.startswith("-") and not endpoint:
|
|
1956
|
+
endpoint = t
|
|
1957
|
+
i += 1
|
|
1958
|
+
# An endpoint given as a full URL goes to whatever host it names, not
|
|
1959
|
+
# api.github.com; host-check it.
|
|
1960
|
+
if "://" in endpoint and policy is not None:
|
|
1961
|
+
r = check_url_host(endpoint, policy, env, "gh api")
|
|
1962
|
+
if r:
|
|
1963
|
+
return r
|
|
1964
|
+
if endpoint == "graphql":
|
|
1965
|
+
joined = " ".join(rest).lower()
|
|
1966
|
+
if any(v.startswith("@") or "=@" in v for v in field_values):
|
|
1967
|
+
return "gh api graphql with an @file field reads a file into the mutation the guard cannot see"
|
|
1968
|
+
if re.search(r"\bmutation\b", joined) or "--input" in rest:
|
|
1969
|
+
return "gh api graphql mutation writes to GitHub"
|
|
1970
|
+
return None
|
|
1971
|
+
explicit = any(t in ("-X", "--method") or t.startswith("--method=") or (t.startswith("-X") and len(t) > 2) for t in rest)
|
|
1972
|
+
if method not in ("GET", "HEAD") or (fields and not explicit):
|
|
1973
|
+
return f"gh api with method {method if explicit else 'POST (fields imply it)'} writes to GitHub"
|
|
1974
|
+
return None
|
|
1975
|
+
|
|
1976
|
+
|
|
1977
|
+
CURL_VALUE_FLAGS = {"-o", "--output", "-H", "--header", "-u", "--user", "-A", "--user-agent", "-e", "--referer",
|
|
1978
|
+
"-X", "--request", "-m", "--max-time", "--connect-timeout", "-w", "--write-out", "-K", "--config",
|
|
1979
|
+
"-x", "--proxy", "-b", "--cookie", "-c", "--cookie-jar", "--retry", "-r", "--range", "--cacert",
|
|
1980
|
+
"--cert", "--key", "-E", "--resolve", "--connect-to", "-O", "--output-dir", "--limit-rate", "-Y", "-y",
|
|
1981
|
+
"--unix-socket", "--doh-url"}
|
|
1982
|
+
CURL_REFUSED_FLAGS = {"-x", "--proxy", "--connect-to", "--resolve", "-K", "--config", "--unix-socket", "--doh-url"}
|
|
1983
|
+
WGET_WRITE_FLAGS = {"--post-data", "--post-file", "--body-data", "--body-file", "--method"}
|
|
1984
|
+
CURL_BODY_LONG = {"--data", "--data-raw", "--data-binary", "--data-urlencode", "--data-ascii", "--json", "--form", "--form-string", "--upload-file"}
|
|
1985
|
+
|
|
1986
|
+
|
|
1987
|
+
def _fetch(toks, policy, env=None):
|
|
1988
|
+
tool = os.path.basename(toks[0]).lower()
|
|
1989
|
+
args = toks[1:]
|
|
1990
|
+
if tool == "curl":
|
|
1991
|
+
method = "GET"
|
|
1992
|
+
for a in args:
|
|
1993
|
+
if a in CURL_REFUSED_FLAGS or a.split("=", 1)[0] in CURL_REFUSED_FLAGS:
|
|
1994
|
+
return f"curl {a.split('=', 1)[0]} redirects the connection somewhere the allowlist cannot check"
|
|
1995
|
+
for i, t in enumerate(args):
|
|
1996
|
+
if t in ("-X", "--request") and i + 1 < len(args):
|
|
1997
|
+
method = args[i + 1].upper()
|
|
1998
|
+
elif t.startswith("-X") and len(t) > 2 and not t.startswith("--"):
|
|
1999
|
+
method = t[2:].upper()
|
|
2000
|
+
elif t.startswith("--request="):
|
|
2001
|
+
method = t.split("=", 1)[1].upper()
|
|
2002
|
+
body = t in CURL_BODY_LONG or any(t.startswith(f + "=") for f in CURL_BODY_LONG)
|
|
2003
|
+
if not body and t.startswith("-") and not t.startswith("--"):
|
|
2004
|
+
# short cluster: -d, -F, -T, and attached forms -d@file, -Tfile
|
|
2005
|
+
if any(ch in t[1:] for ch in ("d", "F", "T")):
|
|
2006
|
+
body = True
|
|
2007
|
+
if body:
|
|
2008
|
+
return "curl with a request body or upload writes to a remote service; the runner is the only writer"
|
|
2009
|
+
if method not in ("GET", "HEAD"):
|
|
2010
|
+
return f"curl -X {method} writes to a remote service; the runner is the only writer"
|
|
2011
|
+
urls, skip = [], False
|
|
2012
|
+
for i, t in enumerate(args):
|
|
2013
|
+
if skip:
|
|
2014
|
+
skip = False
|
|
2015
|
+
continue
|
|
2016
|
+
if t == "--url" and i + 1 < len(args):
|
|
2017
|
+
urls.append(args[i + 1])
|
|
2018
|
+
skip = True
|
|
2019
|
+
continue
|
|
2020
|
+
if t in CURL_VALUE_FLAGS:
|
|
2021
|
+
skip = True
|
|
2022
|
+
continue
|
|
2023
|
+
if not t.startswith("-"):
|
|
2024
|
+
urls.append(t)
|
|
2025
|
+
else:
|
|
2026
|
+
if any(t in WGET_WRITE_FLAGS or t.split("=", 1)[0] in WGET_WRITE_FLAGS for t in args):
|
|
2027
|
+
return "wget with a request body writes to a remote service; the runner is the only writer"
|
|
2028
|
+
urls = [t for t in args if not t.startswith("-")]
|
|
2029
|
+
if not urls:
|
|
2030
|
+
return f"{tool} with no literal URL: the host cannot be checked against the allowlist"
|
|
2031
|
+
for u in urls:
|
|
2032
|
+
r = check_url_host(u, policy, env, tool)
|
|
2033
|
+
if r:
|
|
2034
|
+
return r
|
|
2035
|
+
return None
|
|
2036
|
+
|
|
2037
|
+
|
|
2038
|
+
def _hosts(policy, env=None):
|
|
2039
|
+
hosts = set(h.lower() for h in policy.get("network", {}).get("staticHosts", []))
|
|
2040
|
+
env = os.environ if env is None else env
|
|
2041
|
+
snap = env.get("MA_UNATTENDED_HOSTS")
|
|
2042
|
+
if snap:
|
|
2043
|
+
for h in snap.split(","):
|
|
2044
|
+
if h.strip():
|
|
2045
|
+
hosts.add(h.strip().lower())
|
|
2046
|
+
return hosts
|
|
2047
|
+
prefs = env.get("MULTI_AGENT_PREFS") or os.path.join(home(), ".claude", "multi-agent-preferences.json")
|
|
2048
|
+
try:
|
|
2049
|
+
with open(prefs, encoding="utf-8") as f:
|
|
2050
|
+
configured = ((json.load(f).get("global") or {}).get("hosts") or {})
|
|
2051
|
+
for key in policy.get("network", {}).get("prefsHostKeys", []):
|
|
2052
|
+
v = configured.get(key)
|
|
2053
|
+
if isinstance(v, str) and v.strip():
|
|
2054
|
+
hosts.add(re.sub(r"^[a-z]+://", "", v.strip().lower()).split("/")[0].split(":")[0])
|
|
2055
|
+
except (OSError, ValueError, AttributeError):
|
|
2056
|
+
pass
|
|
2057
|
+
return hosts
|
|
2058
|
+
|
|
2059
|
+
|
|
2060
|
+
def _host_of(url):
|
|
2061
|
+
if "$" in url or "`" in url:
|
|
2062
|
+
return None
|
|
2063
|
+
try:
|
|
2064
|
+
parts = urlsplit(url if "://" in url else "//" + url, scheme="https")
|
|
2065
|
+
except ValueError:
|
|
2066
|
+
return None
|
|
2067
|
+
host = parts.hostname
|
|
2068
|
+
return host.lower() if host else None
|
|
2069
|
+
|
|
2070
|
+
|
|
2071
|
+
def _allowed_host(host, allowed):
|
|
2072
|
+
return any(host == a or host.endswith("." + a) for a in allowed)
|
|
2073
|
+
|
|
2074
|
+
|
|
2075
|
+
def check_url_host(url, policy, env=None, tool="fetch"):
|
|
2076
|
+
if "$" in url or "`" in url:
|
|
2077
|
+
return f"{tool} to a URL built at run time: the host cannot be checked against the allowlist"
|
|
2078
|
+
host = _host_of(url)
|
|
2079
|
+
if not host:
|
|
2080
|
+
return f"{tool} target '{url[:60]}' is not a URL the allowlist can check"
|
|
2081
|
+
if not _allowed_host(host, _hosts(policy, env)):
|
|
2082
|
+
return f"{tool} to {host}: not in the unattended network allowlist (unattended-policy.json + prefs.global.hosts)"
|
|
2083
|
+
return None
|
|
2084
|
+
|
|
2085
|
+
|
|
2086
|
+
# Tools that open a connection to a host the allowlist cannot check (an
|
|
2087
|
+
# arbitrary host, a raw socket, a name lookup that can carry data in the query).
|
|
2088
|
+
# Refused outright unattended; documented as residual in unattended-security.md.
|
|
2089
|
+
NETWORK_TOOLS = {
|
|
2090
|
+
"ssh": "ssh opens a connection to an arbitrary host and can run a command there",
|
|
2091
|
+
"scp": "scp copies over ssh to an arbitrary host",
|
|
2092
|
+
"sftp": "sftp transfers over ssh to an arbitrary host",
|
|
2093
|
+
"rsh": "rsh runs a command on a remote host",
|
|
2094
|
+
"telnet": "telnet opens a raw connection to an arbitrary host",
|
|
2095
|
+
"nc": "nc opens a raw socket the allowlist cannot check",
|
|
2096
|
+
"ncat": "ncat opens a raw socket the allowlist cannot check",
|
|
2097
|
+
"netcat": "netcat opens a raw socket the allowlist cannot check",
|
|
2098
|
+
"socat": "socat opens a raw socket the allowlist cannot check",
|
|
2099
|
+
"dig": "dig resolves a name and can carry data in the query",
|
|
2100
|
+
"nslookup": "nslookup resolves a name and can carry data in the query",
|
|
2101
|
+
"host": "host resolves a name and can carry data in the query",
|
|
2102
|
+
"drill": "drill resolves a name and can carry data in the query",
|
|
2103
|
+
"ping": "ping reaches an arbitrary host",
|
|
2104
|
+
"ping6": "ping6 reaches an arbitrary host",
|
|
2105
|
+
"aria2c": "aria2c downloads from an arbitrary host",
|
|
2106
|
+
"http": "httpie reaches an arbitrary host",
|
|
2107
|
+
"https": "httpie reaches an arbitrary host",
|
|
2108
|
+
"httpie": "httpie reaches an arbitrary host",
|
|
2109
|
+
"xh": "xh reaches an arbitrary host",
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
|
|
2113
|
+
def network_tool_refusal(base):
|
|
2114
|
+
return NETWORK_TOOLS.get(base)
|
|
2115
|
+
|
|
2116
|
+
|
|
2117
|
+
# --- installs -----------------------------------------------------------------
|
|
2118
|
+
|
|
2119
|
+
_GIT_CACHE = {}
|
|
2120
|
+
|
|
2121
|
+
|
|
2122
|
+
def _changed_files(cwd):
|
|
2123
|
+
key = os.path.realpath(cwd or ".")
|
|
2124
|
+
if key in _GIT_CACHE:
|
|
2125
|
+
return _GIT_CACHE[key]
|
|
2126
|
+
try:
|
|
2127
|
+
import subprocess
|
|
2128
|
+
|
|
2129
|
+
out = subprocess.run(["git", "-C", key, "diff", "--name-only", "HEAD"],
|
|
2130
|
+
capture_output=True, text=True, timeout=3)
|
|
2131
|
+
val = None if out.returncode != 0 else {os.path.basename(l.strip()) for l in out.stdout.splitlines() if l.strip()}
|
|
2132
|
+
except Exception:
|
|
2133
|
+
val = None
|
|
2134
|
+
_GIT_CACHE[key] = val
|
|
2135
|
+
return val
|
|
2136
|
+
|
|
2137
|
+
|
|
2138
|
+
def _manifest_changed(names, cwd):
|
|
2139
|
+
changed = _changed_files(cwd)
|
|
2140
|
+
if changed is None:
|
|
2141
|
+
return True
|
|
2142
|
+
return bool(changed & set(names))
|
|
2143
|
+
|
|
2144
|
+
|
|
2145
|
+
NODE_LOCKS = ["package.json", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb"]
|
|
2146
|
+
|
|
2147
|
+
|
|
2148
|
+
def _local_bin(name, cwd):
|
|
2149
|
+
"""node_modules/.bin/<name> in cwd or an ancestor, the way npx looks."""
|
|
2150
|
+
d = os.path.realpath(cwd or ".")
|
|
2151
|
+
while True:
|
|
2152
|
+
if os.path.exists(os.path.join(d, "node_modules", ".bin", name)):
|
|
2153
|
+
return True
|
|
2154
|
+
parent = os.path.dirname(d)
|
|
2155
|
+
if parent == d:
|
|
2156
|
+
return False
|
|
2157
|
+
d = parent
|
|
2158
|
+
|
|
2159
|
+
|
|
2160
|
+
def _npx(tool, args, cwd):
|
|
2161
|
+
"""npx / npm exec run a binary; when it is not already installed they
|
|
2162
|
+
fetch the package, and with no TTY they do it without asking. Only a
|
|
2163
|
+
binary present in node_modules/.bin passes."""
|
|
2164
|
+
spec = ""
|
|
2165
|
+
i = 0
|
|
2166
|
+
while i < len(args):
|
|
2167
|
+
a = args[i]
|
|
2168
|
+
if a in ("-y", "--yes") or a.startswith("--package") or a == "-p":
|
|
2169
|
+
return f"{tool} --yes / --package fetches a package that is not in the lockfile"
|
|
2170
|
+
if a in ("-c", "--call") or a.startswith("--call="):
|
|
2171
|
+
return f"{tool} --call runs a shell command the guard cannot see"
|
|
2172
|
+
if a == "--":
|
|
2173
|
+
spec = args[i + 1] if i + 1 < len(args) else ""
|
|
2174
|
+
break
|
|
2175
|
+
if not a.startswith("-"):
|
|
2176
|
+
spec = a
|
|
2177
|
+
break
|
|
2178
|
+
i += 1
|
|
2179
|
+
if not spec:
|
|
2180
|
+
return None
|
|
2181
|
+
name = spec[1:] if spec.startswith("@") else spec
|
|
2182
|
+
if "@" in name or ":" in name or name.startswith((".", "/")) and not spec.startswith("@"):
|
|
2183
|
+
return f"{tool} {spec[:40]} names a version, path or URL: it fetches or runs a package outside node_modules/.bin"
|
|
2184
|
+
if not _local_bin(spec.rsplit("/", 1)[-1], cwd):
|
|
2185
|
+
return f"{tool} {spec[:40]}: not installed in node_modules/.bin, so it would fetch the package"
|
|
2186
|
+
return None
|
|
2187
|
+
|
|
2188
|
+
|
|
2189
|
+
def _install(toks, cwd):
|
|
2190
|
+
tool = os.path.basename(toks[0]).lower()
|
|
2191
|
+
args = toks[1:]
|
|
2192
|
+
plain = [a for a in args if not a.startswith("-")]
|
|
2193
|
+
sub = plain[0] if plain else ""
|
|
2194
|
+
if tool in ("pnpx", "bunx", "uvx") or (tool in ("pnpm", "yarn") and sub == "dlx") or (tool == "bun" and sub == "x"):
|
|
2195
|
+
return f"{tool} {sub if tool in ('pnpm', 'yarn', 'bun') else ''}".rstrip() + " fetches and runs a package"
|
|
2196
|
+
if tool == "yarn" and sub == "global":
|
|
2197
|
+
return "yarn global changes this machine"
|
|
2198
|
+
if tool == "uv" and sub == "tool" and len(plain) > 1 and plain[1] in ("run", "install", "upgrade"):
|
|
2199
|
+
return f"uv tool {plain[1]} fetches a package"
|
|
2200
|
+
if tool == "pipx" and sub in ("run", "install", "inject", "upgrade", "reinstall", "install-all"):
|
|
2201
|
+
return f"pipx {sub} fetches a package"
|
|
2202
|
+
if tool == "npx" or (tool == "npm" and sub in ("exec", "x")):
|
|
2203
|
+
return _npx(tool, args if tool == "npx" else args[args.index(sub) + 1:], cwd)
|
|
2204
|
+
if tool in ("npm", "pnpm", "yarn", "bun"):
|
|
2205
|
+
adds = {"npm": ("install", "i", "in", "add", "isntall", "install-test", "it", "install-ci-test", "cit"),
|
|
2206
|
+
"pnpm": ("install", "i", "add"), "yarn": ("add",), "bun": ("install", "i", "add")}[tool]
|
|
2207
|
+
if sub in adds and (len(plain) > 1 or sub == "add"):
|
|
2208
|
+
return f"{tool} {sub} {plain[1] if len(plain) > 1 else ''}".rstrip() + " adds a package"
|
|
2209
|
+
if sub in ("update", "upgrade", "up", "dedupe", "upgrade-interactive"):
|
|
2210
|
+
return f"{tool} {sub} rewrites the lockfile"
|
|
2211
|
+
if "-g" in args or "--global" in args:
|
|
2212
|
+
return f"{tool} global install changes this machine"
|
|
2213
|
+
restores = sub in ("install", "i", "ci", "install-test", "it", "install-ci-test", "cit") or (tool == "yarn" and sub == "")
|
|
2214
|
+
if restores and _manifest_changed(NODE_LOCKS, cwd):
|
|
2215
|
+
return f"{tool} install after the run changed package.json or a lockfile"
|
|
2216
|
+
return None
|
|
2217
|
+
if tool in ("pip", "pip3") or (tool.startswith("python") and "-m" in args and "pip" in args):
|
|
2218
|
+
rest = args[args.index("pip") + 1:] if "pip" in args and tool.startswith("python") else args
|
|
2219
|
+
if rest and rest[0] == "install":
|
|
2220
|
+
reqs, pkgs, skip = [], [], False
|
|
2221
|
+
for i, a in enumerate(rest[1:]):
|
|
2222
|
+
if skip:
|
|
2223
|
+
skip = False
|
|
2224
|
+
continue
|
|
2225
|
+
if a in ("-r", "--requirement", "-c", "--constraint"):
|
|
2226
|
+
if i + 2 <= len(rest[1:]):
|
|
2227
|
+
reqs.append(rest[1:][i + 1])
|
|
2228
|
+
skip = True
|
|
2229
|
+
continue
|
|
2230
|
+
if a in ("-e", "--editable"):
|
|
2231
|
+
skip = True
|
|
2232
|
+
continue
|
|
2233
|
+
if not a.startswith("-") and a not in (".",):
|
|
2234
|
+
pkgs.append(a)
|
|
2235
|
+
if pkgs:
|
|
2236
|
+
return f"pip install {pkgs[0]} adds a package"
|
|
2237
|
+
if reqs and _manifest_changed([os.path.basename(r) for r in reqs] + ["pyproject.toml"], cwd):
|
|
2238
|
+
return "pip install -r after the run changed the requirements file"
|
|
2239
|
+
return None
|
|
2240
|
+
if tool in ("uv", "poetry", "pdm", "pipenv"):
|
|
2241
|
+
if sub == "add" or (tool == "uv" and sub == "pip" and len(plain) > 1 and plain[1] == "install") or (tool == "pipenv" and sub == "install" and len(plain) > 1):
|
|
2242
|
+
return f"{tool} {sub} adds a package"
|
|
2243
|
+
return None
|
|
2244
|
+
if tool == "pod":
|
|
2245
|
+
if sub == "update":
|
|
2246
|
+
return "pod update rewrites Podfile.lock"
|
|
2247
|
+
if sub == "install" and _manifest_changed(["Podfile", "Podfile.lock"], cwd):
|
|
2248
|
+
return "pod install after the run changed the Podfile"
|
|
2249
|
+
return None
|
|
2250
|
+
if tool == "gem" and sub == "install":
|
|
2251
|
+
return "gem install adds a package"
|
|
2252
|
+
if tool == "bundle" and sub in ("add", "update"):
|
|
2253
|
+
return f"bundle {sub} changes the Gemfile"
|
|
2254
|
+
if tool == "brew" and sub in ("install", "reinstall", "tap", "upgrade"):
|
|
2255
|
+
return f"brew {sub} changes this machine"
|
|
2256
|
+
if tool == "cargo" and sub in ("add", "install", "update"):
|
|
2257
|
+
return f"cargo {sub} adds or updates a package"
|
|
2258
|
+
if tool == "go" and sub == "get":
|
|
2259
|
+
return "go get adds a module"
|
|
2260
|
+
if tool == "go" and sub == "install" and any("@" in a for a in plain[1:]):
|
|
2261
|
+
return "go install <pkg>@<version> fetches a module"
|
|
2262
|
+
if tool == "swift" and sub == "package" and len(plain) > 1 and plain[1] in ("add-dependency", "update"):
|
|
2263
|
+
return f"swift package {plain[1]} changes Package.resolved"
|
|
2264
|
+
return None
|
|
2265
|
+
|
|
2266
|
+
|
|
2267
|
+
SECURITY_READS = re.compile(r"^(find-[a-z-]+|dump-keychain|export|dump-trust-settings|add-[a-z-]+password|set-[a-z-]+|delete-[a-z-]+password|import)$")
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
WEB_SCHEMES = ("http", "https", "ws", "wss", "ftp")
|
|
2271
|
+
|
|
2272
|
+
|
|
2273
|
+
def _web_url_refusal(url, policy, env, tool):
|
|
2274
|
+
"""Host-check a URL that a browser would load; an app's own deep-link
|
|
2275
|
+
scheme opens the app under test and passes."""
|
|
2276
|
+
m = re.match(r"^([A-Za-z][A-Za-z0-9+.-]*):", url)
|
|
2277
|
+
scheme = m.group(1).lower() if m else ""
|
|
2278
|
+
if scheme and scheme not in WEB_SCHEMES and "://" in url:
|
|
2279
|
+
return None
|
|
2280
|
+
return check_url_host(url, policy, env, tool)
|
|
2281
|
+
|
|
2282
|
+
|
|
2283
|
+
LAUNCHCTL_READS = {"list", "print", "print-cache", "print-disabled", "blame", "help", "version", "error", "examine"}
|
|
2284
|
+
DEFAULTS_READS = {"read", "read-type", "domains", "find", "help"}
|
|
2285
|
+
|
|
2286
|
+
|
|
2287
|
+
def host_command_refusal(base, toks, policy, env):
|
|
2288
|
+
"""Commands that change this machine or send outward without a write
|
|
2289
|
+
target the guard models: persistent jobs, preferences, distribution
|
|
2290
|
+
uploads, and URLs opened in a browser."""
|
|
2291
|
+
plains = [a for a in toks[1:] if not a.startswith("-")]
|
|
2292
|
+
if base == "crontab" and toks[1:] != ["-l"]:
|
|
2293
|
+
return "crontab installs a job that runs as the operator"
|
|
2294
|
+
if base == "launchctl" and not (plains and plains[0] in LAUNCHCTL_READS):
|
|
2295
|
+
return "launchctl changes which jobs this machine runs"
|
|
2296
|
+
if base == "defaults" and not (plains and plains[0] in DEFAULTS_READS):
|
|
2297
|
+
return "defaults writes this machine's preferences"
|
|
2298
|
+
if base in ("altool", "notarytool"):
|
|
2299
|
+
return f"{base} uploads to Apple or stores a credential"
|
|
2300
|
+
if base == "simctl" and plains and plains[0] == "openurl":
|
|
2301
|
+
return _web_url_refusal(plains[-1], policy, env, "simctl openurl") if len(plains) >= 3 else None
|
|
2302
|
+
if base == "open":
|
|
2303
|
+
for a in plains:
|
|
2304
|
+
if "://" not in a or not re.match(r"^(https?)://", a, re.I):
|
|
2305
|
+
return "open launches an application or a file the guard cannot judge"
|
|
2306
|
+
r = check_url_host(a, policy, env, "open")
|
|
2307
|
+
if r:
|
|
2308
|
+
return r
|
|
2309
|
+
return None if plains else "open with no URL cannot be judged"
|
|
2310
|
+
return None
|
|
2311
|
+
|
|
2312
|
+
|
|
2313
|
+
def _publisher(toks, policy):
|
|
2314
|
+
scripts = policy.get("publisherScripts", {})
|
|
2315
|
+
for i, t in enumerate(toks[:3]):
|
|
2316
|
+
base = os.path.basename(t)
|
|
2317
|
+
if base in scripts and not base.startswith("$"):
|
|
2318
|
+
subs = scripts[base]
|
|
2319
|
+
rest = [a for a in toks[i + 1:] if not a.startswith("-")]
|
|
2320
|
+
if subs is None or (rest and rest[0] in subs):
|
|
2321
|
+
return f"{base} publishes outward, reads a credential or changes device access; an unattended run may not"
|
|
2322
|
+
if rest and (_has_ph(rest[0]) or rest[0].startswith("$")):
|
|
2323
|
+
return f"{base} with a subcommand built at run time cannot be judged"
|
|
2324
|
+
return None
|
|
2325
|
+
|
|
2326
|
+
|
|
2327
|
+
# --- variables, cwd and write targets -----------------------------------------
|
|
2328
|
+
|
|
2329
|
+
VAR_RX = re.compile(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))")
|
|
2330
|
+
|
|
2331
|
+
|
|
2332
|
+
def expand(text, variables, env=None):
|
|
2333
|
+
"""text with $NAME / ${NAME} expanded from the variables this command set,
|
|
2334
|
+
then HOME, then the hook's environment. None when any part cannot be
|
|
2335
|
+
resolved: a substitution placeholder, an unknown variable, or a `$` form
|
|
2336
|
+
other than a plain name."""
|
|
2337
|
+
if _has_ph(text):
|
|
2338
|
+
return None
|
|
2339
|
+
env = os.environ if env is None else env
|
|
2340
|
+
unresolved = False
|
|
2341
|
+
|
|
2342
|
+
def sub(m):
|
|
2343
|
+
nonlocal unresolved
|
|
2344
|
+
name = m.group(1) or m.group(2)
|
|
2345
|
+
if name in variables:
|
|
2346
|
+
v = variables[name]
|
|
2347
|
+
elif name == "HOME":
|
|
2348
|
+
v = home()
|
|
2349
|
+
else:
|
|
2350
|
+
v = env.get(name)
|
|
2351
|
+
if v is None:
|
|
2352
|
+
unresolved = True
|
|
2353
|
+
return ""
|
|
2354
|
+
return v
|
|
2355
|
+
|
|
2356
|
+
out = VAR_RX.sub(sub, text)
|
|
2357
|
+
if unresolved or "$" in out:
|
|
2358
|
+
return None
|
|
2359
|
+
return out
|
|
2360
|
+
|
|
2361
|
+
|
|
2362
|
+
class Ctx:
|
|
2363
|
+
"""Per-command state: the cwd after any cd (None once it cannot be
|
|
2364
|
+
resolved), and the variables set by earlier simple assignments."""
|
|
2365
|
+
|
|
2366
|
+
def __init__(self, cwd, policy, env, variables=None, inners=None):
|
|
2367
|
+
self.cwd = cwd
|
|
2368
|
+
self.policy = policy
|
|
2369
|
+
self.env = env
|
|
2370
|
+
self.vars = dict(variables or {})
|
|
2371
|
+
self.inners = inners or []
|
|
2372
|
+
# Set per segment: whether the segment has a glob character outside
|
|
2373
|
+
# quotes, so a globbed write target can be told from a quoted one.
|
|
2374
|
+
self.unquoted_glob = False
|
|
2375
|
+
|
|
2376
|
+
def target(self, raw):
|
|
2377
|
+
"""Reason a write target is refused, or None."""
|
|
2378
|
+
path = raw.strip("'\"")
|
|
2379
|
+
if path in ("/dev/null", "/dev/stderr", "/dev/stdout") or path.startswith("&"):
|
|
2380
|
+
return None
|
|
2381
|
+
if self.unquoted_glob and any(ch in path for ch in GLOB_CHARS):
|
|
2382
|
+
return f"write target '{path[:60]}' is a glob pattern the shell expands to paths the guard cannot see"
|
|
2383
|
+
e = expand(path, self.vars, self.env)
|
|
2384
|
+
if e is None:
|
|
2385
|
+
return f"write target '{path[:60]}' is built at run time and cannot be checked"
|
|
2386
|
+
e = os.path.expanduser(e)
|
|
2387
|
+
if self.cwd is None and not os.path.isabs(e):
|
|
2388
|
+
return f"write target '{path[:60]}' is relative to a directory the guard could not resolve"
|
|
2389
|
+
return check_path(e, self.cwd, self.policy, self.env)
|
|
2390
|
+
|
|
2391
|
+
|
|
2392
|
+
def check_segment(seg, ctx):
|
|
2393
|
+
policy, env = ctx.policy, ctx.env
|
|
2394
|
+
r = dangerous_assignment(tokenize(seg))
|
|
2395
|
+
if r:
|
|
2396
|
+
return r
|
|
2397
|
+
toks = _strip_wrappers(tokenize(seg))
|
|
2398
|
+
if not toks:
|
|
2399
|
+
return None
|
|
2400
|
+
if "$" in toks[0]:
|
|
2401
|
+
e = expand(toks[0], ctx.vars, env)
|
|
2402
|
+
if e is None:
|
|
2403
|
+
return "the command name is built at run time and cannot be judged"
|
|
2404
|
+
toks = [e] + toks[1:]
|
|
2405
|
+
base = os.path.basename(toks[0]).lower()
|
|
2406
|
+
|
|
2407
|
+
def targets(ts):
|
|
2408
|
+
for t in ts:
|
|
2409
|
+
r = ctx.target(t)
|
|
2410
|
+
if r:
|
|
2411
|
+
return r
|
|
2412
|
+
return None
|
|
2413
|
+
|
|
2414
|
+
if base == "git":
|
|
2415
|
+
r = git_config_refusal(toks) or git_global_flag_refusal(toks)
|
|
2416
|
+
if r:
|
|
2417
|
+
return r
|
|
2418
|
+
sub, rest = _git_sub(toks)
|
|
2419
|
+
if _has_ph(sub) or sub.startswith("$"):
|
|
2420
|
+
return "a git subcommand built at run time cannot be judged"
|
|
2421
|
+
r = git_exec_refusal(sub, rest, toks)
|
|
2422
|
+
if r:
|
|
2423
|
+
return r
|
|
2424
|
+
r = git_url_refusal(sub, rest, toks, policy, env)
|
|
2425
|
+
if r:
|
|
2426
|
+
return r
|
|
2427
|
+
# git apply / am write files a diff names, which the guard cannot
|
|
2428
|
+
# enumerate. The run's own work is committed, not applied from a patch;
|
|
2429
|
+
# refused unattended, and the sandbox confines the paths if it ran.
|
|
2430
|
+
if sub in ("apply", "am") and not any(
|
|
2431
|
+
a.startswith("--directory") for a in rest
|
|
2432
|
+
):
|
|
2433
|
+
return f"git {sub} applies a diff that names files the guard cannot enumerate; refused unattended"
|
|
2434
|
+
if sub == "push":
|
|
2435
|
+
return "git push: the runner pushes after it verifies pr-request.json"
|
|
2436
|
+
if sub == "send-email":
|
|
2437
|
+
return "git send-email sends mail"
|
|
2438
|
+
if sub == "remote" and rest and rest[0] in ("add", "set-url", "rename", "remove", "rm", "set-head"):
|
|
2439
|
+
return f"git remote {rest[0]} changes where the runner would push"
|
|
2440
|
+
if sub == "config":
|
|
2441
|
+
r = _git_config(rest)
|
|
2442
|
+
if r:
|
|
2443
|
+
return r
|
|
2444
|
+
return targets(extractor_write_targets(toks))
|
|
2445
|
+
if base == "gh":
|
|
2446
|
+
plains = [a for a in toks[1:] if not a.startswith("-")]
|
|
2447
|
+
if any(_has_ph(a) or a.startswith("$") for a in plains[:2]):
|
|
2448
|
+
return "a gh command group or verb built at run time cannot be judged"
|
|
2449
|
+
return _gh(toks, policy, env)
|
|
2450
|
+
if base == "security" and len(toks) > 1:
|
|
2451
|
+
j = 1
|
|
2452
|
+
while j < len(toks) and toks[j].startswith("-"):
|
|
2453
|
+
j += 2 if toks[j] == "-p" else 1
|
|
2454
|
+
sub = toks[j] if j < len(toks) else ""
|
|
2455
|
+
if SECURITY_READS.match(sub):
|
|
2456
|
+
return f"security {sub} reads or writes the Keychain"
|
|
2457
|
+
if _has_ph(sub) or sub.startswith("$"):
|
|
2458
|
+
return "a security subcommand built at run time cannot be judged"
|
|
2459
|
+
r = network_tool_refusal(base)
|
|
2460
|
+
if r:
|
|
2461
|
+
return r
|
|
2462
|
+
if base == "rsync":
|
|
2463
|
+
# rsync src host:path (or host::module) transfers to a remote the
|
|
2464
|
+
# allowlist cannot check; a local `-e` remote shell is the same.
|
|
2465
|
+
for a in toks[1:]:
|
|
2466
|
+
if a.startswith("-"):
|
|
2467
|
+
continue
|
|
2468
|
+
if re.match(r"^[A-Za-z0-9_.-]+@?[A-Za-z0-9_.-]*:", a) and not os.path.isabs(a) and "://" not in a:
|
|
2469
|
+
return "rsync to a remote host cannot be checked against the allowlist; refused unattended"
|
|
2470
|
+
if any(a in ("-e", "--rsh") or a.startswith("--rsh=") or a.startswith("-e") and len(a) > 2 for a in toks[1:]):
|
|
2471
|
+
return "rsync -e runs a remote shell the guard cannot see"
|
|
2472
|
+
r = host_command_refusal(base, toks, policy, env)
|
|
2473
|
+
if r:
|
|
2474
|
+
return r
|
|
2475
|
+
if base in ("curl", "wget"):
|
|
2476
|
+
r = _fetch(toks, policy, env)
|
|
2477
|
+
if r:
|
|
2478
|
+
return r
|
|
2479
|
+
return targets(extractor_write_targets(toks))
|
|
2480
|
+
if base == "patch":
|
|
2481
|
+
return "patch applies a diff that names files the guard cannot enumerate; refused unattended"
|
|
2482
|
+
if base in ("tar", "gtar", "bsdtar"):
|
|
2483
|
+
# An archive extraction whose members carry absolute paths (-P) writes
|
|
2484
|
+
# anywhere; without -P the paths are under the extraction directory,
|
|
2485
|
+
# which -C names and check_path judges (the sandbox confines the rest).
|
|
2486
|
+
if any(_short_cluster_has(a, "P") or a == "--absolute-names" for a in toks[1:]):
|
|
2487
|
+
return "tar -P / --absolute-names extracts to absolute paths the guard cannot enumerate"
|
|
2488
|
+
r = targets(extractor_write_targets(toks))
|
|
2489
|
+
if r:
|
|
2490
|
+
return r
|
|
2491
|
+
if base in ("unzip", "cpio", "pax", "7z", "7za", "diff", "gdiff", "xcodebuild"):
|
|
2492
|
+
r = targets(extractor_write_targets(toks))
|
|
2493
|
+
if r:
|
|
2494
|
+
return r
|
|
2495
|
+
if base == "swift":
|
|
2496
|
+
r = targets(extractor_write_targets(toks))
|
|
2497
|
+
if r:
|
|
2498
|
+
return r
|
|
2499
|
+
plains = [a for a in toks[1:] if not a.startswith("-")]
|
|
2500
|
+
if base in ("npm", "pnpm", "yarn", "bun", "npx", "pip", "pip3", "uv", "poetry", "pdm", "pipenv", "pod", "gem",
|
|
2501
|
+
"bundle", "brew", "cargo", "go", "swift") and plains and _has_ph(plains[0]):
|
|
2502
|
+
return f"{base} with a subcommand built at run time cannot be judged"
|
|
2503
|
+
r = _install(toks, ctx.cwd or os.getcwd())
|
|
2504
|
+
if r:
|
|
2505
|
+
return r
|
|
2506
|
+
r = _publisher(toks, policy)
|
|
2507
|
+
if r:
|
|
2508
|
+
return r
|
|
2509
|
+
return targets(write_targets(toks))
|
|
2510
|
+
|
|
2511
|
+
|
|
2512
|
+
REDIRECT = re.compile(r"(?:^|[^0-9&<>])(?:\d?>>?|&>>?|>\|)\s*([^\s;&|<>]+)")
|
|
2513
|
+
ASSIGN_RX = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$", re.S)
|
|
2514
|
+
|
|
2515
|
+
|
|
2516
|
+
def _cd(stripped, ctx):
|
|
2517
|
+
"""New cwd after cd/pushd/popd: a resolved absolute path, or None."""
|
|
2518
|
+
cmd = os.path.basename(stripped[0]).lower()
|
|
2519
|
+
if cmd == "popd":
|
|
2520
|
+
return None
|
|
2521
|
+
# `cd -` returns to $OLDPWD, which the guard does not track: the new cwd is
|
|
2522
|
+
# unknown, so anything after it is judged with no directory (and refused if
|
|
2523
|
+
# it needs one). Caught before the flag filter drops the "-".
|
|
2524
|
+
if any(t == "-" for t in stripped[1:]):
|
|
2525
|
+
return None
|
|
2526
|
+
args = [t for t in stripped[1:] if not t.startswith("-")]
|
|
2527
|
+
if not args:
|
|
2528
|
+
return home() if cmd == "cd" else None
|
|
2529
|
+
e = expand(args[0], ctx.vars, ctx.env)
|
|
2530
|
+
if e is None or any(ch in e for ch in GLOB_CHARS):
|
|
2531
|
+
return None
|
|
2532
|
+
e = os.path.expanduser(e)
|
|
2533
|
+
if not os.path.isabs(e):
|
|
2534
|
+
# With CDPATH set, `cd name` may land in a CDPATH entry instead.
|
|
2535
|
+
cdpath = (os.environ if ctx.env is None else ctx.env).get("CDPATH")
|
|
2536
|
+
if cdpath and not (e == "." or e == ".." or e.startswith("./") or e.startswith("../")):
|
|
2537
|
+
return None
|
|
2538
|
+
if ctx.cwd is None:
|
|
2539
|
+
return None
|
|
2540
|
+
e = os.path.join(ctx.cwd, e)
|
|
2541
|
+
return os.path.normpath(e)
|
|
2542
|
+
|
|
2543
|
+
|
|
2544
|
+
def check_bash(cmd, cwd, policy, env=None, variables=None, nested=False):
|
|
2545
|
+
try:
|
|
2546
|
+
if not nested:
|
|
2547
|
+
cmd = strip_heredocs(cmd)
|
|
2548
|
+
outer, inners = extract_substitutions(cmd)
|
|
2549
|
+
except Refused as e:
|
|
2550
|
+
return str(e)
|
|
2551
|
+
if nested and inners:
|
|
2552
|
+
return "a command substitution nested inside another is not analysed"
|
|
2553
|
+
r = structural_refusal(outer)
|
|
2554
|
+
if r:
|
|
2555
|
+
return r
|
|
2556
|
+
ctx = Ctx(cwd or os.getcwd(), policy, env, variables, inners)
|
|
2557
|
+
for seg in split_ops(outer):
|
|
2558
|
+
# Every substitution in this segment runs first, in the segment's cwd,
|
|
2559
|
+
# and must pass the same checks as if it were typed directly.
|
|
2560
|
+
for m in PLACEHOLDER_RX.finditer(seg):
|
|
2561
|
+
inner = inners[int(m.group(1))]
|
|
2562
|
+
if not inner.strip():
|
|
2563
|
+
return "an empty command substitution"
|
|
2564
|
+
if ctx.cwd is None:
|
|
2565
|
+
return "a command substitution after a cd the guard could not resolve"
|
|
2566
|
+
r = check_bash(inner, ctx.cwd, policy, env, ctx.vars, nested=True)
|
|
2567
|
+
if r:
|
|
2568
|
+
return f"inside $(...): {r}"
|
|
2569
|
+
toks = tokenize(seg)
|
|
2570
|
+
ctx.unquoted_glob = any(ch in _strip_quotes(seg, keep_double=False) for ch in GLOB_CHARS)
|
|
2571
|
+
r = simple_command_refusal(seg, toks, ctx.vars, env, ctx.cwd)
|
|
2572
|
+
if r:
|
|
2573
|
+
return r
|
|
2574
|
+
stripped = _strip_wrappers(toks)
|
|
2575
|
+
if stripped and os.path.basename(stripped[0]).lower() in ("cd", "pushd") and ctx.unquoted_glob:
|
|
2576
|
+
if any(ch in a for a in stripped[1:] for ch in GLOB_CHARS):
|
|
2577
|
+
return "cd into a glob pattern moves the run to a directory the guard cannot see"
|
|
2578
|
+
try:
|
|
2579
|
+
redirects = redirect_targets(seg)
|
|
2580
|
+
except Refused as e:
|
|
2581
|
+
return str(e)
|
|
2582
|
+
for t in redirects:
|
|
2583
|
+
r = ctx.target(t)
|
|
2584
|
+
if r:
|
|
2585
|
+
return r
|
|
2586
|
+
r = check_segment(seg, ctx)
|
|
2587
|
+
if r:
|
|
2588
|
+
return r
|
|
2589
|
+
stripped = _strip_wrappers(toks)
|
|
2590
|
+
if stripped and os.path.basename(stripped[0]).lower() in ("cd", "pushd", "popd"):
|
|
2591
|
+
ctx.cwd = _cd(stripped, ctx)
|
|
2592
|
+
elif toks and all(ASSIGN_RX.match(t) for t in toks):
|
|
2593
|
+
# A bare assignment line: remember what it set for later expansion.
|
|
2594
|
+
for t in toks:
|
|
2595
|
+
name, value = ASSIGN_RX.match(t).groups()
|
|
2596
|
+
ctx.vars[name] = expand(value, ctx.vars, env)
|
|
2597
|
+
return None
|
|
2598
|
+
|
|
2599
|
+
|
|
2600
|
+
TOOLKIT_PREFIX = "mcp__multi-agent-toolkit__"
|
|
2601
|
+
|
|
2602
|
+
|
|
2603
|
+
# Keys a toolkit tool uses for a filesystem destination it writes. Checked with
|
|
2604
|
+
# check_path so a toolkit call cannot write a screenshot, recording, build
|
|
2605
|
+
# output or export over a protected path. MCP tools run outside the OS sandbox,
|
|
2606
|
+
# so this is their only path boundary.
|
|
2607
|
+
TOOLKIT_PATH_KEYS = ("path", "filename", "out_dir", "output_path", "output_dir",
|
|
2608
|
+
"export_path", "derived_data_path", "result_bundle_path",
|
|
2609
|
+
"figma_path", "screenshot_path", "video_path", "file_path")
|
|
2610
|
+
|
|
2611
|
+
|
|
2612
|
+
def _toolkit_path_refusal(name, ti, policy, env, cwd):
|
|
2613
|
+
for key, value in ti.items():
|
|
2614
|
+
if not isinstance(value, str) or not value:
|
|
2615
|
+
continue
|
|
2616
|
+
if key in TOOLKIT_PATH_KEYS or key.endswith("_path"):
|
|
2617
|
+
r = check_path(value, cwd, policy, env)
|
|
2618
|
+
if r:
|
|
2619
|
+
return f"{name} {key}: {r}"
|
|
2620
|
+
return None
|
|
2621
|
+
|
|
2622
|
+
|
|
2623
|
+
def check_toolkit(name, ti, policy, env, depth=0, cwd=None):
|
|
2624
|
+
"""A multi-agent-toolkit tool call, by its short name (web_goto,
|
|
2625
|
+
ios_open_url, agent_run_steps). agent_run_steps dispatches other toolkit
|
|
2626
|
+
tools, so each step is judged as if it were called directly. Every
|
|
2627
|
+
path-typed argument is checked, including inside a step."""
|
|
2628
|
+
if not isinstance(ti, dict):
|
|
2629
|
+
return f"{name} arguments are not an object the guard can read"
|
|
2630
|
+
if name.startswith("research_"):
|
|
2631
|
+
return f"{name} sends the query to an outside service"
|
|
2632
|
+
if name == "web_eval":
|
|
2633
|
+
return "web_eval runs arbitrary JavaScript that can reach any host; refused unattended"
|
|
2634
|
+
if name == "agent_run_steps":
|
|
2635
|
+
steps = ti.get("steps")
|
|
2636
|
+
if depth or not isinstance(steps, list):
|
|
2637
|
+
return "agent_run_steps without a readable list of steps cannot be judged"
|
|
2638
|
+
for step in steps:
|
|
2639
|
+
if not isinstance(step, dict) or not isinstance(step.get("tool"), str):
|
|
2640
|
+
return "an agent_run_steps step without a tool name cannot be judged"
|
|
2641
|
+
sub = step["tool"]
|
|
2642
|
+
sub = sub[len(TOOLKIT_PREFIX):] if sub.startswith(TOOLKIT_PREFIX) else sub
|
|
2643
|
+
if sub.startswith("agent_"):
|
|
2644
|
+
return "agent_run_steps may not nest another agent_* tool"
|
|
2645
|
+
r = check_toolkit(sub, step.get("args") if step.get("args") is not None else {}, policy, env, depth + 1, cwd)
|
|
2646
|
+
if r:
|
|
2647
|
+
return f"agent_run_steps step {sub}: {r}"
|
|
2648
|
+
return None
|
|
2649
|
+
r = _toolkit_path_refusal(name, ti, policy, env, cwd)
|
|
2650
|
+
if r:
|
|
2651
|
+
return r
|
|
2652
|
+
if "url" in ti and not isinstance(ti["url"], str):
|
|
2653
|
+
# A non-string url (a list, an object) is not something the guard can
|
|
2654
|
+
# host-check; a web tool handed one is refused rather than waved through.
|
|
2655
|
+
return f"{name} was given a url that is not a string; the host cannot be checked"
|
|
2656
|
+
url = ti.get("url")
|
|
2657
|
+
if not isinstance(url, str) or not url:
|
|
2658
|
+
return None
|
|
2659
|
+
if name.startswith("web_"):
|
|
2660
|
+
return check_url_host(url, policy, env, name)
|
|
2661
|
+
return _web_url_refusal(url, policy, env, name)
|
|
2662
|
+
|
|
2663
|
+
|
|
2664
|
+
def check(payload, env=None):
|
|
2665
|
+
"""Reason string when the tool call is blocked, None when it is allowed."""
|
|
2666
|
+
policy = load_policy()
|
|
2667
|
+
tool = payload.get("tool_name") or ""
|
|
2668
|
+
ti = payload.get("tool_input") or {}
|
|
2669
|
+
cwd = payload.get("cwd") or os.getcwd()
|
|
2670
|
+
if tool in ("Edit", "Write", "NotebookEdit", "MultiEdit"):
|
|
2671
|
+
return check_path(ti.get("file_path") or ti.get("notebook_path") or "", cwd, policy, env)
|
|
2672
|
+
if tool.startswith(TOOLKIT_PREFIX):
|
|
2673
|
+
return check_toolkit(tool[len(TOOLKIT_PREFIX):], ti, policy, env, cwd=cwd)
|
|
2674
|
+
if tool == "WebFetch":
|
|
2675
|
+
url = ti.get("url")
|
|
2676
|
+
if isinstance(url, str) and url:
|
|
2677
|
+
return check_url_host(url, policy, env, "WebFetch")
|
|
2678
|
+
return None
|
|
2679
|
+
cmd = ti.get("command")
|
|
2680
|
+
if isinstance(cmd, str) and cmd:
|
|
2681
|
+
return check_bash(cmd, cwd, policy, env)
|
|
2682
|
+
return None
|
|
2683
|
+
|
|
2684
|
+
|
|
2685
|
+
# --- git subcommand tokenizer, reused by pre-commit-check.sh ------------------
|
|
2686
|
+
|
|
2687
|
+
GIT_COMMIT_LIKE = {"commit", "commit-tree", "merge", "cherry-pick", "revert", "am"}
|
|
2688
|
+
|
|
2689
|
+
|
|
2690
|
+
def git_subcommands(cmd, depth=0):
|
|
2691
|
+
"""Every git subcommand across the simple commands of a shell line,
|
|
2692
|
+
including those inside `$(...)`, using the same wrapper-stripping and
|
|
2693
|
+
casefolding the guard uses. Refuse-structure is NOT applied here: the
|
|
2694
|
+
caller (pre-commit) only needs to know whether a commit happens."""
|
|
2695
|
+
subs = []
|
|
2696
|
+
inners = []
|
|
2697
|
+
if depth == 0:
|
|
2698
|
+
try:
|
|
2699
|
+
cmd = strip_heredocs(cmd)
|
|
2700
|
+
except Refused:
|
|
2701
|
+
pass
|
|
2702
|
+
if depth < 3:
|
|
2703
|
+
try:
|
|
2704
|
+
cmd, inners = extract_substitutions(cmd)
|
|
2705
|
+
except Refused:
|
|
2706
|
+
inners = []
|
|
2707
|
+
for seg in split_ops(cmd):
|
|
2708
|
+
toks = _strip_wrappers(tokenize(seg))
|
|
2709
|
+
if not toks:
|
|
2710
|
+
continue
|
|
2711
|
+
if os.path.basename(toks[0]).lower() == "git":
|
|
2712
|
+
sub, rest = _git_sub(toks)
|
|
2713
|
+
if sub:
|
|
2714
|
+
subs.append(sub)
|
|
2715
|
+
# A git alias could expand to a commit; treat an alias as commit-like.
|
|
2716
|
+
if git_config_refusal(toks):
|
|
2717
|
+
subs.append("commit")
|
|
2718
|
+
for inner in inners:
|
|
2719
|
+
subs.extend(git_subcommands(inner, depth + 1))
|
|
2720
|
+
return subs
|
|
2721
|
+
|
|
2722
|
+
|
|
2723
|
+
COMMIT_WRITERS = ("commit", "commit-tree", "merge", "cherry-pick", "revert", "am")
|
|
2724
|
+
|
|
2725
|
+
|
|
2726
|
+
def _git_dir_opt(toks):
|
|
2727
|
+
"""The directory named by git's global `-C`, or None."""
|
|
2728
|
+
i = 1
|
|
2729
|
+
while i < len(toks):
|
|
2730
|
+
t = toks[i]
|
|
2731
|
+
if t == "-C" and i + 1 < len(toks):
|
|
2732
|
+
return toks[i + 1]
|
|
2733
|
+
if t in ("-c", "--git-dir", "--work-tree", "--exec-path", "--namespace"):
|
|
2734
|
+
i += 2
|
|
2735
|
+
elif t.startswith("-"):
|
|
2736
|
+
i += 1
|
|
2737
|
+
else:
|
|
2738
|
+
return None
|
|
2739
|
+
return None
|
|
2740
|
+
|
|
2741
|
+
|
|
2742
|
+
def _literal_dir(arg, cwd):
|
|
2743
|
+
"""`arg` as an absolute directory, expanding a leading `~` / `$HOME` /
|
|
2744
|
+
`${HOME}` from the environment; "" when anything else would need the shell
|
|
2745
|
+
(a variable, a substitution, a glob, `~user`, `-`) or the base is unknown."""
|
|
2746
|
+
home = os.environ.get("HOME", "")
|
|
2747
|
+
for prefix in ("${HOME}", "$HOME", "~"):
|
|
2748
|
+
if home and (arg == prefix or arg.startswith(prefix + "/")):
|
|
2749
|
+
arg = home + arg[len(prefix):]
|
|
2750
|
+
break
|
|
2751
|
+
if not arg or arg == "-" or any(ch in arg for ch in "$`~*?"):
|
|
2752
|
+
return ""
|
|
2753
|
+
if os.path.isabs(arg):
|
|
2754
|
+
return os.path.normpath(arg)
|
|
2755
|
+
if cwd == "":
|
|
2756
|
+
return ""
|
|
2757
|
+
return os.path.normpath(os.path.join(cwd, arg))
|
|
2758
|
+
|
|
2759
|
+
|
|
2760
|
+
def git_commit_dirs(cmd, base):
|
|
2761
|
+
"""The directory each commit-writing git command on a shell line runs in,
|
|
2762
|
+
following `cd`/`pushd` and git's `-C` from `base`. "" marks a directory the
|
|
2763
|
+
line names in a way that cannot be read literally (`cd "$X"`, `cd -`)."""
|
|
2764
|
+
try:
|
|
2765
|
+
cmd = strip_heredocs(cmd)
|
|
2766
|
+
except Refused:
|
|
2767
|
+
pass
|
|
2768
|
+
cwd = base
|
|
2769
|
+
dirs = []
|
|
2770
|
+
for seg in split_ops(cmd):
|
|
2771
|
+
toks = _strip_wrappers(tokenize(seg))
|
|
2772
|
+
if not toks:
|
|
2773
|
+
continue
|
|
2774
|
+
head = os.path.basename(toks[0]).lower()
|
|
2775
|
+
if head in ("cd", "pushd"):
|
|
2776
|
+
args = [t for t in toks[1:] if t == "-" or not t.startswith("-")]
|
|
2777
|
+
cwd = _literal_dir(args[0], cwd) if args else ""
|
|
2778
|
+
continue
|
|
2779
|
+
if head != "git":
|
|
2780
|
+
continue
|
|
2781
|
+
sub, _ = _git_sub(toks)
|
|
2782
|
+
if sub not in COMMIT_WRITERS:
|
|
2783
|
+
continue
|
|
2784
|
+
opt = _git_dir_opt(toks)
|
|
2785
|
+
dirs.append(cwd if opt is None else _literal_dir(opt, cwd))
|
|
2786
|
+
return dirs
|