@codyswann/lisa 4.74.8 → 4.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +60 -0
- package/all/copy-overwrite/scripts/lib/npm-update-cancel-origin.mjs +4 -2
- package/all/copy-overwrite/scripts/lib/npm-update-checkpoint-codec.mjs +141 -0
- package/all/copy-overwrite/scripts/lib/npm-update-checkpoint.mjs +162 -116
- package/all/copy-overwrite/scripts/lib/npm-update-helper-inventory.mjs +1 -0
- package/all/copy-overwrite/scripts/lib/npm-update-recovery.mjs +9 -60
- package/all/copy-overwrite/scripts/npm-updater-helper-graph.json +18 -6
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +20 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/lisa-skill-sources.d.ts +3 -3
- package/dist/core/lisa-skill-sources.d.ts.map +1 -1
- package/dist/core/lisa-skill-sources.js +3 -2
- package/dist/core/lisa-skill-sources.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +739 -11
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-devin/.claude-plugin/plugin.json +11 -0
- package/plugins/lisa-cdk-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-cdk-devin/skills/setup-aws-accounts/SKILL.md +292 -0
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-devin/.claude-plugin/plugin.json +8 -0
- package/plugins/lisa-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-devin/.mcp.json +8 -0
- package/plugins/lisa-devin/agents/architecture-specialist.md +32 -0
- package/plugins/lisa-devin/agents/bug-fixer.md +43 -0
- package/plugins/lisa-devin/agents/builder.md +44 -0
- package/plugins/lisa-devin/agents/confluence-prd-intake.md +65 -0
- package/plugins/lisa-devin/agents/debug-specialist.md +34 -0
- package/plugins/lisa-devin/agents/eval-specialist.md +33 -0
- package/plugins/lisa-devin/agents/git-history-analyzer.md +183 -0
- package/plugins/lisa-devin/agents/github-agent.md +155 -0
- package/plugins/lisa-devin/agents/github-build-intake.md +64 -0
- package/plugins/lisa-devin/agents/github-prd-intake.md +66 -0
- package/plugins/lisa-devin/agents/jira-agent.md +135 -0
- package/plugins/lisa-devin/agents/jira-build-intake.md +64 -0
- package/plugins/lisa-devin/agents/learner.md +85 -0
- package/plugins/lisa-devin/agents/learning-judge.md +119 -0
- package/plugins/lisa-devin/agents/learnings-synthesizer.md +155 -0
- package/plugins/lisa-devin/agents/linear-agent.md +138 -0
- package/plugins/lisa-devin/agents/linear-build-intake.md +64 -0
- package/plugins/lisa-devin/agents/linear-prd-intake.md +66 -0
- package/plugins/lisa-devin/agents/notion-prd-intake.md +63 -0
- package/plugins/lisa-devin/agents/performance-specialist.md +26 -0
- package/plugins/lisa-devin/agents/pr-mining-specialist.md +85 -0
- package/plugins/lisa-devin/agents/product-specialist.md +30 -0
- package/plugins/lisa-devin/agents/quality-specialist.md +30 -0
- package/plugins/lisa-devin/agents/security-specialist.md +43 -0
- package/plugins/lisa-devin/agents/skill-evaluator.md +205 -0
- package/plugins/lisa-devin/agents/spec-conformance-specialist.md +56 -0
- package/plugins/lisa-devin/agents/test-specialist.md +32 -0
- package/plugins/lisa-devin/agents/tracker-mining-specialist.md +85 -0
- package/plugins/lisa-devin/agents/verification-specialist.md +145 -0
- package/plugins/lisa-devin/hooks/auto-update.mjs +824 -0
- package/plugins/lisa-devin/hooks/auto-update.sh +31 -0
- package/plugins/lisa-devin/hooks/block-blind-automerge.sh +1242 -0
- package/plugins/lisa-devin/hooks/block-direct-issue-create.sh +2603 -0
- package/plugins/lisa-devin/hooks/block-host-name-leak.mjs +412 -0
- package/plugins/lisa-devin/hooks/block-host-name-leak.sh +61 -0
- package/plugins/lisa-devin/hooks/block-instruction-file-edits.sh +207 -0
- package/plugins/lisa-devin/hooks/block-managed-file-edits.sh +1175 -0
- package/plugins/lisa-devin/hooks/block-no-verify.sh +2288 -0
- package/plugins/lisa-devin/hooks/block-shell-json-parsing.sh +263 -0
- package/plugins/lisa-devin/hooks/cleanup-stale-worktrees.sh +122 -0
- package/plugins/lisa-devin/hooks/devin-tool-name-shim.sh +30 -0
- package/plugins/lisa-devin/hooks/discharge-work-item-gates.sh +90 -0
- package/plugins/lisa-devin/hooks/enforce-verification-gate.sh +506 -0
- package/plugins/lisa-devin/hooks/enforcement-vintage-npm.mjs +320 -0
- package/plugins/lisa-devin/hooks/enforcement-vintage.mjs +472 -0
- package/plugins/lisa-devin/hooks/enforcement-vintage.sh +48 -0
- package/plugins/lisa-devin/hooks/failure-signature-index.mjs +717 -0
- package/plugins/lisa-devin/hooks/failure-signature-index.sh +26 -0
- package/plugins/lisa-devin/hooks/guard-dedupe.bash +374 -0
- package/plugins/lisa-devin/hooks/inject-resolved-config.mjs +903 -0
- package/plugins/lisa-devin/hooks/inject-resolved-config.sh +107 -0
- package/plugins/lisa-devin/hooks/inject-rules.sh +49 -0
- package/plugins/lisa-devin/hooks/install-pkgs.sh +84 -0
- package/plugins/lisa-devin/hooks/operational-hazards.mjs +736 -0
- package/plugins/lisa-devin/hooks/operational-hazards.sh +28 -0
- package/plugins/lisa-devin/hooks/parity-safety-net-heredoc.py +1004 -0
- package/plugins/lisa-devin/hooks/parity-safety-net.sh +3275 -0
- package/plugins/lisa-devin/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa-devin/hooks/setup-jira-cli.sh +205 -0
- package/plugins/lisa-devin/hooks/shell-write-nudge.sh +86 -0
- package/plugins/lisa-devin/hooks/sonar-secrets.sh +205 -0
- package/plugins/lisa-devin/hooks/threshold-ratchet-compare.mjs +518 -0
- package/plugins/lisa-devin/hooks/threshold-ratchet-families.mjs +543 -0
- package/plugins/lisa-devin/hooks/threshold-ratchet.mjs +568 -0
- package/plugins/lisa-devin/hooks/threshold-ratchet.sh +22 -0
- package/plugins/lisa-devin/hooks/withdrawn-rulings.mjs +600 -0
- package/plugins/lisa-devin/hooks/withdrawn-rulings.sh +24 -0
- package/plugins/lisa-devin/hooks/worktree-binding-guard.mjs +1383 -0
- package/plugins/lisa-devin/hooks/worktree-binding-guard.sh +79 -0
- package/plugins/lisa-devin/hooks.json +247 -0
- package/plugins/lisa-devin/rules/eager/00-rule-index.md +82 -0
- package/plugins/lisa-devin/rules/eager/base-rules.md +85 -0
- package/plugins/lisa-devin/rules/eager/coding-philosophy.md +27 -0
- package/plugins/lisa-devin/rules/eager/config-resolution.md +37 -0
- package/plugins/lisa-devin/rules/eager/do-it-now.md +11 -0
- package/plugins/lisa-devin/rules/eager/empirical-inquiry.md +24 -0
- package/plugins/lisa-devin/rules/eager/falsifiable-checks.md +48 -0
- package/plugins/lisa-devin/rules/eager/history-audit.md +27 -0
- package/plugins/lisa-devin/rules/eager/integration-access-layer.md +19 -0
- package/plugins/lisa-devin/rules/eager/intent-routing.md +18 -0
- package/plugins/lisa-devin/rules/eager/learnings-ladder.md +18 -0
- package/plugins/lisa-devin/rules/eager/local-ci-first.md +15 -0
- package/plugins/lisa-devin/rules/eager/measurement-integrity.md +18 -0
- package/plugins/lisa-devin/rules/eager/never-name-a-downstream-project.md +9 -0
- package/plugins/lisa-devin/rules/eager/not-blocked-just-waiting.md +13 -0
- package/plugins/lisa-devin/rules/eager/operational-hazards.md +50 -0
- package/plugins/lisa-devin/rules/eager/report-actionability.md +26 -0
- package/plugins/lisa-devin/rules/eager/security-audit-handling.md +40 -0
- package/plugins/lisa-devin/rules/eager/session-status-updates.md +20 -0
- package/plugins/lisa-devin/rules/eager/settled-decisions.md +75 -0
- package/plugins/lisa-devin/rules/eager/stale-state-claims.md +26 -0
- package/plugins/lisa-devin/rules/eager/tool-access-gate.md +23 -0
- package/plugins/lisa-devin/rules/eager/tracked-work.md +9 -0
- package/plugins/lisa-devin/rules/eager/upstream-to-lisa.md +14 -0
- package/plugins/lisa-devin/rules/eager/verification.md +33 -0
- package/plugins/lisa-devin/rules/eager/wiki-knowledge-source.md +13 -0
- package/plugins/lisa-devin/rules/eager/withdrawn-rulings.md +102 -0
- package/plugins/lisa-devin/rules/reference/automation-runbook-contract.md +355 -0
- package/plugins/lisa-devin/rules/reference/base-rules.md +136 -0
- package/plugins/lisa-devin/rules/reference/bdd-e2e-coverage.md +301 -0
- package/plugins/lisa-devin/rules/reference/blocker-containment.md +165 -0
- package/plugins/lisa-devin/rules/reference/claim-archaeology.md +184 -0
- package/plugins/lisa-devin/rules/reference/claim-evidence-mapping.md +362 -0
- package/plugins/lisa-devin/rules/reference/claim-time-guards.md +135 -0
- package/plugins/lisa-devin/rules/reference/coding-philosophy.md +428 -0
- package/plugins/lisa-devin/rules/reference/config-resolution.md +1225 -0
- package/plugins/lisa-devin/rules/reference/control-reachability.md +166 -0
- package/plugins/lisa-devin/rules/reference/convergent-review.md +145 -0
- package/plugins/lisa-devin/rules/reference/credential-substrate-precedence.md +249 -0
- package/plugins/lisa-devin/rules/reference/dependency-decision-records.md +177 -0
- package/plugins/lisa-devin/rules/reference/dependency-internalization-kit.md +244 -0
- package/plugins/lisa-devin/rules/reference/dependency-trust-classes.md +291 -0
- package/plugins/lisa-devin/rules/reference/deployed-state-readback.md +142 -0
- package/plugins/lisa-devin/rules/reference/derived-branch-plan.md +214 -0
- package/plugins/lisa-devin/rules/reference/design-source-of-truth.md +260 -0
- package/plugins/lisa-devin/rules/reference/design-value-binding.md +311 -0
- package/plugins/lisa-devin/rules/reference/do-it-now.md +19 -0
- package/plugins/lisa-devin/rules/reference/documentation-source-paths.md +31 -0
- package/plugins/lisa-devin/rules/reference/empirical-inquiry.md +28 -0
- package/plugins/lisa-devin/rules/reference/factory-model.md +111 -0
- package/plugins/lisa-devin/rules/reference/falsifiable-checks.md +275 -0
- package/plugins/lisa-devin/rules/reference/history-audit.md +42 -0
- package/plugins/lisa-devin/rules/reference/integration-access-layer.md +89 -0
- package/plugins/lisa-devin/rules/reference/intent-routing.md +428 -0
- package/plugins/lisa-devin/rules/reference/leaf-only-lifecycle.md +227 -0
- package/plugins/lisa-devin/rules/reference/learnings-ladder.md +41 -0
- package/plugins/lisa-devin/rules/reference/local-ci-first.md +34 -0
- package/plugins/lisa-devin/rules/reference/measurement-integrity.md +119 -0
- package/plugins/lisa-devin/rules/reference/never-name-a-downstream-project.md +37 -0
- package/plugins/lisa-devin/rules/reference/not-blocked-just-waiting.md +41 -0
- package/plugins/lisa-devin/rules/reference/observability-audit.md +146 -0
- package/plugins/lisa-devin/rules/reference/operational-hazards.md +115 -0
- package/plugins/lisa-devin/rules/reference/prd-definition-of-ready.md +118 -0
- package/plugins/lisa-devin/rules/reference/prd-lifecycle-rollup.md +192 -0
- package/plugins/lisa-devin/rules/reference/pre-flight-autofill.md +250 -0
- package/plugins/lisa-devin/rules/reference/project-learnings.md +159 -0
- package/plugins/lisa-devin/rules/reference/promotion-contract.md +148 -0
- package/plugins/lisa-devin/rules/reference/readiness-rubric.md +242 -0
- package/plugins/lisa-devin/rules/reference/ready-role-filing.md +212 -0
- package/plugins/lisa-devin/rules/reference/rejection-detection.md +308 -0
- package/plugins/lisa-devin/rules/reference/repo-scope-split.md +119 -0
- package/plugins/lisa-devin/rules/reference/report-actionability.md +61 -0
- package/plugins/lisa-devin/rules/reference/reset-seed-coverage.md +286 -0
- package/plugins/lisa-devin/rules/reference/security-audit-handling.md +55 -0
- package/plugins/lisa-devin/rules/reference/session-status-updates.md +111 -0
- package/plugins/lisa-devin/rules/reference/settled-decisions.md +39 -0
- package/plugins/lisa-devin/rules/reference/stale-state-claims.md +88 -0
- package/plugins/lisa-devin/rules/reference/tool-access-gate.md +114 -0
- package/plugins/lisa-devin/rules/reference/tracked-work.md +49 -0
- package/plugins/lisa-devin/rules/reference/upstream-to-lisa.md +26 -0
- package/plugins/lisa-devin/rules/reference/usage-accounting.md +261 -0
- package/plugins/lisa-devin/rules/reference/verification.md +278 -0
- package/plugins/lisa-devin/rules/reference/wiki-knowledge-source.md +37 -0
- package/plugins/lisa-devin/rules/reference/withdrawn-rulings.md +148 -0
- package/plugins/lisa-devin/rules/reference/work-item-definition-of-ready.md +177 -0
- package/plugins/lisa-devin/rules/reference/work-item-trailer-definition.md +129 -0
- package/plugins/lisa-devin/scripts/automation-run-record.mjs +678 -0
- package/plugins/lisa-devin/scripts/automation-status-claude-adapter.mjs +804 -0
- package/plugins/lisa-devin/scripts/automation-status-codex-adapter.mjs +1081 -0
- package/plugins/lisa-devin/scripts/automation-status-contract-drift.mjs +392 -0
- package/plugins/lisa-devin/scripts/automation-status-expected-fleet.mjs +508 -0
- package/plugins/lisa-devin/scripts/automation-status-report.mjs +200 -0
- package/plugins/lisa-devin/scripts/automation-status-run-history.mjs +216 -0
- package/plugins/lisa-devin/scripts/automation-status-unrecorded-runs.mjs +127 -0
- package/plugins/lisa-devin/scripts/blocker-edge-resolution.mjs +150 -0
- package/plugins/lisa-devin/scripts/cross-pollinate.mjs +763 -0
- package/plugins/lisa-devin/scripts/design-bindings-probe.mjs +713 -0
- package/plugins/lisa-devin/scripts/design-intake-gate.mjs +470 -0
- package/plugins/lisa-devin/scripts/design-source-gate.mjs +610 -0
- package/plugins/lisa-devin/scripts/design-variable-ids.mjs +567 -0
- package/plugins/lisa-devin/scripts/doctor-report.mjs +635 -0
- package/plugins/lisa-devin/scripts/install-remote-agent-aws.mjs +443 -0
- package/plugins/lisa-devin/scripts/intake-blocker-reprobe.mjs +1065 -0
- package/plugins/lisa-devin/scripts/intake-prework-denominator.mjs +249 -0
- package/plugins/lisa-devin/scripts/lifecycle-label-trust.mjs +593 -0
- package/plugins/lisa-devin/scripts/linear-state-write-target.mjs +362 -0
- package/plugins/lisa-devin/scripts/plugin-sync-explain.mjs +654 -0
- package/plugins/lisa-devin/scripts/pr-arming-sweep.mjs +561 -0
- package/plugins/lisa-devin/scripts/project-ideation-idempotency-harness.mjs +338 -0
- package/plugins/lisa-devin/scripts/qa-signal-lifecycle.mjs +523 -0
- package/plugins/lisa-devin/scripts/queue-contract-resolution.mjs +583 -0
- package/plugins/lisa-devin/scripts/queue-health-classification.mjs +157 -0
- package/plugins/lisa-devin/scripts/queue-status-build-readers.mjs +561 -0
- package/plugins/lisa-devin/scripts/queue-status-prd-readers.mjs +452 -0
- package/plugins/lisa-devin/scripts/remote-agent-aws-setup.sh +677 -0
- package/plugins/lisa-devin/scripts/repo-scope-vocabulary-audit.mjs +458 -0
- package/plugins/lisa-devin/scripts/resolve-lifecycle-role.mjs +347 -0
- package/plugins/lisa-devin/scripts/rollup-blocker-classification.mjs +616 -0
- package/plugins/lisa-devin/scripts/scoping-label-audit.mjs +474 -0
- package/plugins/lisa-devin/skills/lisa-acceptance-criteria/SKILL.md +73 -0
- package/plugins/lisa-devin/skills/lisa-agent-design-best-practices/SKILL.md +242 -0
- package/plugins/lisa-devin/skills/lisa-agent-ready/SKILL.md +271 -0
- package/plugins/lisa-devin/skills/lisa-analyze-claude-remote/SKILL.md +434 -0
- package/plugins/lisa-devin/skills/lisa-atlassian-access/SKILL.md +469 -0
- package/plugins/lisa-devin/skills/lisa-atlassian-access/scripts/markdown-to-adf.mjs +236 -0
- package/plugins/lisa-devin/skills/lisa-attribute-failure/SKILL.md +142 -0
- package/plugins/lisa-devin/skills/lisa-automation-status/SKILL.md +149 -0
- package/plugins/lisa-devin/skills/lisa-bug-triage/SKILL.md +23 -0
- package/plugins/lisa-devin/skills/lisa-capability-drift/SKILL.md +71 -0
- package/plugins/lisa-devin/skills/lisa-codebase-research/SKILL.md +87 -0
- package/plugins/lisa-devin/skills/lisa-codify-verification/SKILL.md +213 -0
- package/plugins/lisa-devin/skills/lisa-confluence-prd-intake/SKILL.md +417 -0
- package/plugins/lisa-devin/skills/lisa-confluence-to-tracker/SKILL.md +469 -0
- package/plugins/lisa-devin/skills/lisa-confluence-write-prd/SKILL.md +111 -0
- package/plugins/lisa-devin/skills/lisa-cross-pollinate/SKILL.md +175 -0
- package/plugins/lisa-devin/skills/lisa-debrief/SKILL.md +96 -0
- package/plugins/lisa-devin/skills/lisa-debrief-apply/SKILL.md +106 -0
- package/plugins/lisa-devin/skills/lisa-delivery-effectiveness/SKILL.md +62 -0
- package/plugins/lisa-devin/skills/lisa-design-intake/SKILL.md +198 -0
- package/plugins/lisa-devin/skills/lisa-detect-tooling/SKILL.md +110 -0
- package/plugins/lisa-devin/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
- package/plugins/lisa-devin/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +658 -0
- package/plugins/lisa-devin/skills/lisa-doctor/SKILL.md +743 -0
- package/plugins/lisa-devin/skills/lisa-drive-pr-to-merge/SKILL.md +1587 -0
- package/plugins/lisa-devin/skills/lisa-epic-triage/SKILL.md +28 -0
- package/plugins/lisa-devin/skills/lisa-evaluation-suite/SKILL.md +63 -0
- package/plugins/lisa-devin/skills/lisa-exploratory-qa/SKILL.md +185 -0
- package/plugins/lisa-devin/skills/lisa-fix-linter-error/SKILL.md +45 -0
- package/plugins/lisa-devin/skills/lisa-generate-claude-remote-build-script/SKILL.md +235 -0
- package/plugins/lisa-devin/skills/lisa-git-commit/SKILL.md +53 -0
- package/plugins/lisa-devin/skills/lisa-git-prune/SKILL.md +35 -0
- package/plugins/lisa-devin/skills/lisa-git-submit-pr/SKILL.md +170 -0
- package/plugins/lisa-devin/skills/lisa-github-add-journey/SKILL.md +118 -0
- package/plugins/lisa-devin/skills/lisa-github-build-intake/SKILL.md +745 -0
- package/plugins/lisa-devin/skills/lisa-github-claim/SKILL.md +27 -0
- package/plugins/lisa-devin/skills/lisa-github-create/SKILL.md +105 -0
- package/plugins/lisa-devin/skills/lisa-github-evidence/SKILL.md +120 -0
- package/plugins/lisa-devin/skills/lisa-github-journey/SKILL.md +134 -0
- package/plugins/lisa-devin/skills/lisa-github-prd-intake/SKILL.md +424 -0
- package/plugins/lisa-devin/skills/lisa-github-project-v2/SKILL.md +227 -0
- package/plugins/lisa-devin/skills/lisa-github-read-issue/SKILL.md +305 -0
- package/plugins/lisa-devin/skills/lisa-github-sync/SKILL.md +201 -0
- package/plugins/lisa-devin/skills/lisa-github-to-tracker/SKILL.md +456 -0
- package/plugins/lisa-devin/skills/lisa-github-validate-issue/SKILL.md +598 -0
- package/plugins/lisa-devin/skills/lisa-github-verify/SKILL.md +60 -0
- package/plugins/lisa-devin/skills/lisa-github-write-issue/SKILL.md +531 -0
- package/plugins/lisa-devin/skills/lisa-github-write-prd/SKILL.md +156 -0
- package/plugins/lisa-devin/skills/lisa-health/SKILL.md +142 -0
- package/plugins/lisa-devin/skills/lisa-health-drift-cron/SKILL.md +80 -0
- package/plugins/lisa-devin/skills/lisa-implement/SKILL.md +372 -0
- package/plugins/lisa-devin/skills/lisa-improve-code-complexity/SKILL.md +44 -0
- package/plugins/lisa-devin/skills/lisa-improve-harness/SKILL.md +487 -0
- package/plugins/lisa-devin/skills/lisa-improve-max-lines/SKILL.md +45 -0
- package/plugins/lisa-devin/skills/lisa-improve-max-lines-per-function/SKILL.md +46 -0
- package/plugins/lisa-devin/skills/lisa-improve-test-coverage/SKILL.md +47 -0
- package/plugins/lisa-devin/skills/lisa-improve-tests/SKILL.md +50 -0
- package/plugins/lisa-devin/skills/lisa-intake/SKILL.md +208 -0
- package/plugins/lisa-devin/skills/lisa-intake-explain/SKILL.md +279 -0
- package/plugins/lisa-devin/skills/lisa-jam-access/SKILL.md +128 -0
- package/plugins/lisa-devin/skills/lisa-jira-add-journey/SKILL.md +139 -0
- package/plugins/lisa-devin/skills/lisa-jira-build-intake/SKILL.md +556 -0
- package/plugins/lisa-devin/skills/lisa-jira-claim/SKILL.md +27 -0
- package/plugins/lisa-devin/skills/lisa-jira-create/SKILL.md +157 -0
- package/plugins/lisa-devin/skills/lisa-jira-evidence/SKILL.md +106 -0
- package/plugins/lisa-devin/skills/lisa-jira-evidence/scripts/post-evidence.sh +262 -0
- package/plugins/lisa-devin/skills/lisa-jira-journey/SKILL.md +168 -0
- package/plugins/lisa-devin/skills/lisa-jira-journey/scripts/generate-templates.py +239 -0
- package/plugins/lisa-devin/skills/lisa-jira-journey/scripts/parse-plan.py +570 -0
- package/plugins/lisa-devin/skills/lisa-jira-read-ticket/SKILL.md +199 -0
- package/plugins/lisa-devin/skills/lisa-jira-read-ticket/scripts/download-attachment.sh +211 -0
- package/plugins/lisa-devin/skills/lisa-jira-sync/SKILL.md +117 -0
- package/plugins/lisa-devin/skills/lisa-jira-validate-ticket/SKILL.md +525 -0
- package/plugins/lisa-devin/skills/lisa-jira-verify/SKILL.md +61 -0
- package/plugins/lisa-devin/skills/lisa-jira-write-ticket/SKILL.md +440 -0
- package/plugins/lisa-devin/skills/lisa-jsdoc-best-practices/SKILL.md +491 -0
- package/plugins/lisa-devin/skills/lisa-kane-browser/SKILL.md +80 -0
- package/plugins/lisa-devin/skills/lisa-learnings-audit/SKILL.md +490 -0
- package/plugins/lisa-devin/skills/lisa-linear-access/SKILL.md +483 -0
- package/plugins/lisa-devin/skills/lisa-linear-add-journey/SKILL.md +109 -0
- package/plugins/lisa-devin/skills/lisa-linear-build-intake/SKILL.md +548 -0
- package/plugins/lisa-devin/skills/lisa-linear-claim/SKILL.md +28 -0
- package/plugins/lisa-devin/skills/lisa-linear-create/SKILL.md +150 -0
- package/plugins/lisa-devin/skills/lisa-linear-evidence/SKILL.md +124 -0
- package/plugins/lisa-devin/skills/lisa-linear-journey/SKILL.md +147 -0
- package/plugins/lisa-devin/skills/lisa-linear-prd-intake/SKILL.md +411 -0
- package/plugins/lisa-devin/skills/lisa-linear-read-issue/SKILL.md +201 -0
- package/plugins/lisa-devin/skills/lisa-linear-sync/SKILL.md +164 -0
- package/plugins/lisa-devin/skills/lisa-linear-to-tracker/SKILL.md +454 -0
- package/plugins/lisa-devin/skills/lisa-linear-validate-issue/SKILL.md +504 -0
- package/plugins/lisa-devin/skills/lisa-linear-verify/SKILL.md +65 -0
- package/plugins/lisa-devin/skills/lisa-linear-write-issue/SKILL.md +448 -0
- package/plugins/lisa-devin/skills/lisa-linear-write-prd/SKILL.md +98 -0
- package/plugins/lisa-devin/skills/lisa-monitor/SKILL.md +224 -0
- package/plugins/lisa-devin/skills/lisa-nightly-add-test-coverage/SKILL.md +40 -0
- package/plugins/lisa-devin/skills/lisa-nightly-improve-tests/SKILL.md +29 -0
- package/plugins/lisa-devin/skills/lisa-nightly-lower-code-complexity/SKILL.md +42 -0
- package/plugins/lisa-devin/skills/lisa-notion-access/SKILL.md +301 -0
- package/plugins/lisa-devin/skills/lisa-notion-prd-intake/SKILL.md +360 -0
- package/plugins/lisa-devin/skills/lisa-notion-to-tracker/SKILL.md +467 -0
- package/plugins/lisa-devin/skills/lisa-notion-write-prd/SKILL.md +111 -0
- package/plugins/lisa-devin/skills/lisa-parity-code-review/SKILL.md +83 -0
- package/plugins/lisa-devin/skills/lisa-parity-code-simplifier/SKILL.md +76 -0
- package/plugins/lisa-devin/skills/lisa-parity-coderabbit/SKILL.md +87 -0
- package/plugins/lisa-devin/skills/lisa-parity-sentry-sdk-setup/SKILL.md +375 -0
- package/plugins/lisa-devin/skills/lisa-parity-sentry-seer/SKILL.md +168 -0
- package/plugins/lisa-devin/skills/lisa-parity-skill-creator/SKILL.md +155 -0
- package/plugins/lisa-devin/skills/lisa-performance-review/SKILL.md +94 -0
- package/plugins/lisa-devin/skills/lisa-persist-learning/SKILL.md +247 -0
- package/plugins/lisa-devin/skills/lisa-plan/SKILL.md +64 -0
- package/plugins/lisa-devin/skills/lisa-plugin-sync-explain/SKILL.md +53 -0
- package/plugins/lisa-devin/skills/lisa-posthog-access/SKILL.md +140 -0
- package/plugins/lisa-devin/skills/lisa-prd-backlink/SKILL.md +274 -0
- package/plugins/lisa-devin/skills/lisa-prd-source-write/SKILL.md +104 -0
- package/plugins/lisa-devin/skills/lisa-prd-ticket-coverage/SKILL.md +187 -0
- package/plugins/lisa-devin/skills/lisa-product-walkthrough/SKILL.md +106 -0
- package/plugins/lisa-devin/skills/lisa-project-ideation/SKILL.md +417 -0
- package/plugins/lisa-devin/skills/lisa-project-ideation/examples/evidence-card-format.md +21 -0
- package/plugins/lisa-devin/skills/lisa-project-ideation/examples/host-project-only.md +22 -0
- package/plugins/lisa-devin/skills/lisa-project-ideation/examples/idempotency-verification-harness.md +57 -0
- package/plugins/lisa-devin/skills/lisa-project-ideation/examples/public-external-inspiration.md +22 -0
- package/plugins/lisa-devin/skills/lisa-project-ideation/examples/unavailable-data-rejection.md +22 -0
- package/plugins/lisa-devin/skills/lisa-pull-request-review/SKILL.md +132 -0
- package/plugins/lisa-devin/skills/lisa-quality-review/SKILL.md +63 -0
- package/plugins/lisa-devin/skills/lisa-queue-status/SKILL.md +215 -0
- package/plugins/lisa-devin/skills/lisa-remote-dispatch/SKILL.md +109 -0
- package/plugins/lisa-devin/skills/lisa-remote-dispatch/scripts/dispatch.mjs +535 -0
- package/plugins/lisa-devin/skills/lisa-repair-intake/SKILL.md +1621 -0
- package/plugins/lisa-devin/skills/lisa-reproduce-bug/SKILL.md +75 -0
- package/plugins/lisa-devin/skills/lisa-research/SKILL.md +92 -0
- package/plugins/lisa-devin/skills/lisa-review-implementation/SKILL.md +209 -0
- package/plugins/lisa-devin/skills/lisa-review-local/SKILL.md +141 -0
- package/plugins/lisa-devin/skills/lisa-rework-triage/SKILL.md +206 -0
- package/plugins/lisa-devin/skills/lisa-root-cause-analysis/SKILL.md +135 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/SKILL.md +430 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +433 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +288 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +273 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/envfile.mjs +80 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/inspect-vault.mjs +142 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +1085 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/note-format.mjs +179 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +342 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/prompt-secret.mjs +79 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/providers.mjs +805 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +680 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/read-secret-note.mjs +105 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/resolve-expo-token.mjs +90 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/resolve-secret.mjs +285 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/rotate-secret.mjs +297 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/surfaces.mjs +480 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +510 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/tools-from-notes.mjs +63 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/validate-config.mjs +550 -0
- package/plugins/lisa-devin/skills/lisa-security-review/SKILL.md +124 -0
- package/plugins/lisa-devin/skills/lisa-security-zap-scan/SKILL.md +53 -0
- package/plugins/lisa-devin/skills/lisa-sentry-access/SKILL.md +140 -0
- package/plugins/lisa-devin/skills/lisa-setup-atlassian/SKILL.md +407 -0
- package/plugins/lisa-devin/skills/lisa-setup-automations/SKILL.md +430 -0
- package/plugins/lisa-devin/skills/lisa-setup-automations/scripts/generate-workflow.mjs +288 -0
- package/plugins/lisa-devin/skills/lisa-setup-confluence/SKILL.md +254 -0
- package/plugins/lisa-devin/skills/lisa-setup-github/SKILL.md +319 -0
- package/plugins/lisa-devin/skills/lisa-setup-github-repo/SKILL.md +128 -0
- package/plugins/lisa-devin/skills/lisa-setup-jira/SKILL.md +198 -0
- package/plugins/lisa-devin/skills/lisa-setup-kane/SKILL.md +96 -0
- package/plugins/lisa-devin/skills/lisa-setup-linear/SKILL.md +375 -0
- package/plugins/lisa-devin/skills/lisa-setup-local-env/SKILL.md +95 -0
- package/plugins/lisa-devin/skills/lisa-setup-local-env/scripts/local-env.mjs +196 -0
- package/plugins/lisa-devin/skills/lisa-setup-notion/SKILL.md +376 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-aws/SKILL.md +149 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/SKILL.md +382 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/assets/materialized-env-authority.mjs +262 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/assets/session-start.sh +87 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/assets/setup.sh +216 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/environment.mjs +227 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +378 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +1632 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/toolchain.mjs +448 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +250 -0
- package/plugins/lisa-devin/skills/lisa-setup-sonar/SKILL.md +153 -0
- package/plugins/lisa-devin/skills/lisa-setup-workstation/SKILL.md +143 -0
- package/plugins/lisa-devin/skills/lisa-setup-workstation/scripts/bounded-child.mjs +168 -0
- package/plugins/lisa-devin/skills/lisa-setup-workstation/scripts/catalogue.mjs +293 -0
- package/plugins/lisa-devin/skills/lisa-setup-workstation/scripts/cli.mjs +333 -0
- package/plugins/lisa-devin/skills/lisa-setup-workstation/scripts/workstation.mjs +511 -0
- package/plugins/lisa-devin/skills/lisa-sonarcloud-access/SKILL.md +202 -0
- package/plugins/lisa-devin/skills/lisa-spec-conformance/SKILL.md +205 -0
- package/plugins/lisa-devin/skills/lisa-starter-sync/SKILL.md +35 -0
- package/plugins/lisa-devin/skills/lisa-sync-down/SKILL.md +209 -0
- package/plugins/lisa-devin/skills/lisa-task-decomposition/SKILL.md +195 -0
- package/plugins/lisa-devin/skills/lisa-task-triage/SKILL.md +23 -0
- package/plugins/lisa-devin/skills/lisa-tdd-implementation/SKILL.md +91 -0
- package/plugins/lisa-devin/skills/lisa-tear-down-automations/SKILL.md +85 -0
- package/plugins/lisa-devin/skills/lisa-test-strategy/SKILL.md +66 -0
- package/plugins/lisa-devin/skills/lisa-ticket-triage/SKILL.md +214 -0
- package/plugins/lisa-devin/skills/lisa-track/SKILL.md +129 -0
- package/plugins/lisa-devin/skills/lisa-tracker-add-journey/SKILL.md +28 -0
- package/plugins/lisa-devin/skills/lisa-tracker-build-intake/SKILL.md +134 -0
- package/plugins/lisa-devin/skills/lisa-tracker-claim/SKILL.md +35 -0
- package/plugins/lisa-devin/skills/lisa-tracker-create/SKILL.md +28 -0
- package/plugins/lisa-devin/skills/lisa-tracker-evidence/SKILL.md +63 -0
- package/plugins/lisa-devin/skills/lisa-tracker-journey/SKILL.md +27 -0
- package/plugins/lisa-devin/skills/lisa-tracker-read/SKILL.md +28 -0
- package/plugins/lisa-devin/skills/lisa-tracker-source-artifacts/SKILL.md +111 -0
- package/plugins/lisa-devin/skills/lisa-tracker-sync/SKILL.md +150 -0
- package/plugins/lisa-devin/skills/lisa-tracker-validate/SKILL.md +37 -0
- package/plugins/lisa-devin/skills/lisa-tracker-verify/SKILL.md +29 -0
- package/plugins/lisa-devin/skills/lisa-tracker-write/SKILL.md +55 -0
- package/plugins/lisa-devin/skills/lisa-usage-accounting/SKILL.md +205 -0
- package/plugins/lisa-devin/skills/lisa-usage-accounting/references/effectiveness.md +63 -0
- package/plugins/lisa-devin/skills/lisa-use-the-product/SKILL.md +86 -0
- package/plugins/lisa-devin/skills/lisa-validate-tracker-mapping/SKILL.md +467 -0
- package/plugins/lisa-devin/skills/lisa-verification-lifecycle/SKILL.md +384 -0
- package/plugins/lisa-devin/skills/lisa-verify/SKILL.md +55 -0
- package/plugins/lisa-devin/skills/lisa-verify-prd/SKILL.md +394 -0
- package/plugins/lisa-devin/skills/lisa-verify-workflow-change/SKILL.md +154 -0
- package/plugins/lisa-devin/skills/lisa-wiki-install/SKILL.md +101 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-devin/.claude-plugin/plugin.json +11 -0
- package/plugins/lisa-expo-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-expo-devin/.mcp.json +8 -0
- package/plugins/lisa-expo-devin/THIRD-PARTY-NOTICES.md +84 -0
- package/plugins/lisa-expo-devin/agents/ops-specialist.md +124 -0
- package/plugins/lisa-expo-devin/hooks/devin-tool-name-shim.sh +30 -0
- package/plugins/lisa-expo-devin/hooks/register-maestro-mcp.sh +84 -0
- package/plugins/lisa-expo-devin/hooks.json +14 -0
- package/plugins/lisa-expo-devin/skills/add-app-clip/SKILL.md +280 -0
- package/plugins/lisa-expo-devin/skills/add-app-clip/references/native-module.md +96 -0
- package/plugins/lisa-expo-devin/skills/apollo-client/SKILL.md +238 -0
- package/plugins/lisa-expo-devin/skills/apollo-client/references/mutation-patterns.md +360 -0
- package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/SKILL.md +369 -0
- package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/references/atomic-levels.md +417 -0
- package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/references/folder-structure.md +257 -0
- package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/references/gluestack-mapping.md +233 -0
- package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/scripts/test_validate_atomic_structure.py +240 -0
- package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/scripts/validate_atomic_structure.py +438 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/SKILL.md +321 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/animations.md +220 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/controls.md +272 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/form-sheet.md +253 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/gradients.md +106 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/icons.md +213 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/media.md +198 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/route-structure.md +229 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/search.md +248 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/storage.md +121 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/tabs.md +433 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/toolbar-and-headers.md +284 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/visual-effects.md +197 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/webgpu-three.md +605 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/zoom-transitions.md +158 -0
- package/plugins/lisa-expo-devin/skills/container-view-pattern/SKILL.md +355 -0
- package/plugins/lisa-expo-devin/skills/container-view-pattern/references/examples.md +749 -0
- package/plugins/lisa-expo-devin/skills/container-view-pattern/references/patterns.md +318 -0
- package/plugins/lisa-expo-devin/skills/container-view-pattern/scripts/create_component.py +200 -0
- package/plugins/lisa-expo-devin/skills/container-view-pattern/scripts/validate_component.py +332 -0
- package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/SKILL.md +268 -0
- package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/references/common-issues.md +619 -0
- package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/references/file-extensions.md +340 -0
- package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/references/platform-api.md +276 -0
- package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/scripts/validate_cross_platform.py +416 -0
- package/plugins/lisa-expo-devin/skills/directory-structure/SKILL.md +214 -0
- package/plugins/lisa-expo-devin/skills/directory-structure/scripts/validate_structure.py +454 -0
- package/plugins/lisa-expo-devin/skills/e2e-coverage-gaps/SKILL.md +117 -0
- package/plugins/lisa-expo-devin/skills/eas-update-insights/SKILL.md +228 -0
- package/plugins/lisa-expo-devin/skills/eas-update-insights/references/channel-insights-schema.md +47 -0
- package/plugins/lisa-expo-devin/skills/eas-update-insights/references/update-insights-schema.md +69 -0
- package/plugins/lisa-expo-devin/skills/expo-api-routes/SKILL.md +369 -0
- package/plugins/lisa-expo-devin/skills/expo-brownfield/SKILL.md +54 -0
- package/plugins/lisa-expo-devin/skills/expo-brownfield/references/brownfield-integrated.md +526 -0
- package/plugins/lisa-expo-devin/skills/expo-brownfield/references/brownfield-isolated.md +402 -0
- package/plugins/lisa-expo-devin/skills/expo-brownfield/references/comparison.md +63 -0
- package/plugins/lisa-expo-devin/skills/expo-brownfield/references/troubleshooting.md +88 -0
- package/plugins/lisa-expo-devin/skills/expo-cicd-workflows/SKILL.md +92 -0
- package/plugins/lisa-expo-devin/skills/expo-cicd-workflows/scripts/fetch.js +113 -0
- package/plugins/lisa-expo-devin/skills/expo-cicd-workflows/scripts/package.json +11 -0
- package/plugins/lisa-expo-devin/skills/expo-cicd-workflows/scripts/validate.js +85 -0
- package/plugins/lisa-expo-devin/skills/expo-deployment/SKILL.md +193 -0
- package/plugins/lisa-expo-devin/skills/expo-deployment/references/app-store-metadata.md +479 -0
- package/plugins/lisa-expo-devin/skills/expo-deployment/references/ios-app-store.md +355 -0
- package/plugins/lisa-expo-devin/skills/expo-deployment/references/play-store.md +264 -0
- package/plugins/lisa-expo-devin/skills/expo-deployment/references/testflight.md +58 -0
- package/plugins/lisa-expo-devin/skills/expo-deployment/references/workflows.md +200 -0
- package/plugins/lisa-expo-devin/skills/expo-dev-client/SKILL.md +164 -0
- package/plugins/lisa-expo-devin/skills/expo-env-config/SKILL.md +309 -0
- package/plugins/lisa-expo-devin/skills/expo-env-config/references/validation-patterns.md +417 -0
- package/plugins/lisa-expo-devin/skills/expo-module/SKILL.md +141 -0
- package/plugins/lisa-expo-devin/skills/expo-module/references/config-plugin.md +90 -0
- package/plugins/lisa-expo-devin/skills/expo-module/references/create-expo-module.md +206 -0
- package/plugins/lisa-expo-devin/skills/expo-module/references/lifecycle.md +127 -0
- package/plugins/lisa-expo-devin/skills/expo-module/references/module-config.md +48 -0
- package/plugins/lisa-expo-devin/skills/expo-module/references/native-module.md +286 -0
- package/plugins/lisa-expo-devin/skills/expo-module/references/native-view.md +171 -0
- package/plugins/lisa-expo-devin/skills/expo-router-best-practices/SKILL.md +432 -0
- package/plugins/lisa-expo-devin/skills/expo-router-best-practices/references/official-docs.md +290 -0
- package/plugins/lisa-expo-devin/skills/expo-router-best-practices/references/static-export-verification.md +54 -0
- package/plugins/lisa-expo-devin/skills/expo-router-best-practices/scripts/generate-route.py +171 -0
- package/plugins/lisa-expo-devin/skills/expo-tailwind-setup/SKILL.md +487 -0
- package/plugins/lisa-expo-devin/skills/expo-ui-jetpack-compose/SKILL.md +40 -0
- package/plugins/lisa-expo-devin/skills/expo-ui-swift-ui/SKILL.md +39 -0
- package/plugins/lisa-expo-devin/skills/gluestack-nativewind/SKILL.md +419 -0
- package/plugins/lisa-expo-devin/skills/gluestack-nativewind/references/color-tokens.md +343 -0
- package/plugins/lisa-expo-devin/skills/gluestack-nativewind/references/component-mapping.md +307 -0
- package/plugins/lisa-expo-devin/skills/gluestack-nativewind/references/spacing-scale.md +300 -0
- package/plugins/lisa-expo-devin/skills/gluestack-nativewind/scripts/test_validate_styling.py +240 -0
- package/plugins/lisa-expo-devin/skills/gluestack-nativewind/scripts/validate_styling.py +424 -0
- package/plugins/lisa-expo-devin/skills/jira-add-journey/SKILL.md +140 -0
- package/plugins/lisa-expo-devin/skills/jira-create/SKILL.md +161 -0
- package/plugins/lisa-expo-devin/skills/jira-evidence/SKILL.md +84 -0
- package/plugins/lisa-expo-devin/skills/jira-evidence/scripts/post-evidence.sh +262 -0
- package/plugins/lisa-expo-devin/skills/jira-journey/SKILL.md +196 -0
- package/plugins/lisa-expo-devin/skills/jira-journey/scripts/generate-templates.py +281 -0
- package/plugins/lisa-expo-devin/skills/jira-journey/scripts/parse-plan.py +400 -0
- package/plugins/lisa-expo-devin/skills/jira-verify/SKILL.md +27 -0
- package/plugins/lisa-expo-devin/skills/local-state/SKILL.md +362 -0
- package/plugins/lisa-expo-devin/skills/local-state/references/async-storage.md +505 -0
- package/plugins/lisa-expo-devin/skills/local-state/references/persistence-patterns.md +711 -0
- package/plugins/lisa-expo-devin/skills/local-state/references/reactive-variables.md +446 -0
- package/plugins/lisa-expo-devin/skills/maestro-mcp-setup/SKILL.md +144 -0
- package/plugins/lisa-expo-devin/skills/native-data-fetching/SKILL.md +507 -0
- package/plugins/lisa-expo-devin/skills/native-data-fetching/references/expo-router-loaders.md +344 -0
- package/plugins/lisa-expo-devin/skills/ops-browser-uat/SKILL.md +124 -0
- package/plugins/lisa-expo-devin/skills/ops-check-logs/SKILL.md +217 -0
- package/plugins/lisa-expo-devin/skills/ops-db-ops/SKILL.md +214 -0
- package/plugins/lisa-expo-devin/skills/ops-deploy/SKILL.md +123 -0
- package/plugins/lisa-expo-devin/skills/ops-monitor-errors/SKILL.md +99 -0
- package/plugins/lisa-expo-devin/skills/ops-performance/SKILL.md +165 -0
- package/plugins/lisa-expo-devin/skills/ops-run-local/SKILL.md +169 -0
- package/plugins/lisa-expo-devin/skills/ops-verify-health/SKILL.md +101 -0
- package/plugins/lisa-expo-devin/skills/owasp-zap/SKILL.md +61 -0
- package/plugins/lisa-expo-devin/skills/play-store-access/SKILL.md +226 -0
- package/plugins/lisa-expo-devin/skills/playwright-ci-debugging/SKILL.md +140 -0
- package/plugins/lisa-expo-devin/skills/playwright-selectors/SKILL.md +496 -0
- package/plugins/lisa-expo-devin/skills/reduce-complexity/SKILL.md +251 -0
- package/plugins/lisa-expo-devin/skills/reduce-complexity/references/extraction-strategies.md +456 -0
- package/plugins/lisa-expo-devin/skills/reduce-complexity/references/refactoring-patterns.md +557 -0
- package/plugins/lisa-expo-devin/skills/testing-library/SKILL.md +314 -0
- package/plugins/lisa-expo-devin/skills/testing-library/references/async-patterns.md +420 -0
- package/plugins/lisa-expo-devin/skills/testing-library/references/expo-router-testing.md +556 -0
- package/plugins/lisa-expo-devin/skills/testing-library/references/mocking-patterns.md +612 -0
- package/plugins/lisa-expo-devin/skills/testing-library/references/query-priority.md +291 -0
- package/plugins/lisa-expo-devin/skills/upgrading-expo/SKILL.md +134 -0
- package/plugins/lisa-expo-devin/skills/upgrading-expo/references/expo-av-to-audio.md +132 -0
- package/plugins/lisa-expo-devin/skills/upgrading-expo/references/expo-av-to-video.md +160 -0
- package/plugins/lisa-expo-devin/skills/upgrading-expo/references/native-tabs.md +124 -0
- package/plugins/lisa-expo-devin/skills/upgrading-expo/references/new-architecture.md +79 -0
- package/plugins/lisa-expo-devin/skills/upgrading-expo/references/react-19.md +79 -0
- package/plugins/lisa-expo-devin/skills/upgrading-expo/references/react-compiler.md +59 -0
- package/plugins/lisa-expo-devin/skills/upgrading-expo/references/react-navigation-to-expo-router.md +61 -0
- package/plugins/lisa-expo-devin/skills/use-dom/SKILL.md +417 -0
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-devin/.claude-plugin/plugin.json +11 -0
- package/plugins/lisa-harper-fabric-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-harper-fabric-devin/generated-artifact-globs.txt +5 -0
- package/plugins/lisa-harper-fabric-devin/hooks/block-generated-artifact-edits.sh +141 -0
- package/plugins/lisa-harper-fabric-devin/hooks/devin-tool-name-shim.sh +30 -0
- package/plugins/lisa-harper-fabric-devin/hooks/enforce-config-extensions.mjs +196 -0
- package/plugins/lisa-harper-fabric-devin/hooks/enforce-config-extensions.sh +46 -0
- package/plugins/lisa-harper-fabric-devin/hooks/inject-rules.sh +27 -0
- package/plugins/lisa-harper-fabric-devin/hooks.json +36 -0
- package/plugins/lisa-harper-fabric-devin/rules/eager/harper-fabric.md +31 -0
- package/plugins/lisa-harper-fabric-devin/rules/reference/harper-fabric.md +52 -0
- package/plugins/lisa-harper-fabric-devin/skills/e2e-coverage-gaps/SKILL.md +117 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-auth/SKILL.md +368 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-build-and-deploy/SKILL.md +198 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-caching/SKILL.md +265 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-component-model/SKILL.md +87 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-config-yaml/SKILL.md +283 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-operations/SKILL.md +239 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-realtime/SKILL.md +187 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-resources/SKILL.md +142 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-rest-queries/SKILL.md +403 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-schema-graphql/SKILL.md +123 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-testing/SKILL.md +254 -0
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-devin/.claude-plugin/plugin.json +11 -0
- package/plugins/lisa-nestjs-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-nestjs-devin/hooks/block-migration-edits.sh +125 -0
- package/plugins/lisa-nestjs-devin/hooks/devin-tool-name-shim.sh +30 -0
- package/plugins/lisa-nestjs-devin/hooks/lisa-edit-gate.sh +152 -0
- package/plugins/lisa-nestjs-devin/hooks.json +15 -0
- package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/SKILL.md +176 -0
- package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/advanced-features.md +527 -0
- package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/project-patterns.md +531 -0
- package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/quick-start.md +257 -0
- package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/resolvers-mutations.md +413 -0
- package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/types-scalars.md +513 -0
- package/plugins/lisa-nestjs-devin/skills/nestjs-rules/SKILL.md +560 -0
- package/plugins/lisa-nestjs-devin/skills/typeorm-patterns/SKILL.md +275 -0
- package/plugins/lisa-nestjs-devin/skills/typeorm-patterns/references/configuration-patterns.md +487 -0
- package/plugins/lisa-nestjs-devin/skills/typeorm-patterns/references/entity-patterns.md +450 -0
- package/plugins/lisa-nestjs-devin/skills/typeorm-patterns/references/observability-patterns.md +536 -0
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-devin/.claude-plugin/plugin.json +8 -0
- package/plugins/lisa-openclaw-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-repo-topic/SKILL.md +163 -0
- package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-repo-topic/references/repo-topic-config.md +185 -0
- package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-staff/SKILL.md +175 -0
- package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-staff/references/platform-routing.md +123 -0
- package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-staff/references/prompts.md +78 -0
- package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-setup/SKILL.md +141 -0
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-devin/.claude-plugin/plugin.json +11 -0
- package/plugins/lisa-phaser-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-phaser-devin/agents/accessibility-advocate.md +55 -0
- package/plugins/lisa-phaser-devin/agents/art-director.md +90 -0
- package/plugins/lisa-phaser-devin/agents/audio-director.md +65 -0
- package/plugins/lisa-phaser-devin/agents/combat-designer.md +61 -0
- package/plugins/lisa-phaser-devin/agents/economy-designer.md +59 -0
- package/plugins/lisa-phaser-devin/agents/game-designer.md +62 -0
- package/plugins/lisa-phaser-devin/agents/game-feel-specialist.md +56 -0
- package/plugins/lisa-phaser-devin/agents/level-designer.md +59 -0
- package/plugins/lisa-phaser-devin/agents/localization-manager.md +61 -0
- package/plugins/lisa-phaser-devin/agents/marketing-strategist.md +59 -0
- package/plugins/lisa-phaser-devin/agents/monetization-designer.md +59 -0
- package/plugins/lisa-phaser-devin/agents/narrative-designer.md +56 -0
- package/plugins/lisa-phaser-devin/agents/onboarding-advocate.md +59 -0
- package/plugins/lisa-phaser-devin/agents/player-advocate.md +57 -0
- package/plugins/lisa-phaser-devin/agents/producer.md +61 -0
- package/plugins/lisa-phaser-devin/agents/product-analyst.md +62 -0
- package/plugins/lisa-phaser-devin/agents/publisher.md +59 -0
- package/plugins/lisa-phaser-devin/agents/qa-playtester.md +57 -0
- package/plugins/lisa-phaser-devin/agents/target-player.md +52 -0
- package/plugins/lisa-phaser-devin/agents/ux-ui-designer.md +60 -0
- package/plugins/lisa-phaser-devin/hooks/devin-tool-name-shim.sh +30 -0
- package/plugins/lisa-phaser-devin/hooks/inject-rules.sh +27 -0
- package/plugins/lisa-phaser-devin/hooks.json +14 -0
- package/plugins/lisa-phaser-devin/rules/eager/phaser.md +44 -0
- package/plugins/lisa-phaser-devin/rules/reference/phaser.md +184 -0
- package/plugins/lisa-phaser-devin/skills/phaser-accessibility/SKILL.md +135 -0
- package/plugins/lisa-phaser-devin/skills/phaser-asset-pipeline/SKILL.md +222 -0
- package/plugins/lisa-phaser-devin/skills/phaser-asset-sourcing/SKILL.md +151 -0
- package/plugins/lisa-phaser-devin/skills/phaser-build-deploy/SKILL.md +153 -0
- package/plugins/lisa-phaser-devin/skills/phaser-i18n/SKILL.md +122 -0
- package/plugins/lisa-phaser-devin/skills/phaser-project-structure/SKILL.md +89 -0
- package/plugins/lisa-phaser-devin/skills/phaser-services/SKILL.md +208 -0
- package/plugins/lisa-phaser-devin/skills/phaser-testing/SKILL.md +192 -0
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-devin/.claude-plugin/plugin.json +11 -0
- package/plugins/lisa-rails-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-rails-devin/agents/ops-specialist.md +226 -0
- package/plugins/lisa-rails-devin/hooks/devin-tool-name-shim.sh +30 -0
- package/plugins/lisa-rails-devin/hooks/inject-rules.sh +40 -0
- package/plugins/lisa-rails-devin/hooks/lisa-edit-gate.sh +152 -0
- package/plugins/lisa-rails-devin/hooks/rubocop-on-edit.sh +101 -0
- package/plugins/lisa-rails-devin/hooks/sg-scan-on-edit.sh +97 -0
- package/plugins/lisa-rails-devin/hooks.json +29 -0
- package/plugins/lisa-rails-devin/rules/eager/rails-conventions.md +23 -0
- package/plugins/lisa-rails-devin/rules/reference/rails-conventions.md +176 -0
- package/plugins/lisa-rails-devin/skills/action-controller-best-practices/SKILL.md +374 -0
- package/plugins/lisa-rails-devin/skills/action-view-best-practices/SKILL.md +335 -0
- package/plugins/lisa-rails-devin/skills/active-record-model-best-practices/SKILL.md +166 -0
- package/plugins/lisa-rails-devin/skills/e2e-coverage-gaps/SKILL.md +117 -0
- package/plugins/lisa-rails-devin/skills/fix-linter-error/SKILL.md +45 -0
- package/plugins/lisa-rails-devin/skills/improve-code-complexity/SKILL.md +51 -0
- package/plugins/lisa-rails-devin/skills/improve-max-lines/SKILL.md +46 -0
- package/plugins/lisa-rails-devin/skills/improve-max-lines-per-function/SKILL.md +46 -0
- package/plugins/lisa-rails-devin/skills/improve-test-coverage/SKILL.md +48 -0
- package/plugins/lisa-rails-devin/skills/jira-add-journey/SKILL.md +79 -0
- package/plugins/lisa-rails-devin/skills/jira-create/SKILL.md +100 -0
- package/plugins/lisa-rails-devin/skills/jira-evidence/SKILL.md +76 -0
- package/plugins/lisa-rails-devin/skills/jira-evidence/scripts/post-evidence.sh +262 -0
- package/plugins/lisa-rails-devin/skills/jira-journey/SKILL.md +65 -0
- package/plugins/lisa-rails-devin/skills/jira-verify/SKILL.md +26 -0
- package/plugins/lisa-rails-devin/skills/ops-check-logs/SKILL.md +191 -0
- package/plugins/lisa-rails-devin/skills/ops-deploy/SKILL.md +153 -0
- package/plugins/lisa-rails-devin/skills/ops-run-local/SKILL.md +169 -0
- package/plugins/lisa-rails-devin/skills/ops-verify-jobs/SKILL.md +157 -0
- package/plugins/lisa-rails-devin/skills/ops-verify-telemetry/SKILL.md +226 -0
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-devin/.claude-plugin/plugin.json +11 -0
- package/plugins/lisa-typescript-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-typescript-devin/hooks/block-suppress-directives.sh +141 -0
- package/plugins/lisa-typescript-devin/hooks/devin-tool-name-shim.sh +30 -0
- package/plugins/lisa-typescript-devin/hooks/format-on-edit.sh +102 -0
- package/plugins/lisa-typescript-devin/hooks/lint-on-edit.sh +117 -0
- package/plugins/lisa-typescript-devin/hooks/lisa-edit-gate.sh +152 -0
- package/plugins/lisa-typescript-devin/hooks/sg-scan-on-edit.sh +94 -0
- package/plugins/lisa-typescript-devin/hooks.json +34 -0
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-devin/.claude-plugin/plugin.json +8 -0
- package/plugins/lisa-wiki-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-wiki-devin/ci/lisa-wiki-validate.yml +33 -0
- package/plugins/lisa-wiki-devin/schema/lisa-wiki-config.schema.json +162 -0
- package/plugins/lisa-wiki-devin/schema/wiki-structure.schema.json +51 -0
- package/plugins/lisa-wiki-devin/scripts/_wiki-lib.mjs +204 -0
- package/plugins/lisa-wiki-devin/scripts/diff-guard.mjs +117 -0
- package/plugins/lisa-wiki-devin/scripts/ensure-gitignore.mjs +131 -0
- package/plugins/lisa-wiki-devin/scripts/ensure-wiki.mjs +268 -0
- package/plugins/lisa-wiki-devin/scripts/ingest-git.mjs +198 -0
- package/plugins/lisa-wiki-devin/scripts/ingest-memory.mjs +135 -0
- package/plugins/lisa-wiki-devin/scripts/ingest-roles.mjs +95 -0
- package/plugins/lisa-wiki-devin/scripts/ingest_slack_channel.py +361 -0
- package/plugins/lisa-wiki-devin/scripts/lib/bounded-child.mjs +159 -0
- package/plugins/lisa-wiki-devin/scripts/lint-wiki.mjs +513 -0
- package/plugins/lisa-wiki-devin/scripts/mcp-doctor.mjs +72 -0
- package/plugins/lisa-wiki-devin/scripts/render-contract.mjs +107 -0
- package/plugins/lisa-wiki-devin/scripts/rewrite-refs.mjs +144 -0
- package/plugins/lisa-wiki-devin/scripts/slack_oauth_user.py +179 -0
- package/plugins/lisa-wiki-devin/scripts/validate-config.mjs +321 -0
- package/plugins/lisa-wiki-devin/scripts/verify-migration.mjs +203 -0
- package/plugins/lisa-wiki-devin/scripts/verify-wiki-safety.mjs +126 -0
- package/plugins/lisa-wiki-devin/scripts/wiki-safety.mjs +378 -0
- package/plugins/lisa-wiki-devin/scripts/wiki-status.mjs +345 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-add-ingest/SKILL.md +34 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-add-role/SKILL.md +35 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-confluence/SKILL.md +25 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-docs/SKILL.md +30 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-git/SKILL.md +25 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-jira/SKILL.md +28 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-memory/SKILL.md +28 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-notion/SKILL.md +25 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-roles/SKILL.md +22 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-slack/SKILL.md +30 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-web/SKILL.md +23 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-doctor/SKILL.md +47 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-ingest/SKILL.md +106 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-lint/SKILL.md +32 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-migrate/SKILL.md +43 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-onboard-me/SKILL.md +33 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-query/SKILL.md +40 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-setup/SKILL.md +75 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-setup-automations/SKILL.md +83 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-status/SKILL.md +47 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-tear-down-automations/SKILL.md +34 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-usage/SKILL.md +50 -0
- package/plugins/lisa-wiki-devin/templates/agents/role-agent.claude.md +16 -0
- package/plugins/lisa-wiki-devin/templates/agents/role-agent.codex.toml +15 -0
- package/plugins/lisa-wiki-devin/templates/index.md +17 -0
- package/plugins/lisa-wiki-devin/templates/llm-wiki-contract.md +72 -0
- package/plugins/lisa-wiki-devin/templates/log.md +8 -0
- package/plugins/lisa-wiki-devin/templates/page-types/architecture.md +18 -0
- package/plugins/lisa-wiki-devin/templates/page-types/concept.md +18 -0
- package/plugins/lisa-wiki-devin/templates/page-types/decision.md +18 -0
- package/plugins/lisa-wiki-devin/templates/page-types/entity.md +19 -0
- package/plugins/lisa-wiki-devin/templates/page-types/open-question.md +18 -0
- package/plugins/lisa-wiki-devin/templates/page-types/playbook.md +18 -0
- package/plugins/lisa-wiki-devin/templates/page-types/project.md +19 -0
- package/plugins/lisa-wiki-devin/templates/page-types/requirement.md +19 -0
- package/plugins/lisa-wiki-devin/templates/page-types/staff.md +26 -0
- package/plugins/lisa-wiki-devin/templates/start-here.md +24 -0
- package/plugins/lisa-wiki-devin/templates/state-readme.md +20 -0
- package/plugins/lisa-wiki-devin/templates/wrapper-gitignore.txt +19 -0
- package/scripts/build-plugins.sh +2 -2
- package/scripts/deployed-guard-advice.mjs +1 -0
- package/scripts/generate-devin-plugin-artifacts.mjs +263 -0
- package/scripts/lib/hook-registration-audit.mjs +3 -1
- package/scripts/lib/per-agent-hook-filter.mjs +174 -5
- package/scripts/lib/plugin-rule-citations.mjs +2 -2
- package/scripts/two-channel-couplings.json +1 -1
|
@@ -0,0 +1,2288 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
3
|
+
# Do not edit directly — durable changes belong upstream in Lisa.
|
|
4
|
+
|
|
5
|
+
# PreToolUse hook for Bash: blocks commands that bypass git's verification hooks.
|
|
6
|
+
# Bypassing pre-commit/pre-push hooks (which exist for a reason) is blocked in
|
|
7
|
+
# all of its forms; the fix is to address the underlying issue, not silence the
|
|
8
|
+
# check. See feedback_never_no_verify in user memory.
|
|
9
|
+
#
|
|
10
|
+
# Blocked bypass vectors:
|
|
11
|
+
# 1. --no-verify, and any prefix abbreviation git would accept for it;
|
|
12
|
+
# 2. HUSKY=0 / HUSKY_SKIP_HOOKS=... — disables husky-managed git hooks;
|
|
13
|
+
# 3. core.hooksPath pointed anywhere but an allowlisted in-repo hooks dir;
|
|
14
|
+
# 4. --config-env=core.hooksPath=VAR, in both the one- and two-token spellings;
|
|
15
|
+
# 5. GIT_CONFIG_KEY_<n>=core.hooksPath env-var-style command-scope config;
|
|
16
|
+
# 6. GIT_CONFIG_PARAMETERS carrying core.hooksPath command-scope config;
|
|
17
|
+
# 7. the short `-n`, as a real argv token of a `git commit` — bare, or bundled
|
|
18
|
+
# into a short-option cluster such as `-nm "msg"`;
|
|
19
|
+
# 8. a nested shell carrying any of the above as its `-c` command string,
|
|
20
|
+
# including behind long shell options such as `bash --norc -c`;
|
|
21
|
+
# 9. `env --split-string` in EVERY abbreviation GNU accepts (`--s` upward,
|
|
22
|
+
# with or without an attached `=value`);
|
|
23
|
+
# 10. those forms behind a wrapper that does not change what runs —
|
|
24
|
+
# `command`, `exec`, `builtin`, `nohup`, `setsid`, and nested `env`, and
|
|
25
|
+
# wrappers with an operand of their own (`nice -n 5`, `timeout 5`, `sudo`);
|
|
26
|
+
# 11. any of the above inside a script the command EXECUTES — `bash nv.sh`,
|
|
27
|
+
# `source`, `.`, `bash < nv.sh`, and an interpreter a `find -exec` or
|
|
28
|
+
# `xargs` puts at a command position.
|
|
29
|
+
#
|
|
30
|
+
# NOT vector 11: `bash -n nv.sh`. `-n` is noexec — the shell reads and parses
|
|
31
|
+
# the file, then exits without running a line — so a syntax check is the one
|
|
32
|
+
# shape that puts a path at a command position while executing nothing. It used
|
|
33
|
+
# to be refused, which made `bash -n` unusable on this file, on every husky
|
|
34
|
+
# hook, and on most of this repository (#3781). The distinction is noexec, not
|
|
35
|
+
# the program: `bash nv.sh` still executes and is still refused.
|
|
36
|
+
#
|
|
37
|
+
# The line below is what lets `lisa apply` tell a downstream copy of this guard
|
|
38
|
+
# that is BEHIND from one that is AHEAD. Byte comparison cannot: both look like
|
|
39
|
+
# "differs from mine", and guessing "behind" is how a fork's stronger guard gets
|
|
40
|
+
# silently replaced by a weaker upstream one. Refresh compares the two declared
|
|
41
|
+
# sets, and refuses to overwrite a copy declaring anything this one does not.
|
|
42
|
+
#
|
|
43
|
+
# Add a name here in the same commit that closes a vector. A hardening that
|
|
44
|
+
# forgets to is invisible to refresh, and shows up as an unexplained diff at
|
|
45
|
+
# review time instead of a named capability.
|
|
46
|
+
# lisa-guard-capabilities: no-verify-abbrev, husky-env, hookspath-allowlist, config-env, env-split-string, git-config-key, git-config-parameters, git-config-parameters-append, git-config-parameters-expansion, heredoc-shell-word, herestring-aware, no-verify-short, nested-shell-no-verify, nested-shell-long-options, env-split-string-abbrev, command-wrapper-normalization, executed-script-reach, source-builtin-reach, stdin-redirect-reach, static-script-directory-reach, wrapper-positional-operand, dispatcher-exec-position, tilde-script-reach, shell-option-stdin-reach, eval-payload
|
|
47
|
+
#
|
|
48
|
+
# Shell-token matching avoids false positives from issue bodies, heredocs, and
|
|
49
|
+
# commit-message prose while still catching quoted real argv values such as
|
|
50
|
+
# `git -c "core.hooksPath=/dev/null"`.
|
|
51
|
+
#
|
|
52
|
+
# ## The long flag is scoped to a git argv, and that is a WEAKENING
|
|
53
|
+
#
|
|
54
|
+
# `--no-verify` used to match as a bare token anywhere on the line. Measured
|
|
55
|
+
# against that matcher: `echo --no-verify` BLOCK, `grep -rl -- "--no-verify"
|
|
56
|
+
# scripts/` BLOCK. Both are correct to ALLOW — neither runs git — and both are
|
|
57
|
+
# now allowed. They are listed here rather than left to be rediscovered, because
|
|
58
|
+
# giving up a refusal is exactly the change that deserves to be written down.
|
|
59
|
+
#
|
|
60
|
+
# It was given up because it had to be. Vector 11 above reads FILE CONTENTS, and
|
|
61
|
+
# this file, every husky hook, and much of this repository carry the literal
|
|
62
|
+
# token in prose and in refusal text. An unscoped matcher plus reach refuses
|
|
63
|
+
# `bash` on this guard's own source and on the repository's own operations, on
|
|
64
|
+
# the first command. Narrowing was the prerequisite, not a cleanup.
|
|
65
|
+
#
|
|
66
|
+
# Scoped to a git INVOCATION, not to `git commit`: `git push --no-verify` skips
|
|
67
|
+
# pre-push and `git am` / `git merge` skip their own hooks. Only the short `-n`
|
|
68
|
+
# needs the commit scope. See `git_argv_disables_verification`.
|
|
69
|
+
#
|
|
70
|
+
# `HUSKY=0` and `core.hooksPath=` stay unscoped, deliberately — they are
|
|
71
|
+
# assignments that configure whatever git runs next, so they legitimately sit
|
|
72
|
+
# before the invocation or on an earlier line. See `token_bypass`.
|
|
73
|
+
#
|
|
74
|
+
# The short `-n` form used to be excluded here, and the stated reason was that
|
|
75
|
+
# "grep cannot distinguish a real -n option from -n in commit-message prose or
|
|
76
|
+
# an unrelated piped command". That was true of the matcher it was written for.
|
|
77
|
+
# It is not true of this one: the command is TOKENIZED, not grepped, so `-m "fix
|
|
78
|
+
# the -n flag"` is one token holding the message and `grep -n` is a separate
|
|
79
|
+
# command — the two cases the comment called inseparable are separated by the
|
|
80
|
+
# same machinery `--config-env` and `GIT_CONFIG_KEY_<n>` already depend on.
|
|
81
|
+
#
|
|
82
|
+
# A rationale that outlives the implementation it describes is worse than none:
|
|
83
|
+
# it reads as a considered decision and stops the next reader from re-examining
|
|
84
|
+
# it. Measured against real git, `git commit -n`, `-nm msg`, and `-anm msg` all
|
|
85
|
+
# skip pre-commit exactly as completely as `--no-verify`, and `-nm` is the more
|
|
86
|
+
# likely spelling in practice because it reads as an ordinary message flag.
|
|
87
|
+
#
|
|
88
|
+
# What the old comment got right is the part about `-n` being common, and that
|
|
89
|
+
# concern is answered by SCOPE rather than by giving up: `-n` is `--dry-run` for
|
|
90
|
+
# `push`, `--no-stat` for `merge`, and a line count for head/tail/sort/grep, so
|
|
91
|
+
# the match is confined to the argv of a `git commit` invocation and nothing
|
|
92
|
+
# else. See `git_skips_verification` below.
|
|
93
|
+
set -euo pipefail
|
|
94
|
+
|
|
95
|
+
input="$(cat)"
|
|
96
|
+
|
|
97
|
+
# Evaluate once per tool call when this guard is registered on both channels.
|
|
98
|
+
#
|
|
99
|
+
# Lisa reaches an agent through the repository dispatcher AND the plugin
|
|
100
|
+
# manifest, and where both are live the harness runs this guard twice for one
|
|
101
|
+
# tool call. `guard-dedupe.bash` short-circuits the second run ONLY when a
|
|
102
|
+
# byte-identical copy already ALLOWED this exact payload on this exact tool
|
|
103
|
+
# call; a differing vintage, a refusal, and a host with one channel all
|
|
104
|
+
# evaluate exactly as before. Nothing is de-registered by it
|
|
105
|
+
# (CodySwannGT/lisa#3814).
|
|
106
|
+
#
|
|
107
|
+
# Absent library means no dedupe, which is the pre-existing behaviour, so an
|
|
108
|
+
# older channel copy that predates it is unaffected.
|
|
109
|
+
lisa_guard_hook_dir="${BASH_SOURCE[0]%/*}"
|
|
110
|
+
lisa_guard_dedupe_lib="$lisa_guard_hook_dir/guard-dedupe.bash"
|
|
111
|
+
if [ -r "$lisa_guard_dedupe_lib" ]; then
|
|
112
|
+
# shellcheck source=guard-dedupe.bash
|
|
113
|
+
. "$lisa_guard_dedupe_lib"
|
|
114
|
+
trap 'lisa_guard_dedupe_record $?' EXIT
|
|
115
|
+
lisa_guard_dedupe block-no-verify "$input"
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
# Both interpreters must be probed BEFORE they are used, and a missing one must
|
|
119
|
+
# be announced rather than swallowed.
|
|
120
|
+
#
|
|
121
|
+
# `jq` used to be called unguarded here while `python3` two lines below was
|
|
122
|
+
# guarded. Under `set -e` an absent jq aborted the script with 127, and Claude
|
|
123
|
+
# Code treats any non-2 exit as a NON-BLOCKING hook error — so the hook that
|
|
124
|
+
# enforces "never --no-verify" silently permitted the very thing it exists to
|
|
125
|
+
# stop. It was not hypothetical: agent containers routinely ship no jq (that is
|
|
126
|
+
# why jq is now a pinned toolchain entry). The Codex variant of this script has
|
|
127
|
+
# always had the guard, so this was a parity gap rather than a design choice.
|
|
128
|
+
#
|
|
129
|
+
# Degrading to "allow" is still the right behaviour — a hook that cannot parse
|
|
130
|
+
# its input cannot tell a bypass from an ordinary command, and failing closed
|
|
131
|
+
# would block every Bash call on a machine missing an interpreter. What is NOT
|
|
132
|
+
# right is doing it quietly, so the operator gets one line on stderr saying the
|
|
133
|
+
# protection is off. A guard that is silently absent reads exactly like a guard
|
|
134
|
+
# that is passing.
|
|
135
|
+
for required in jq python3; do
|
|
136
|
+
if ! command -v "$required" >/dev/null 2>&1; then
|
|
137
|
+
printf 'block-no-verify: %s not found; --no-verify protection is NOT active\n' \
|
|
138
|
+
"$required" >&2
|
|
139
|
+
exit 0
|
|
140
|
+
fi
|
|
141
|
+
done
|
|
142
|
+
|
|
143
|
+
tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null || true)"
|
|
144
|
+
if [ "$tool_name" != "Bash" ]; then
|
|
145
|
+
exit 0
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
|
|
149
|
+
if [ -z "$command_str" ]; then
|
|
150
|
+
exit 0
|
|
151
|
+
fi
|
|
152
|
+
|
|
153
|
+
if ! BLOCK_NO_VERIFY_COMMAND="$command_str" python3 - <<'PY'
|
|
154
|
+
import os
|
|
155
|
+
import io
|
|
156
|
+
import re
|
|
157
|
+
import shlex
|
|
158
|
+
import sys
|
|
159
|
+
|
|
160
|
+
command = os.environ.get("BLOCK_NO_VERIFY_COMMAND", "")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def strip_heredocs(text: str) -> str:
|
|
164
|
+
lines = text.splitlines()
|
|
165
|
+
output = []
|
|
166
|
+
pending = []
|
|
167
|
+
marker_pattern = re.compile(
|
|
168
|
+
r'''(?<!<)<<-?(?!<)\s*((?:'[^']*'|"(?:[^"\\]|\\.)*"|\\.|[^\s;&|<>()])+)'''
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def marker_word(raw: str):
|
|
172
|
+
try:
|
|
173
|
+
words = shlex.split(raw, posix=True)
|
|
174
|
+
except ValueError:
|
|
175
|
+
return None
|
|
176
|
+
# An explicitly quoted empty word is a valid heredoc delimiter. Bash
|
|
177
|
+
# terminates that heredoc on the next empty line, so preserve "" as a
|
|
178
|
+
# marker instead of treating it as a parse failure.
|
|
179
|
+
return words[0] if len(words) == 1 else None
|
|
180
|
+
|
|
181
|
+
index = 0
|
|
182
|
+
while index < len(lines):
|
|
183
|
+
line = lines[index]
|
|
184
|
+
output.append(line)
|
|
185
|
+
pending.extend(
|
|
186
|
+
marker
|
|
187
|
+
for match in marker_pattern.finditer(line)
|
|
188
|
+
if (marker := marker_word(match.group(1))) is not None
|
|
189
|
+
)
|
|
190
|
+
index += 1
|
|
191
|
+
while pending and index < len(lines):
|
|
192
|
+
if lines[index].strip() == pending[0]:
|
|
193
|
+
output.append(lines[index])
|
|
194
|
+
pending.pop(0)
|
|
195
|
+
index += 1
|
|
196
|
+
break
|
|
197
|
+
index += 1
|
|
198
|
+
return "\n".join(output)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# The only relocations that keep a repo's own hooks in play. `.husky` is the
|
|
202
|
+
# husky convention this fleet runs; `.githooks` is the common hand-rolled one.
|
|
203
|
+
# Anything else — including "" and /dev/null, which are simply the two most
|
|
204
|
+
# obvious members of the blocked set rather than special cases — is refused.
|
|
205
|
+
PERMITTED_HOOKS_PATHS = {".husky", ".githooks"}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def is_permitted_hooks_path(value):
|
|
209
|
+
"""Whether a core.hooksPath value relocates hooks rather than disabling them.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
value: The raw core.hooksPath value as it appeared on the command line.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
True if the path is an established in-repo hooks directory.
|
|
216
|
+
"""
|
|
217
|
+
cleaned = value.strip().strip("'\"")
|
|
218
|
+
if cleaned.startswith("./"):
|
|
219
|
+
cleaned = cleaned[2:]
|
|
220
|
+
return cleaned.rstrip("/") in PERMITTED_HOOKS_PATHS
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# Git resolves any UNAMBIGUOUS abbreviation of a long option, so `--no-verify`
|
|
224
|
+
# is only the longest of the spellings that disable verification: `git commit
|
|
225
|
+
# --no-veri` skips hooks exactly as completely. An equality check therefore
|
|
226
|
+
# enforced the guard against the one spelling nobody in a hurry types.
|
|
227
|
+
#
|
|
228
|
+
# Matched as "a prefix of --no-verify" rather than by listing abbreviations,
|
|
229
|
+
# because the set of accepted abbreviations is a property of git's parser and
|
|
230
|
+
# changes with the surrounding options. `--no-verbose` is NOT caught, and must
|
|
231
|
+
# not be: it diverges from `--no-verify` at the character after `--no-ver`, so
|
|
232
|
+
# it fails the prefix test.
|
|
233
|
+
#
|
|
234
|
+
# The floor is `--no-v`. Shorter is refused anyway — bare `--no-` is not a flag
|
|
235
|
+
# — and blocking an abbreviation git would reject as ambiguous costs nothing,
|
|
236
|
+
# while missing one git accepts costs the whole guard.
|
|
237
|
+
NO_VERIFY = "--no-verify"
|
|
238
|
+
NO_VERIFY_MIN_PREFIX = len("--no-v")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def disables_verification(token):
|
|
242
|
+
"""Whether a token is `--no-verify` or an abbreviation git would accept.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
token: A single shell token from the command line.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
True if git would read this token as --no-verify.
|
|
249
|
+
"""
|
|
250
|
+
return (
|
|
251
|
+
len(token) >= NO_VERIFY_MIN_PREFIX
|
|
252
|
+
and len(token) <= len(NO_VERIFY)
|
|
253
|
+
and NO_VERIFY.startswith(token)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# `git commit -n` is the short spelling of --no-verify and skips pre-commit and
|
|
258
|
+
# commit-msg identically. Matching it needs one thing the `--no-verify` scan
|
|
259
|
+
# above does not: SCOPE. `-n` means --dry-run to `git push`, --no-stat to `git
|
|
260
|
+
# merge`, and a line count to head/tail/sort/grep, so a token-anywhere match
|
|
261
|
+
# would refuse ordinary commands all day. Only `git commit` reads it as a hook
|
|
262
|
+
# bypass, so only that invocation's argv is scanned.
|
|
263
|
+
#
|
|
264
|
+
# The tokenizer below is a SECOND pass, deliberately not shared with the one the
|
|
265
|
+
# checks above use. `shlex.split` leaves `|` and `;` glued to their neighbours,
|
|
266
|
+
# which those checks paper over by stripping the punctuation off each token —
|
|
267
|
+
# fine when the question is "does this token look like a bypass anywhere in the
|
|
268
|
+
# line", fatal when the question is "where does this command end", because
|
|
269
|
+
# `git commit -m x && grep -n foo` would then read as one long git invocation
|
|
270
|
+
# and refuse the grep. `punctuation_chars=True` emits the operators as their own
|
|
271
|
+
# tokens, so the invocation's boundary is a token the scan can stop at.
|
|
272
|
+
COMMAND_SEPARATORS = {
|
|
273
|
+
";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
|
|
274
|
+
# `|&` pipes stdout AND stderr into the next command, so it ends a command
|
|
275
|
+
# exactly as `|` does. shlex emits it as one token, and before it was listed
|
|
276
|
+
# here `echo hi |& git commit --no-verify` read as a single `echo` command
|
|
277
|
+
# whose arguments happened to include git. The case terminators end a
|
|
278
|
+
# command the same way.
|
|
279
|
+
"|&", ";;", ";&", ";;&",
|
|
280
|
+
}
|
|
281
|
+
# shlex also GLUES adjacent punctuation into one token across distinct
|
|
282
|
+
# operators: `(echo hi)|&git ...` arrives as `)|&`, which no set membership
|
|
283
|
+
# test can recognise. An operator-only token is therefore re-split into the
|
|
284
|
+
# operators it is made of, longest first. Redirections are kept whole and are
|
|
285
|
+
# deliberately NOT separators, so `git commit 2>&1 -n` keeps its `-n` inside the
|
|
286
|
+
# commit's argv scope.
|
|
287
|
+
OPERATOR_ONLY = re.compile(r"^[();<>|&]+$")
|
|
288
|
+
SHELL_OPERATOR = re.compile("|".join(
|
|
289
|
+
re.escape(operator) for operator in sorted(
|
|
290
|
+
COMMAND_SEPARATORS | {">|", "&>", "&>>", ">&", "<&", "<>", "<<<"},
|
|
291
|
+
key=lambda operator: (-len(operator), operator),
|
|
292
|
+
)
|
|
293
|
+
))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def split_operators(token):
|
|
297
|
+
"""Split a glued operator-only token into the shell operators it holds.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
token: One shlex token that was not read inside a quote or escape.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
The operators, longest first, or the token itself when it is not
|
|
304
|
+
made only of shell punctuation.
|
|
305
|
+
"""
|
|
306
|
+
if OPERATOR_ONLY.match(token):
|
|
307
|
+
return SHELL_OPERATOR.findall(token)
|
|
308
|
+
return [token]
|
|
309
|
+
|
|
310
|
+
SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
|
|
311
|
+
MAX_SHELL_NESTING = 8
|
|
312
|
+
|
|
313
|
+
# GNU env's long options, listed in FULL so an abbreviation can be resolved
|
|
314
|
+
# against them. getopt_long accepts any prefix that names exactly one option,
|
|
315
|
+
# and no other env option begins with `s` — so `env --s`, `env --sp`, and
|
|
316
|
+
# `env --split` are `env --split-string` just as completely as the full
|
|
317
|
+
# spelling is. Matching only the full spelling is bypassed by deleting
|
|
318
|
+
# characters, which is why the set below exists instead of a literal list of
|
|
319
|
+
# the two spellings the guard used to know.
|
|
320
|
+
ENV_LONG_OPTIONS = {
|
|
321
|
+
"--argv0", "--block-signals", "--chdir", "--debug", "--default-signal",
|
|
322
|
+
"--help", "--ignore-environment", "--ignore-signal",
|
|
323
|
+
"--list-signal-handling", "--null", "--split-string", "--unset",
|
|
324
|
+
"--version",
|
|
325
|
+
}
|
|
326
|
+
# Of those, the ones whose value is a SEPARATE token, so the operand is never
|
|
327
|
+
# mistaken for the command (`env -u bash -c ...` consumes `bash` as the
|
|
328
|
+
# variable name). The signal options take an OPTIONAL value, which GNU reads
|
|
329
|
+
# only when it is attached with `=`, so they never consume a following token.
|
|
330
|
+
ENV_LONG_SEPARATE_VALUE = {"--argv0", "--chdir", "--split-string", "--unset"}
|
|
331
|
+
|
|
332
|
+
# env's single-letter options, by whether the rest of a cluster is more
|
|
333
|
+
# options or the option's value.
|
|
334
|
+
#
|
|
335
|
+
# BOTH PLATFORMS' SETS, not one. These began as GNU's, and an option that is
|
|
336
|
+
# in neither set falls through to `ambiguous`, which this guard treats as
|
|
337
|
+
# suspicious. That default is correct and stays — an unknown option genuinely
|
|
338
|
+
# could take a value and swallow the command name. What is wrong is a
|
|
339
|
+
# well-known option being unknown, and on a macOS fleet `env -P` is exactly
|
|
340
|
+
# that: BSD's "search altpath for the utility", refused on every ordinary
|
|
341
|
+
# command it prefixes.
|
|
342
|
+
#
|
|
343
|
+
# Measured, one option at a time, against the guard before this change. BSD's
|
|
344
|
+
# complete short set is `0 i v u C P S`, from the synopsis in env(1):
|
|
345
|
+
#
|
|
346
|
+
# allowed -0 -i -v -u NAME -C DIR already covered
|
|
347
|
+
# BLOCKED -P altpath the defect, and the ONLY one
|
|
348
|
+
# BLOCKED -S string correct: split-string reparses
|
|
349
|
+
# an opaque payload, so what it
|
|
350
|
+
# runs cannot be proven here
|
|
351
|
+
# BLOCKED -Z -Q correct: unknown stays suspicious
|
|
352
|
+
#
|
|
353
|
+
# So the population is one letter, not a class. The two platforms' short
|
|
354
|
+
# options coincide everywhere else, which is why "detect the platform and
|
|
355
|
+
# choose a table" would be machinery for a single character — and would fail
|
|
356
|
+
# on a machine running GNU coreutils under a BSD userland, or the reverse.
|
|
357
|
+
# Recognising the union is both smaller and more robust.
|
|
358
|
+
#
|
|
359
|
+
# Adding a letter here is a WEAKENING, so it needs the same evidence as any
|
|
360
|
+
# other: ten bypass shapes routed through `-P` — long flag, attached value,
|
|
361
|
+
# short cluster, abbreviation, an assignment, `HUSKY=0`, a hooksPath disable,
|
|
362
|
+
# a nested `-c` shell, an executed script, and `-P` swallowing the command
|
|
363
|
+
# word — were all refused before this change and are all still refused after.
|
|
364
|
+
ENV_SHORT_NO_VALUE = frozenset("i0v")
|
|
365
|
+
ENV_SHORT_TAKES_VALUE = frozenset("uCaSP")
|
|
366
|
+
|
|
367
|
+
# Builtins and wrappers that run whatever FOLLOWS them without changing what
|
|
368
|
+
# it is: `command env -S ...` runs exactly the `env` that `env -S ...` runs,
|
|
369
|
+
# and `env -- env -S ...` runs exactly the inner `env`. Reading the wrapper as
|
|
370
|
+
# the command is how a payload hides from a guard that inspects only the first
|
|
371
|
+
# word of an invocation.
|
|
372
|
+
COMMAND_PREFIX_WRAPPERS = {"command", "exec", "builtin", "nohup", "setsid"}
|
|
373
|
+
# The only option of those that consumes a separate following token
|
|
374
|
+
# (`exec -a NAME env -S ...`).
|
|
375
|
+
COMMAND_PREFIX_SEPARATE_VALUE = {"-a"}
|
|
376
|
+
|
|
377
|
+
# Shell options whose following token is an operand, not another option. The
|
|
378
|
+
# generic recursive scanner must step over these before looking for `-c`.
|
|
379
|
+
SHELL_OPTIONS_SEPARATE_VALUE = {
|
|
380
|
+
"-o", "+o", "-O", "+O", "--rcfile", "--init-file", "--emulate",
|
|
381
|
+
}
|
|
382
|
+
SHELL_OPTIONS_INLINE_VALUE = ("--rcfile=", "--init-file=", "--emulate=")
|
|
383
|
+
|
|
384
|
+
# Boolean long options of the recognized shells. Listed only for PRECISION:
|
|
385
|
+
# a long option is never the command-string flag either way, but naming the
|
|
386
|
+
# ordinary ones keeps `bash --norc script.sh` from being treated as a possible
|
|
387
|
+
# value-taking option whose operand must be scanned past. zsh and ksh accept a
|
|
388
|
+
# long option per shell setting, so this set is deliberately not exhaustive —
|
|
389
|
+
# the scanner stays correct on the ones it does not name.
|
|
390
|
+
SHELL_LONG_OPTIONS_NO_VALUE = {
|
|
391
|
+
"--debugger", "--dump-po-strings", "--dump-strings", "--emacs",
|
|
392
|
+
"--globalrcs", "--help", "--interactive", "--login", "--monitor",
|
|
393
|
+
"--no-globalrcs", "--no-rcs", "--noediting", "--noprofile", "--norc",
|
|
394
|
+
"--norcs", "--posix", "--pretty-print", "--privileged", "--protected",
|
|
395
|
+
"--rcs", "--restricted", "--verbose", "--version", "--vi", "--wordexp",
|
|
396
|
+
"--xtrace",
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
# git's own options that take a SEPARATE value token, which therefore must be
|
|
400
|
+
# skipped when looking for the subcommand: `git -c core.hooksPath=x commit` and
|
|
401
|
+
# `git -C /repo commit` both reach `commit`.
|
|
402
|
+
GIT_GLOBAL_SEPARATE_VALUE = {
|
|
403
|
+
"-c", "-C", "--config-env", "--git-dir", "--work-tree",
|
|
404
|
+
"--namespace", "--exec-path", "--super-prefix",
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
# `git commit` long options whose value can be a separate token. Listed so the
|
|
408
|
+
# VALUE is never mistaken for a flag — `git commit --author "A -n B" -m x`
|
|
409
|
+
# must stay allowed. Only genuinely value-taking options belong here: adding a
|
|
410
|
+
# boolean one by mistake would swallow the token after it, and `git commit
|
|
411
|
+
# --amend -n` would go unnoticed.
|
|
412
|
+
COMMIT_LONG_SEPARATE_VALUE = {
|
|
413
|
+
"--message", "--file", "--author", "--date", "--template", "--cleanup",
|
|
414
|
+
"--reuse-message", "--reedit-message", "--fixup", "--squash",
|
|
415
|
+
"--pathspec-from-file", "--trailer",
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
# Short `git commit` options that REQUIRE a value: `-m`/`-F`/`-c`/`-C`/`-t`.
|
|
419
|
+
# In a cluster the value is whatever follows them in the same token, or the next
|
|
420
|
+
# token when nothing does — which is why `-mn` is the message "n" and not a
|
|
421
|
+
# bypass, and `-nm msg` IS one.
|
|
422
|
+
COMMIT_SHORT_REQUIRED_VALUE = set("mFcCt")
|
|
423
|
+
|
|
424
|
+
# Short options taking an OPTIONAL value, which git only ever reads attached:
|
|
425
|
+
# `-uno` is --untracked-files=no, `-Skeyid` is --gpg-sign=keyid. The cluster
|
|
426
|
+
# ends at them either way, so `-un` is untracked-files "n" rather than a bypass.
|
|
427
|
+
COMMIT_SHORT_OPTIONAL_VALUE = set("uS")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def line_boundaries_as_separators(text):
|
|
431
|
+
"""Turn newlines into command separators, keeping continuations intact.
|
|
432
|
+
|
|
433
|
+
A newline ends a command exactly as `;` does, but shlex treats it as plain
|
|
434
|
+
whitespace — so `git commit -m x` on one line and `grep -n foo` on the next
|
|
435
|
+
read as ONE invocation and the grep gets refused. That is the same
|
|
436
|
+
false-positive class the short-form match exists to avoid, arriving through
|
|
437
|
+
the boundary rather than through the token.
|
|
438
|
+
|
|
439
|
+
Backslash-newline is joined first, because there the newline is NOT a
|
|
440
|
+
boundary: `git commit \\` + newline + `-nm x` is one command, and turning
|
|
441
|
+
that newline into a separator would hide a real bypass.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
text: The command line, heredoc payloads already stripped.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
The same command with line breaks spelled as separators.
|
|
448
|
+
"""
|
|
449
|
+
joined = text.replace("\r\n", "\n").replace("\\\n", " ")
|
|
450
|
+
return joined.replace("\n", " ; ")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def shell_tokens(text):
|
|
454
|
+
"""Tokenize a command with shell operators kept as their own tokens.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
text: The command line, heredoc payloads already stripped.
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
|
|
461
|
+
"""
|
|
462
|
+
stream = OperatorStream(line_boundaries_as_separators(text))
|
|
463
|
+
lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
|
|
464
|
+
lexer.whitespace_split = True
|
|
465
|
+
# shlex treats `#` as a comment introducer by default and would silently
|
|
466
|
+
# truncate the rest of the line; `shlex.split` disables it, and so must this.
|
|
467
|
+
lexer.commenters = ""
|
|
468
|
+
stream.lexer = lexer
|
|
469
|
+
tokens = []
|
|
470
|
+
while True:
|
|
471
|
+
stream.literal_operator = False
|
|
472
|
+
token = lexer.get_token()
|
|
473
|
+
if token is None:
|
|
474
|
+
return tokens
|
|
475
|
+
# Quoted punctuation is an argument, never a boundary: it stays whole,
|
|
476
|
+
# and a quoted separator is marked so no scan stops at it (a path or
|
|
477
|
+
# branch can be named `|&`). Only real operator runs are split.
|
|
478
|
+
if stream.literal_operator:
|
|
479
|
+
tokens.append(
|
|
480
|
+
chr(0) + token if token in COMMAND_SEPARATORS else token
|
|
481
|
+
)
|
|
482
|
+
else:
|
|
483
|
+
tokens.extend(split_operators(token))
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def cluster_skips_verification(cluster):
|
|
487
|
+
"""Read a short-option cluster the way git's own parser reads it.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
cluster: A single token beginning with one `-`, e.g. `-nm` or `-mn`.
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
A pair (bypasses, consumes_next_token). `bypasses` is True when a real
|
|
494
|
+
`-n` option is present; `consumes_next_token` is True when the cluster
|
|
495
|
+
ends in a value-taking option whose value is the following token.
|
|
496
|
+
"""
|
|
497
|
+
body = cluster[1:]
|
|
498
|
+
for offset, letter in enumerate(body):
|
|
499
|
+
if letter == "n":
|
|
500
|
+
return (True, False)
|
|
501
|
+
if letter in COMMIT_SHORT_REQUIRED_VALUE:
|
|
502
|
+
# Everything after this letter is the value. It is a separate token
|
|
503
|
+
# only when nothing is attached.
|
|
504
|
+
return (False, offset == len(body) - 1)
|
|
505
|
+
if letter in COMMIT_SHORT_OPTIONAL_VALUE:
|
|
506
|
+
return (False, False)
|
|
507
|
+
return (False, False)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def commit_bypasses_verification(argv):
|
|
511
|
+
"""Whether a `git commit` invocation's argv carries a real short `-n`.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
argv: Tokens following the `commit` subcommand, to the end of the line.
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
True if git would read one of them as --no-verify.
|
|
518
|
+
"""
|
|
519
|
+
index = 0
|
|
520
|
+
while index < len(argv):
|
|
521
|
+
token = argv[index]
|
|
522
|
+
index += 1
|
|
523
|
+
# `--` ends the options; everything after it is a pathspec, and a file
|
|
524
|
+
# legitimately named `-n` is not a bypass.
|
|
525
|
+
if token in COMMAND_SEPARATORS or token == "--":
|
|
526
|
+
return False
|
|
527
|
+
if token.startswith("--"):
|
|
528
|
+
if token in COMMIT_LONG_SEPARATE_VALUE:
|
|
529
|
+
index += 1
|
|
530
|
+
elif disables_verification(token):
|
|
531
|
+
return True
|
|
532
|
+
continue
|
|
533
|
+
# A bare `-` is git's stdin placeholder, not an option cluster.
|
|
534
|
+
if not token.startswith("-") or token == "-":
|
|
535
|
+
continue
|
|
536
|
+
bypasses, consumes_next = cluster_skips_verification(token)
|
|
537
|
+
if bypasses:
|
|
538
|
+
return True
|
|
539
|
+
if consumes_next:
|
|
540
|
+
index += 1
|
|
541
|
+
return False
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def subcommand_after_git(tokens, start):
|
|
545
|
+
"""Find the subcommand a `git` token introduces, and its argv.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
tokens: The full operator-aware token list.
|
|
549
|
+
start: Index just past the `git` token.
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
A pair (subcommand, argv), or None when the invocation names none.
|
|
553
|
+
"""
|
|
554
|
+
index = start
|
|
555
|
+
while index < len(tokens):
|
|
556
|
+
token = tokens[index]
|
|
557
|
+
if token in COMMAND_SEPARATORS:
|
|
558
|
+
return None
|
|
559
|
+
if not token.startswith("-"):
|
|
560
|
+
return (token, tokens[index + 1:])
|
|
561
|
+
index += 2 if token in GIT_GLOBAL_SEPARATE_VALUE else 1
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def resolve_env_long_option(name):
|
|
566
|
+
"""Resolve one GNU env long option, abbreviations included.
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
name: A token beginning with `--`, with any `=value` already removed.
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
The full option spelling; "" when the abbreviation names more than one
|
|
573
|
+
option, which real env refuses; None when nothing matches at all.
|
|
574
|
+
"""
|
|
575
|
+
if not name.startswith("--") or name == "--":
|
|
576
|
+
return None
|
|
577
|
+
if name in ENV_LONG_OPTIONS:
|
|
578
|
+
return name
|
|
579
|
+
matches = [option for option in ENV_LONG_OPTIONS if option.startswith(name)]
|
|
580
|
+
if len(matches) == 1:
|
|
581
|
+
return matches[0]
|
|
582
|
+
return "" if matches else None
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def env_option_kind(token):
|
|
586
|
+
"""Classify one token sitting in an `env` option position.
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
"split-string" when env will reparse an argv value as shell words,
|
|
590
|
+
"separate-value" when the NEXT token is this option's operand,
|
|
591
|
+
"no-value" when the token stands alone, "ambiguous" when env accepts
|
|
592
|
+
the token but this parser cannot say which option it is, or None when
|
|
593
|
+
the token is not an option.
|
|
594
|
+
"""
|
|
595
|
+
if not token.startswith("-") or token in {"-", "--"}:
|
|
596
|
+
return None
|
|
597
|
+
if token.startswith("--"):
|
|
598
|
+
name = token.split("=", 1)[0]
|
|
599
|
+
resolved = resolve_env_long_option(name)
|
|
600
|
+
if not resolved:
|
|
601
|
+
# Either unknown to this parser or an abbreviation naming several
|
|
602
|
+
# options. Neither can be reasoned about, so say so and let the
|
|
603
|
+
# caller fail closed rather than read past it.
|
|
604
|
+
return "ambiguous"
|
|
605
|
+
if resolved == "--split-string":
|
|
606
|
+
return "split-string"
|
|
607
|
+
if "=" in token:
|
|
608
|
+
return "no-value"
|
|
609
|
+
return (
|
|
610
|
+
"separate-value" if resolved in ENV_LONG_SEPARATE_VALUE else "no-value"
|
|
611
|
+
)
|
|
612
|
+
for position, letter in enumerate(token[1:], start=2):
|
|
613
|
+
if letter == "S":
|
|
614
|
+
return "split-string"
|
|
615
|
+
if letter in ENV_SHORT_TAKES_VALUE:
|
|
616
|
+
# The rest of this cluster is the option's value when there is
|
|
617
|
+
# one; otherwise the value is the following token.
|
|
618
|
+
return "no-value" if position <= len(token) - 1 else "separate-value"
|
|
619
|
+
if letter not in ENV_SHORT_NO_VALUE:
|
|
620
|
+
return "ambiguous"
|
|
621
|
+
return "no-value"
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def strip_command_prefix(prefix):
|
|
625
|
+
"""Drop the tokens that precede a command without changing what it is.
|
|
626
|
+
|
|
627
|
+
Variable assignments, the `command`/`exec`/`builtin` builtins, and nested
|
|
628
|
+
`env` wrappers all leave the following word in command position. Reading
|
|
629
|
+
any of them as the command itself is what let `command env -S ...` and
|
|
630
|
+
`env -- env -S ...` slip past the split-string check: the prefix failed
|
|
631
|
+
the "assignments only" test, so the wrapped `env` was never classified.
|
|
632
|
+
|
|
633
|
+
Args:
|
|
634
|
+
prefix: The tokens between the last command separator and the
|
|
635
|
+
candidate program.
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
A pair (status, remainder). "ok" carries the tokens still standing
|
|
639
|
+
between the separator and the candidate — empty means the candidate is
|
|
640
|
+
in command position. "operand" means the candidate is a wrapper
|
|
641
|
+
option's value rather than a command. "ambiguous" means a wrapper
|
|
642
|
+
option could not be classified and the caller must fail closed.
|
|
643
|
+
"""
|
|
644
|
+
cursor = 0
|
|
645
|
+
while cursor < len(prefix):
|
|
646
|
+
token = prefix[cursor]
|
|
647
|
+
if "=" in token and not token.startswith(("=", "-")):
|
|
648
|
+
cursor += 1
|
|
649
|
+
continue
|
|
650
|
+
program = token.rsplit("/", 1)[-1]
|
|
651
|
+
if program in COMMAND_PREFIX_WRAPPERS:
|
|
652
|
+
cursor += 1
|
|
653
|
+
while cursor < len(prefix) and prefix[cursor].startswith("-"):
|
|
654
|
+
if prefix[cursor] == "--":
|
|
655
|
+
cursor += 1
|
|
656
|
+
break
|
|
657
|
+
if prefix[cursor] in COMMAND_PREFIX_SEPARATE_VALUE:
|
|
658
|
+
if cursor + 1 >= len(prefix):
|
|
659
|
+
return ("operand", [])
|
|
660
|
+
cursor += 2
|
|
661
|
+
continue
|
|
662
|
+
cursor += 1
|
|
663
|
+
continue
|
|
664
|
+
if program == "env":
|
|
665
|
+
cursor += 1
|
|
666
|
+
while cursor < len(prefix):
|
|
667
|
+
option = prefix[cursor]
|
|
668
|
+
if option == "--":
|
|
669
|
+
cursor += 1
|
|
670
|
+
break
|
|
671
|
+
kind = env_option_kind(option)
|
|
672
|
+
if kind is None:
|
|
673
|
+
break
|
|
674
|
+
if kind == "ambiguous":
|
|
675
|
+
return ("ambiguous", [])
|
|
676
|
+
if kind == "split-string":
|
|
677
|
+
# Everything after `-S` is env's own opaque payload, so the
|
|
678
|
+
# candidate is not a command at all.
|
|
679
|
+
return ("operand", [])
|
|
680
|
+
if kind == "separate-value":
|
|
681
|
+
if cursor + 1 >= len(prefix):
|
|
682
|
+
return ("operand", [])
|
|
683
|
+
cursor += 2
|
|
684
|
+
continue
|
|
685
|
+
cursor += 1
|
|
686
|
+
continue
|
|
687
|
+
break
|
|
688
|
+
return ("ok", prefix[cursor:])
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def shell_starts_command(tokens, index):
|
|
692
|
+
"""Whether a recognized shell token occupies command position."""
|
|
693
|
+
start = index - 1
|
|
694
|
+
while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
|
|
695
|
+
start -= 1
|
|
696
|
+
status, remainder = strip_command_prefix(tokens[start + 1:index])
|
|
697
|
+
if status == "ambiguous":
|
|
698
|
+
# A prefix option this parser cannot classify may still be launching
|
|
699
|
+
# the shell. Fail closed rather than trusting the wrapper.
|
|
700
|
+
return True
|
|
701
|
+
return status == "ok" and not remainder
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def printed_argument(tokens, index):
|
|
705
|
+
"""Whether a token is data passed to an ordinary echo or printf command."""
|
|
706
|
+
start = index - 1
|
|
707
|
+
while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
|
|
708
|
+
start -= 1
|
|
709
|
+
program, _, opaque = command_word(tokens[start + 1:index])
|
|
710
|
+
return not opaque and program in {"echo", "printf"}
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def env_uses_split_string(tokens, index):
|
|
714
|
+
"""Whether a command-position env invocation uses split-string parsing.
|
|
715
|
+
|
|
716
|
+
`env -S` reparses one opaque argv value as shell-like words. Inspecting the
|
|
717
|
+
outer command cannot prove what executable or nested shell that second
|
|
718
|
+
parse will produce, so the verification guard refuses the ambiguous form.
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
tokens: The full operator-aware token list.
|
|
722
|
+
index: Index of the candidate env executable.
|
|
723
|
+
|
|
724
|
+
Returns:
|
|
725
|
+
True when env will reparse a split-string payload.
|
|
726
|
+
"""
|
|
727
|
+
start = index - 1
|
|
728
|
+
while start >= 0 and tokens[start] not in COMMAND_SEPARATORS:
|
|
729
|
+
start -= 1
|
|
730
|
+
status, remainder = strip_command_prefix(tokens[start + 1:index])
|
|
731
|
+
if status != "ok" or remainder:
|
|
732
|
+
return False
|
|
733
|
+
|
|
734
|
+
cursor = index + 1
|
|
735
|
+
while cursor < len(tokens):
|
|
736
|
+
token = tokens[cursor]
|
|
737
|
+
if token in COMMAND_SEPARATORS or token == "--":
|
|
738
|
+
return False
|
|
739
|
+
kind = env_option_kind(token)
|
|
740
|
+
if kind == "split-string":
|
|
741
|
+
return True
|
|
742
|
+
if kind == "ambiguous":
|
|
743
|
+
# An option env accepts but this parser cannot name makes the rest
|
|
744
|
+
# of the invocation unreadable; refuse instead of reading past it.
|
|
745
|
+
return True
|
|
746
|
+
if kind is None:
|
|
747
|
+
if "=" in token and not token.startswith(("=", "-")):
|
|
748
|
+
cursor += 1
|
|
749
|
+
continue
|
|
750
|
+
return False
|
|
751
|
+
cursor += 2 if kind == "separate-value" else 1
|
|
752
|
+
return False
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def nested_shell_payload(tokens, index):
|
|
756
|
+
"""Return a shell `-c` payload, or True when option parsing is ambiguous."""
|
|
757
|
+
cursor = index + 1
|
|
758
|
+
opaque_option_seen = False
|
|
759
|
+
while cursor < len(tokens):
|
|
760
|
+
option = tokens[cursor]
|
|
761
|
+
if option in COMMAND_SEPARATORS or option == "--":
|
|
762
|
+
return None
|
|
763
|
+
if not option.startswith(("-", "+")):
|
|
764
|
+
# Ordinarily the script operand, which ends option parsing. After
|
|
765
|
+
# a long option this parser cannot name it may instead be that
|
|
766
|
+
# option's value (`zsh --emulate ksh -c ...`), so keep scanning
|
|
767
|
+
# rather than concluding there is no command string.
|
|
768
|
+
if not opaque_option_seen:
|
|
769
|
+
return None
|
|
770
|
+
cursor += 1
|
|
771
|
+
continue
|
|
772
|
+
if option in SHELL_OPTIONS_SEPARATE_VALUE:
|
|
773
|
+
if cursor + 1 >= len(tokens) or tokens[cursor + 1] in COMMAND_SEPARATORS:
|
|
774
|
+
return True
|
|
775
|
+
cursor += 2
|
|
776
|
+
continue
|
|
777
|
+
if option.startswith(SHELL_OPTIONS_INLINE_VALUE):
|
|
778
|
+
cursor += 1
|
|
779
|
+
continue
|
|
780
|
+
# A LONG option is never the command-string flag, and testing it for a
|
|
781
|
+
# bare `c` was the bug: `--norc` and `--restricted` merely CONTAIN one,
|
|
782
|
+
# so the real `-c` that followed was consumed as their payload and the
|
|
783
|
+
# nested `git commit --no-verify` was never classified at all. The
|
|
784
|
+
# command string lives only in a single-dash cluster (`-c`, `-lc`).
|
|
785
|
+
if option.startswith("--"):
|
|
786
|
+
if option not in SHELL_LONG_OPTIONS_NO_VALUE:
|
|
787
|
+
opaque_option_seen = True
|
|
788
|
+
cursor += 1
|
|
789
|
+
continue
|
|
790
|
+
if "c" in option[1:]:
|
|
791
|
+
if cursor + 1 >= len(tokens) or tokens[cursor + 1] in COMMAND_SEPARATORS:
|
|
792
|
+
return True
|
|
793
|
+
return tokens[cursor + 1]
|
|
794
|
+
cursor += 1
|
|
795
|
+
return None
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
# ── Reach: the contents of a script this command EXECUTES ──────────────────
|
|
799
|
+
#
|
|
800
|
+
# The guard used to inspect argv and nothing else, so `bash nv.sh` showed it two
|
|
801
|
+
# tokens, `bash` and a path, while the script it was about to run carried
|
|
802
|
+
# `git commit --no-verify` and skipped pre-commit exactly as completely as the
|
|
803
|
+
# inline spelling. Measured before this change: inline BLOCK, the same line one
|
|
804
|
+
# file away ALLOW — on bash, sh, zsh, `source`, `.`, an absolute interpreter
|
|
805
|
+
# path, and behind every wrapper.
|
|
806
|
+
#
|
|
807
|
+
# THE RULE IS TAKEN FROM parity-safety-net.sh RATHER THAN INVENTED HERE: a path
|
|
808
|
+
# is followed ONLY when the command EXECUTES it. `grep -n x nv.sh`,
|
|
809
|
+
# `git commit -F msg.txt` and `gh pr create --body-file body.md` name a file as
|
|
810
|
+
# DATA, and following those is the known-wrong fix that guard names by number —
|
|
811
|
+
# it refuses ordinary reads and attributes a file's capability to a command that
|
|
812
|
+
# merely mentions it. So COMMAND POSITION decides, never the presence of a path.
|
|
813
|
+
#
|
|
814
|
+
# Non-shell interpreters (`python3 x.py`, `node x.mjs`) are deliberately NOT
|
|
815
|
+
# followed. These matchers are git and shell syntax; running them over a Python
|
|
816
|
+
# or JS file buys mis-attribution, not coverage.
|
|
817
|
+
#
|
|
818
|
+
# Reach was not safe to add until the bare-token match was narrowed to a git
|
|
819
|
+
# argv — see `git_argv_disables_verification`. With the old unscoped matcher
|
|
820
|
+
# this guard would have refused `bash` on its own source, on every husky hook,
|
|
821
|
+
# and on much of this repository, all of which carry the literal token in prose.
|
|
822
|
+
FOLLOW_MAX_BYTES = 262144
|
|
823
|
+
FOLLOW_MAX_FILES = 8
|
|
824
|
+
FOLLOW_MAX_DEPTH = 3
|
|
825
|
+
|
|
826
|
+
# The `.` builtin and its `source` alias run the named file in the CURRENT
|
|
827
|
+
# shell, which is execution by any definition.
|
|
828
|
+
SOURCE_BUILTINS = {"source", "."}
|
|
829
|
+
|
|
830
|
+
# Wrappers that run what FOLLOWS them without changing what it is, mapped to
|
|
831
|
+
# (options whose value is a SEPARATE token, positional operands consumed).
|
|
832
|
+
#
|
|
833
|
+
# The second field is why this table exists rather than a bare set. `timeout 5
|
|
834
|
+
# bash x` puts a POSITIONAL between the wrapper and the interpreter, so a walk
|
|
835
|
+
# that steps over `-flags` only stops at `5` and never reaches `bash` — the
|
|
836
|
+
# shape that walked past three guards in this family before it was named.
|
|
837
|
+
FOLLOW_WRAPPERS = {
|
|
838
|
+
"builtin": (frozenset(), 0),
|
|
839
|
+
"command": (frozenset(), 0),
|
|
840
|
+
"exec": (frozenset({"-a"}), 0),
|
|
841
|
+
"nice": (frozenset({"-n", "--adjustment"}), 0),
|
|
842
|
+
"nohup": (frozenset(), 0),
|
|
843
|
+
"setsid": (frozenset(), 0),
|
|
844
|
+
"stdbuf": (frozenset({"-i", "-o", "-e"}), 0),
|
|
845
|
+
"sudo": (
|
|
846
|
+
frozenset(
|
|
847
|
+
{
|
|
848
|
+
"-C", "--close-from", "-g", "--group", "-h", "--host",
|
|
849
|
+
"-p", "--prompt", "-r", "--role", "-t", "--type",
|
|
850
|
+
"-U", "--other-user", "-u", "--user",
|
|
851
|
+
}
|
|
852
|
+
),
|
|
853
|
+
0,
|
|
854
|
+
),
|
|
855
|
+
"time": (frozenset(), 0),
|
|
856
|
+
"timeout": (frozenset({"-k", "--kill-after", "-s", "--signal"}), 1),
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
# Dispatchers that BUILD a command out of their own arguments, putting an
|
|
860
|
+
# interpreter at a command position the text does not spell as one. Scoped to
|
|
861
|
+
# exactly one token — the word after `find -exec`/`-execdir`, and `xargs`'s
|
|
862
|
+
# first operand — because latching "an interpreter name anywhere" refuses
|
|
863
|
+
# `find . -name bash`, where `bash` is a filename PATTERN and nothing runs.
|
|
864
|
+
FIND_EXEC_FLAGS = {"-exec", "-execdir"}
|
|
865
|
+
|
|
866
|
+
# A value the SHELL computes. This guard sees the command before expansion, so
|
|
867
|
+
# it cannot prove which file such a token will name.
|
|
868
|
+
COMPUTED_VALUE = re.compile(r"[$`]")
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def strip_shell_comments(text):
|
|
872
|
+
"""Remove `#` comments from a file's contents, quote-aware.
|
|
873
|
+
|
|
874
|
+
Applied to FILE CONTENTS and never to a typed command, because the two are
|
|
875
|
+
genuinely different problems. On a typed command `#` handling is a
|
|
876
|
+
bash-versus-shlex divergence a guard can be attacked through, which is why
|
|
877
|
+
`shell_tokens` sets `commenters = ""`. Inside a file the question is not
|
|
878
|
+
adversarial and the answer is unambiguous: a comment cannot execute.
|
|
879
|
+
|
|
880
|
+
It is also what makes reach usable at all. This repository's guards, its
|
|
881
|
+
husky hooks and its own refusal messages discuss `git commit -n` and
|
|
882
|
+
`--no-verify` in prose, and a comment line reading "`git commit -n` skips
|
|
883
|
+
pre-commit" tokenizes into exactly the argv of a real bypass. Following a
|
|
884
|
+
file without dropping its comments refuses this guard's own source.
|
|
885
|
+
|
|
886
|
+
Args:
|
|
887
|
+
text: A file's contents.
|
|
888
|
+
|
|
889
|
+
Returns:
|
|
890
|
+
The same text with unquoted comments removed.
|
|
891
|
+
"""
|
|
892
|
+
output = []
|
|
893
|
+
for line in text.splitlines():
|
|
894
|
+
quote = ""
|
|
895
|
+
escaped = False
|
|
896
|
+
cut = None
|
|
897
|
+
for position, character in enumerate(line):
|
|
898
|
+
if escaped:
|
|
899
|
+
escaped = False
|
|
900
|
+
continue
|
|
901
|
+
if character == "\\" and quote != "'":
|
|
902
|
+
escaped = True
|
|
903
|
+
continue
|
|
904
|
+
if quote:
|
|
905
|
+
if character == quote:
|
|
906
|
+
quote = ""
|
|
907
|
+
continue
|
|
908
|
+
if character in "'\"":
|
|
909
|
+
quote = character
|
|
910
|
+
continue
|
|
911
|
+
# A `#` only opens a comment at the start of a word, so `x#y` and
|
|
912
|
+
# `${x#y}` keep their hashes.
|
|
913
|
+
if character == "#" and (position == 0 or line[position - 1].isspace()):
|
|
914
|
+
cut = position
|
|
915
|
+
break
|
|
916
|
+
output.append(line if cut is None else line[:cut])
|
|
917
|
+
return "\n".join(output)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def statement_slices(tokens):
|
|
921
|
+
"""Split an operator-aware token list into statements.
|
|
922
|
+
|
|
923
|
+
Args:
|
|
924
|
+
tokens: The full operator-aware token list.
|
|
925
|
+
|
|
926
|
+
Returns:
|
|
927
|
+
Pairs of (separator that PRECEDED the statement, its tokens). The
|
|
928
|
+
separator is carried because `<` is both a statement boundary and the
|
|
929
|
+
link between `bash` and the script it reads — `bash < nv.sh` runs that
|
|
930
|
+
file as surely as `bash nv.sh` does.
|
|
931
|
+
"""
|
|
932
|
+
statements = []
|
|
933
|
+
current = []
|
|
934
|
+
separator = ""
|
|
935
|
+
for token in tokens:
|
|
936
|
+
if token in COMMAND_SEPARATORS:
|
|
937
|
+
statements.append((separator, current))
|
|
938
|
+
current = []
|
|
939
|
+
separator = token
|
|
940
|
+
continue
|
|
941
|
+
current.append(token)
|
|
942
|
+
statements.append((separator, current))
|
|
943
|
+
return statements
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def command_word(statement):
|
|
947
|
+
"""The program a statement runs, and the tokens after it.
|
|
948
|
+
|
|
949
|
+
Steps over leading variable assignments and over wrappers that do not
|
|
950
|
+
change what runs. Reading a wrapper as the command is how a payload hides
|
|
951
|
+
from a guard that inspects only a statement's first word.
|
|
952
|
+
|
|
953
|
+
Args:
|
|
954
|
+
statement: One statement's tokens.
|
|
955
|
+
|
|
956
|
+
Returns:
|
|
957
|
+
A triple (program, args, opaque). `opaque` is True when a wrapper's
|
|
958
|
+
option grammar could not be read, which the caller must treat as an
|
|
959
|
+
execution it cannot follow rather than as an ordinary command.
|
|
960
|
+
"""
|
|
961
|
+
index = 0
|
|
962
|
+
while index < len(statement):
|
|
963
|
+
token = statement[index]
|
|
964
|
+
if "=" in token and not token.startswith(("=", "-")):
|
|
965
|
+
index += 1
|
|
966
|
+
continue
|
|
967
|
+
program = token.rsplit("/", 1)[-1]
|
|
968
|
+
if program == "env":
|
|
969
|
+
index += 1
|
|
970
|
+
while index < len(statement):
|
|
971
|
+
option = statement[index]
|
|
972
|
+
if option == "--":
|
|
973
|
+
index += 1
|
|
974
|
+
break
|
|
975
|
+
kind = env_option_kind(option)
|
|
976
|
+
if kind is None:
|
|
977
|
+
if "=" in option and not option.startswith(("=", "-")):
|
|
978
|
+
index += 1
|
|
979
|
+
continue
|
|
980
|
+
break
|
|
981
|
+
if kind in {"ambiguous", "split-string"}:
|
|
982
|
+
# `env -S` reparses an opaque payload as shell words, and an
|
|
983
|
+
# unnameable option makes the rest unreadable. Neither can be
|
|
984
|
+
# resolved to a program.
|
|
985
|
+
return (None, [], True)
|
|
986
|
+
index += 2 if kind == "separate-value" else 1
|
|
987
|
+
continue
|
|
988
|
+
if program in FOLLOW_WRAPPERS:
|
|
989
|
+
separate, positional = FOLLOW_WRAPPERS[program]
|
|
990
|
+
index += 1
|
|
991
|
+
while index < len(statement) and statement[index].startswith("-"):
|
|
992
|
+
option = statement[index]
|
|
993
|
+
if option == "--":
|
|
994
|
+
index += 1
|
|
995
|
+
break
|
|
996
|
+
index += 2 if option in separate else 1
|
|
997
|
+
index += positional
|
|
998
|
+
continue
|
|
999
|
+
return (program, statement[index + 1 :], False)
|
|
1000
|
+
return (None, [], False)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def shell_script_operand(args):
|
|
1004
|
+
"""The file a shell invocation runs, when it runs one.
|
|
1005
|
+
|
|
1006
|
+
Args:
|
|
1007
|
+
args: The tokens following the shell's own name.
|
|
1008
|
+
|
|
1009
|
+
Returns:
|
|
1010
|
+
The operand token, or None when the invocation runs no script file —
|
|
1011
|
+
an interactive shell, or a `-c` command string, which
|
|
1012
|
+
`nested_shell_payload` already owns.
|
|
1013
|
+
"""
|
|
1014
|
+
index = 0
|
|
1015
|
+
opaque_option_seen = False
|
|
1016
|
+
while index < len(args):
|
|
1017
|
+
token = args[index]
|
|
1018
|
+
if token == "--":
|
|
1019
|
+
index += 1
|
|
1020
|
+
break
|
|
1021
|
+
if not token.startswith(("-", "+")):
|
|
1022
|
+
if not opaque_option_seen:
|
|
1023
|
+
break
|
|
1024
|
+
index += 1
|
|
1025
|
+
opaque_option_seen = False
|
|
1026
|
+
continue
|
|
1027
|
+
if token in SHELL_OPTIONS_SEPARATE_VALUE:
|
|
1028
|
+
index += 2
|
|
1029
|
+
continue
|
|
1030
|
+
if token.startswith(SHELL_OPTIONS_INLINE_VALUE):
|
|
1031
|
+
index += 1
|
|
1032
|
+
continue
|
|
1033
|
+
if token.startswith("--"):
|
|
1034
|
+
if token not in SHELL_LONG_OPTIONS_NO_VALUE:
|
|
1035
|
+
opaque_option_seen = True
|
|
1036
|
+
index += 1
|
|
1037
|
+
continue
|
|
1038
|
+
if "c" in token[1:]:
|
|
1039
|
+
return None
|
|
1040
|
+
index += 1
|
|
1041
|
+
return args[index] if index < len(args) else None
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def dispatched_commands(program, args):
|
|
1045
|
+
"""Commands a dispatcher builds out of its own arguments.
|
|
1046
|
+
|
|
1047
|
+
`find … -exec bash {} \\;` and `find … | xargs bash` put an interpreter at
|
|
1048
|
+
a command position the text does not spell as one. The exception is scoped
|
|
1049
|
+
to a `find`/`xargs` statement and to exactly ONE token, because latching an
|
|
1050
|
+
interpreter NAME anywhere refuses `find . -name bash` and
|
|
1051
|
+
`xargs grep bash`, where the word is a pattern and nothing is executed.
|
|
1052
|
+
|
|
1053
|
+
Args:
|
|
1054
|
+
program: The dispatcher's name.
|
|
1055
|
+
args: Its arguments.
|
|
1056
|
+
|
|
1057
|
+
Returns:
|
|
1058
|
+
Pairs of (program, args) the dispatcher will run.
|
|
1059
|
+
"""
|
|
1060
|
+
built = []
|
|
1061
|
+
if program == "find":
|
|
1062
|
+
for index, token in enumerate(args):
|
|
1063
|
+
if token in FIND_EXEC_FLAGS and index + 1 < len(args):
|
|
1064
|
+
built.append((args[index + 1].rsplit("/", 1)[-1], args[index + 2 :]))
|
|
1065
|
+
return built
|
|
1066
|
+
if program != "xargs":
|
|
1067
|
+
return built
|
|
1068
|
+
index = 0
|
|
1069
|
+
while index < len(args):
|
|
1070
|
+
token = args[index]
|
|
1071
|
+
if token == "--":
|
|
1072
|
+
index += 1
|
|
1073
|
+
break
|
|
1074
|
+
if not token.startswith("-"):
|
|
1075
|
+
break
|
|
1076
|
+
# `-I`, `-n`, `-P`, `-s`, `-a`, `-d`, `-E`, `-L` take a value; the rest
|
|
1077
|
+
# do not. Over-consuming here only loses the dispatcher, which degrades
|
|
1078
|
+
# to the fail-open this file's header already accepts.
|
|
1079
|
+
index += 2 if token in {"-I", "-n", "-P", "-s", "-a", "-d", "-E", "-L"} else 1
|
|
1080
|
+
if index < len(args):
|
|
1081
|
+
built.append((args[index].rsplit("/", 1)[-1], args[index + 1 :]))
|
|
1082
|
+
return built
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def resolve_script(token):
|
|
1086
|
+
"""Resolve a token naming a script to a readable file.
|
|
1087
|
+
|
|
1088
|
+
Args:
|
|
1089
|
+
token: The operand an interpreter will run.
|
|
1090
|
+
|
|
1091
|
+
Returns:
|
|
1092
|
+
A pair (path, reason). Exactly one is set. `reason` names why the
|
|
1093
|
+
execution could not be followed, which the caller fails CLOSED on:
|
|
1094
|
+
silence about a command that is definitely running something the guard
|
|
1095
|
+
cannot read is the fail-open this change exists to close.
|
|
1096
|
+
"""
|
|
1097
|
+
if COMPUTED_VALUE.search(token):
|
|
1098
|
+
return (None, "a computed path the guard cannot resolve before the shell does")
|
|
1099
|
+
# A quoted separator carries a NUL marker from shell_tokens; the path the
|
|
1100
|
+
# shell passes is the argument without it.
|
|
1101
|
+
if token.startswith(chr(0)):
|
|
1102
|
+
token = token[1:]
|
|
1103
|
+
text = os.path.expanduser(token.strip().strip("'\""))
|
|
1104
|
+
# `bash -` and a bare `-` read the script from stdin; there is no file.
|
|
1105
|
+
if not text or text == "-":
|
|
1106
|
+
return (None, None)
|
|
1107
|
+
candidates = [text]
|
|
1108
|
+
if not os.path.isabs(text):
|
|
1109
|
+
project = os.environ.get("CLAUDE_PROJECT_DIR", "")
|
|
1110
|
+
candidates.append(os.path.join(os.getcwd(), text))
|
|
1111
|
+
if project:
|
|
1112
|
+
candidates.append(os.path.join(project, text))
|
|
1113
|
+
for candidate in candidates:
|
|
1114
|
+
try:
|
|
1115
|
+
if not os.path.isfile(candidate):
|
|
1116
|
+
continue
|
|
1117
|
+
if os.path.getsize(candidate) > FOLLOW_MAX_BYTES:
|
|
1118
|
+
return (None, "a script larger than the inspection cap")
|
|
1119
|
+
except OSError:
|
|
1120
|
+
continue
|
|
1121
|
+
return (candidate, None)
|
|
1122
|
+
return (None, "a script that does not exist or cannot be read")
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
SHELL_COMMAND_STRING = object()
|
|
1126
|
+
SHELL_STDIN = object()
|
|
1127
|
+
# `-n` / `--noexec`: the shell READS and parses its operand, then exits without
|
|
1128
|
+
# executing a line of it. Distinct from the two above because nothing runs at
|
|
1129
|
+
# all — there is no payload to scan and no stdin script to wait for.
|
|
1130
|
+
SHELL_NOEXEC = object()
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def shell_execution_operand(args):
|
|
1134
|
+
"""The file a shell runs, or a sentinel for `-c` and stdin modes.
|
|
1135
|
+
|
|
1136
|
+
`shell_script_operand` intentionally collapses both cases to None for the
|
|
1137
|
+
nested-command scanner. Reach cannot: `bash -c '...'` has already exposed
|
|
1138
|
+
its text, while `bash -x`, `bash -e`, and `bash --` still await a script on
|
|
1139
|
+
stdin and must remain linked to a following `< file` separator.
|
|
1140
|
+
"""
|
|
1141
|
+
index = 0
|
|
1142
|
+
opaque_option_seen = False
|
|
1143
|
+
while index < len(args):
|
|
1144
|
+
token = args[index]
|
|
1145
|
+
if token == "--":
|
|
1146
|
+
index += 1
|
|
1147
|
+
break
|
|
1148
|
+
if not token.startswith(("-", "+")):
|
|
1149
|
+
if not opaque_option_seen:
|
|
1150
|
+
break
|
|
1151
|
+
index += 1
|
|
1152
|
+
opaque_option_seen = False
|
|
1153
|
+
continue
|
|
1154
|
+
if token in SHELL_OPTIONS_SEPARATE_VALUE:
|
|
1155
|
+
index += 2
|
|
1156
|
+
continue
|
|
1157
|
+
if token.startswith(SHELL_OPTIONS_INLINE_VALUE):
|
|
1158
|
+
index += 1
|
|
1159
|
+
continue
|
|
1160
|
+
if token.startswith("--"):
|
|
1161
|
+
if token == "--noexec":
|
|
1162
|
+
return SHELL_NOEXEC
|
|
1163
|
+
if token not in SHELL_LONG_OPTIONS_NO_VALUE:
|
|
1164
|
+
opaque_option_seen = True
|
|
1165
|
+
index += 1
|
|
1166
|
+
continue
|
|
1167
|
+
if "c" in token[1:]:
|
|
1168
|
+
return SHELL_COMMAND_STRING
|
|
1169
|
+
# `-n` is noexec. `bash -n <file>` is the one shape that puts a path at
|
|
1170
|
+
# a command position while provably executing nothing, so it is the
|
|
1171
|
+
# exact seam between reading and running — and reach got it wrong in
|
|
1172
|
+
# BOTH directions (CodySwannGT/lisa#3781). Here it meant a syntax check
|
|
1173
|
+
# of any file merely DISCUSSING the bypass was refused, which is most of
|
|
1174
|
+
# this repository's own guards and every husky hook.
|
|
1175
|
+
#
|
|
1176
|
+
# `bash <file>` still executes and is still refused. Noexec is the
|
|
1177
|
+
# distinction, not the program: this is not a read-only exemption.
|
|
1178
|
+
if "n" in token[1:]:
|
|
1179
|
+
return SHELL_NOEXEC
|
|
1180
|
+
index += 1
|
|
1181
|
+
return args[index] if index < len(args) else SHELL_STDIN
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def executed_scripts(tokens):
|
|
1185
|
+
"""The files a command line will RUN, and the executions it cannot follow.
|
|
1186
|
+
|
|
1187
|
+
Args:
|
|
1188
|
+
tokens: The full operator-aware token list.
|
|
1189
|
+
|
|
1190
|
+
Returns:
|
|
1191
|
+
A pair (paths, reasons). `reasons` is non-empty when a command position
|
|
1192
|
+
clearly runs a script the guard cannot read.
|
|
1193
|
+
"""
|
|
1194
|
+
paths = []
|
|
1195
|
+
reasons = []
|
|
1196
|
+
previous_shell_awaiting_stdin = False
|
|
1197
|
+
for separator, statement in statement_slices(tokens):
|
|
1198
|
+
if separator == "<" and previous_shell_awaiting_stdin and statement:
|
|
1199
|
+
# `bash < nv.sh`: the redirect target is the script.
|
|
1200
|
+
path, reason = resolve_script(statement[0])
|
|
1201
|
+
if reason:
|
|
1202
|
+
reasons.append((reason, statement[0]))
|
|
1203
|
+
elif path:
|
|
1204
|
+
paths.append((path, "stdin"))
|
|
1205
|
+
previous_shell_awaiting_stdin = False
|
|
1206
|
+
if not statement:
|
|
1207
|
+
continue
|
|
1208
|
+
program, args, opaque = command_word(statement)
|
|
1209
|
+
if opaque:
|
|
1210
|
+
reasons.append(("an invocation whose wrapper options cannot be read", ""))
|
|
1211
|
+
continue
|
|
1212
|
+
if program is None:
|
|
1213
|
+
continue
|
|
1214
|
+
for name, arguments in [(program, args)] + dispatched_commands(program, args):
|
|
1215
|
+
if name in SOURCE_BUILTINS:
|
|
1216
|
+
operand = arguments[0] if arguments else None
|
|
1217
|
+
elif name in SHELL_PROGRAMS:
|
|
1218
|
+
operand = shell_execution_operand(arguments)
|
|
1219
|
+
if operand is SHELL_STDIN:
|
|
1220
|
+
previous_shell_awaiting_stdin = True
|
|
1221
|
+
continue
|
|
1222
|
+
if operand is SHELL_COMMAND_STRING:
|
|
1223
|
+
continue
|
|
1224
|
+
if operand is SHELL_NOEXEC:
|
|
1225
|
+
# Nothing runs, so there is nothing to follow and nothing
|
|
1226
|
+
# to fail closed about.
|
|
1227
|
+
continue
|
|
1228
|
+
else:
|
|
1229
|
+
continue
|
|
1230
|
+
if operand is None:
|
|
1231
|
+
continue
|
|
1232
|
+
path, reason = resolve_script(operand)
|
|
1233
|
+
if reason:
|
|
1234
|
+
reasons.append((reason, operand))
|
|
1235
|
+
elif path:
|
|
1236
|
+
paths.append((path, "source" if name in SOURCE_BUILTINS else "execute"))
|
|
1237
|
+
return paths, reasons
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def read_script(path):
|
|
1241
|
+
"""The text of a script the command runs, comments removed.
|
|
1242
|
+
|
|
1243
|
+
Args:
|
|
1244
|
+
path: A path from `resolve_script`.
|
|
1245
|
+
|
|
1246
|
+
Returns:
|
|
1247
|
+
The contents, or an empty string when unreadable.
|
|
1248
|
+
"""
|
|
1249
|
+
try:
|
|
1250
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
1251
|
+
return strip_shell_comments(strip_heredocs(handle.read(FOLLOW_MAX_BYTES)))
|
|
1252
|
+
except OSError:
|
|
1253
|
+
return ""
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def raw_reach_statements(text):
|
|
1257
|
+
"""Keep quotes and complete substitutions for ordered file reach.
|
|
1258
|
+
|
|
1259
|
+
shlex intentionally removes quotes, and splits the nested quotes in the
|
|
1260
|
+
canonical dirname idiom. Those tokens cannot prove an assignment or tell
|
|
1261
|
+
a literal '$DIR' from a variable. This lexer only preserves raw words; it
|
|
1262
|
+
never evaluates substitutions. The existing command classifier still owns
|
|
1263
|
+
interpreter options, wrappers and dispatchers.
|
|
1264
|
+
"""
|
|
1265
|
+
text = text.replace("\\\n", "")
|
|
1266
|
+
|
|
1267
|
+
def quoted_end(start, quote, depth=0):
|
|
1268
|
+
if depth > 32:
|
|
1269
|
+
raise ValueError("shell nesting exceeds inspection budget")
|
|
1270
|
+
index = start + 1
|
|
1271
|
+
while index < len(text):
|
|
1272
|
+
char = text[index]
|
|
1273
|
+
if char == "\\" and quote != "'":
|
|
1274
|
+
index += 2
|
|
1275
|
+
continue
|
|
1276
|
+
if quote == '"' and text.startswith("$(", index):
|
|
1277
|
+
index = substitution_end(index + 1, depth + 1)
|
|
1278
|
+
continue
|
|
1279
|
+
if char == quote:
|
|
1280
|
+
return index + 1
|
|
1281
|
+
index += 1
|
|
1282
|
+
raise ValueError("unclosed shell quote")
|
|
1283
|
+
|
|
1284
|
+
def substitution_end(start, depth=0):
|
|
1285
|
+
if depth > 32:
|
|
1286
|
+
raise ValueError("shell nesting exceeds inspection budget")
|
|
1287
|
+
index = start + 1
|
|
1288
|
+
nesting = 1
|
|
1289
|
+
while index < len(text):
|
|
1290
|
+
char = text[index]
|
|
1291
|
+
if char == "\\":
|
|
1292
|
+
index += 2
|
|
1293
|
+
continue
|
|
1294
|
+
if char in "'\"`":
|
|
1295
|
+
index = quoted_end(index, char, depth + 1)
|
|
1296
|
+
continue
|
|
1297
|
+
if char == "(":
|
|
1298
|
+
nesting += 1
|
|
1299
|
+
elif char == ")":
|
|
1300
|
+
nesting -= 1
|
|
1301
|
+
if not nesting:
|
|
1302
|
+
return index + 1
|
|
1303
|
+
index += 1
|
|
1304
|
+
raise ValueError("unclosed command substitution")
|
|
1305
|
+
|
|
1306
|
+
statements = []
|
|
1307
|
+
words = []
|
|
1308
|
+
separator = ""
|
|
1309
|
+
index = 0
|
|
1310
|
+
while index < len(text):
|
|
1311
|
+
char = text[index]
|
|
1312
|
+
if char in " \t\r":
|
|
1313
|
+
index += 1
|
|
1314
|
+
continue
|
|
1315
|
+
if char in "\n;|&()<>":
|
|
1316
|
+
end = index + 1
|
|
1317
|
+
if char in "|&<>" and end < len(text) and text[end] == char:
|
|
1318
|
+
end += 1
|
|
1319
|
+
statements.append((separator, words))
|
|
1320
|
+
words = []
|
|
1321
|
+
separator = ";" if char == "\n" else text[index:end]
|
|
1322
|
+
index = end
|
|
1323
|
+
continue
|
|
1324
|
+
start = index
|
|
1325
|
+
while index < len(text) and text[index] not in " \t\r\n;|&()<>":
|
|
1326
|
+
if text[index] == "\\":
|
|
1327
|
+
index += 2
|
|
1328
|
+
elif text[index] in "'\"`":
|
|
1329
|
+
index = quoted_end(index, text[index])
|
|
1330
|
+
elif text.startswith("$(", index):
|
|
1331
|
+
index = substitution_end(index + 1)
|
|
1332
|
+
else:
|
|
1333
|
+
index += 1
|
|
1334
|
+
words.append(text[start:index])
|
|
1335
|
+
statements.append((separator, words))
|
|
1336
|
+
return statements
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
DIRECTORY_ASSIGNMENT = re.compile(r"^([A-Za-z_][A-Za-z_0-9]*)(?:\[[^\]]*\])?(\+?=)(.*)$", re.S)
|
|
1340
|
+
DIRECTORY_OPERAND = re.compile(
|
|
1341
|
+
r'^\$(?:\{([A-Za-z_][A-Za-z_0-9]*)\}|([A-Za-z_][A-Za-z_0-9]*))([^$`\'"\\]*)$'
|
|
1342
|
+
)
|
|
1343
|
+
DIRECTORY_MUTATORS = {
|
|
1344
|
+
"eval", "read", "readarray", "mapfile", "unset", "let", "declare",
|
|
1345
|
+
"typeset", "local", "export", "readonly", "trap", "alias", "unalias",
|
|
1346
|
+
"enable", "getopts",
|
|
1347
|
+
}
|
|
1348
|
+
PARENT_PARAMETER_WRITE = re.compile(
|
|
1349
|
+
r"\$\{[A-Za-z_][A-Za-z_0-9]*(?:\[[^\]]*\])?:?="
|
|
1350
|
+
)
|
|
1351
|
+
PARENT_ARRAY_READ = re.compile(r"\$\{[A-Za-z_][A-Za-z_0-9]*\[")
|
|
1352
|
+
CONSTANT_ARRAY_READ = re.compile(r"\$\{[A-Za-z_][A-Za-z_0-9]*\[[0-9]+\]")
|
|
1353
|
+
CONSTANT_ARRAY_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z_0-9]*\[[0-9]+\]\+?=")
|
|
1354
|
+
SPECIAL_DIRECTORY_VARIABLES = {
|
|
1355
|
+
"_",
|
|
1356
|
+
"RANDOM", "SRANDOM", "SECONDS", "LINENO", "EPOCHSECONDS", "EPOCHREALTIME",
|
|
1357
|
+
"PWD", "OLDPWD", "DIRSTACK", "FUNCNAME", "GROUPS", "PIPESTATUS",
|
|
1358
|
+
"UID", "EUID", "PPID", "BASHPID", "SHLVL", "OPTIND", "OPTERR",
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def bound_directory_operand(raw, context):
|
|
1363
|
+
"""Expand one proven binding, retaining raw quote provenance."""
|
|
1364
|
+
if context is None:
|
|
1365
|
+
return None
|
|
1366
|
+
quoted = raw.startswith('"') and raw.endswith('"')
|
|
1367
|
+
candidate = raw[1:-1] if quoted else raw
|
|
1368
|
+
match = DIRECTORY_OPERAND.fullmatch(candidate)
|
|
1369
|
+
if match:
|
|
1370
|
+
variable = match[1] or match[2]
|
|
1371
|
+
value = context["bindings"].get(variable)
|
|
1372
|
+
if value is not None:
|
|
1373
|
+
result = value + match[3]
|
|
1374
|
+
if (quoted or (context["ifs_default"] and not re.search(r"[\s*?\[{}]", result))) and (os.path.isabs(value) or context["cwd_known"]):
|
|
1375
|
+
return result
|
|
1376
|
+
return None
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def directory_assignment_value(raw, context):
|
|
1380
|
+
"""Prove only literal directory idioms in the followed file's context."""
|
|
1381
|
+
if context is None:
|
|
1382
|
+
return None
|
|
1383
|
+
if context["assignment_types_unknown"]:
|
|
1384
|
+
return None
|
|
1385
|
+
derived = bound_directory_operand(raw, context)
|
|
1386
|
+
if derived is not None:
|
|
1387
|
+
return derived
|
|
1388
|
+
if raw == '"${BASH_SOURCE[0]%/*}"':
|
|
1389
|
+
pathname = context["file"]
|
|
1390
|
+
if pathname is None:
|
|
1391
|
+
return None
|
|
1392
|
+
return pathname.rsplit("/", 1)[0] if "/" in pathname else pathname
|
|
1393
|
+
if context["shadowed"]:
|
|
1394
|
+
return None
|
|
1395
|
+
# $0 belongs to the outer executed script when this file was sourced.
|
|
1396
|
+
source = '"${BASH_SOURCE[0]}"'
|
|
1397
|
+
zero = '"$0"'
|
|
1398
|
+
candidates = [(source, context["file"]), (zero, context["zero"])]
|
|
1399
|
+
for reference, pathname in candidates:
|
|
1400
|
+
if pathname is None or "\n" in pathname:
|
|
1401
|
+
continue
|
|
1402
|
+
directory = os.path.dirname(pathname) or "."
|
|
1403
|
+
if raw == '"$(dirname ' + reference + ')"':
|
|
1404
|
+
return directory
|
|
1405
|
+
if raw == '"$(cd "$(dirname ' + reference + ')" && pwd)"':
|
|
1406
|
+
if os.path.isabs(directory):
|
|
1407
|
+
return os.path.normpath(directory)
|
|
1408
|
+
if context["cwd_known"] and not context["cdpath"]:
|
|
1409
|
+
return os.path.abspath(directory)
|
|
1410
|
+
return None
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
def parent_expansion_writes(raw):
|
|
1414
|
+
"""Detect possible parent-shell writes without reading quoted data as code."""
|
|
1415
|
+
index = 0
|
|
1416
|
+
double_quoted = False
|
|
1417
|
+
while index < len(raw):
|
|
1418
|
+
if raw[index] == "\\":
|
|
1419
|
+
index += 2
|
|
1420
|
+
continue
|
|
1421
|
+
if raw[index] == '"':
|
|
1422
|
+
double_quoted = not double_quoted
|
|
1423
|
+
index += 1
|
|
1424
|
+
continue
|
|
1425
|
+
if raw[index] == "'" and not double_quoted:
|
|
1426
|
+
end = raw.find("'", index + 1)
|
|
1427
|
+
if end < 0:
|
|
1428
|
+
return True
|
|
1429
|
+
index = end + 1
|
|
1430
|
+
continue
|
|
1431
|
+
if raw.startswith("$((", index) or raw.startswith("$[", index):
|
|
1432
|
+
return True
|
|
1433
|
+
if raw.startswith("${", index):
|
|
1434
|
+
if PARENT_PARAMETER_WRITE.match(raw, index):
|
|
1435
|
+
return True
|
|
1436
|
+
if PARENT_ARRAY_READ.match(raw, index) and not CONSTANT_ARRAY_READ.match(raw, index):
|
|
1437
|
+
return True
|
|
1438
|
+
index += 1
|
|
1439
|
+
return False
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def directory_variable_write(context, name):
|
|
1443
|
+
"""Invalidate shell facts as well as bindings when a variable can change."""
|
|
1444
|
+
if context is None:
|
|
1445
|
+
return
|
|
1446
|
+
match = re.fullmatch(r"([A-Za-z_][A-Za-z_0-9]*)(?:\[[0-9]+\])?(?:=.*)?", name, re.S)
|
|
1447
|
+
variable = match[1] if match else None
|
|
1448
|
+
if variable is not None:
|
|
1449
|
+
context["bindings"].pop(variable, None)
|
|
1450
|
+
if variable is None or variable == "PATH":
|
|
1451
|
+
context["shadowed"] = True
|
|
1452
|
+
if variable is None or variable == "CDPATH":
|
|
1453
|
+
context["cdpath"] = True
|
|
1454
|
+
if variable is None or variable == "IFS":
|
|
1455
|
+
context["ifs_default"] = False
|
|
1456
|
+
if variable is None or variable in {"BASH_ENV", "ENV"}:
|
|
1457
|
+
context["startup_unknown"] = True
|
|
1458
|
+
if variable is None or variable == "BASH_SOURCE":
|
|
1459
|
+
context["file"] = None
|
|
1460
|
+
if variable is None or variable == "BASH_ARGV0":
|
|
1461
|
+
context["zero"] = None
|
|
1462
|
+
if variable is None:
|
|
1463
|
+
context["assignment_types_unknown"] = True
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def directory_command(words):
|
|
1467
|
+
"""Strip command-position control prefixes while counting their scopes."""
|
|
1468
|
+
index = 0
|
|
1469
|
+
openings = 0
|
|
1470
|
+
braces = 0
|
|
1471
|
+
while index < len(words) and words[index] in {"if", "elif", "while", "until", "then", "else", "do", "{", "!"}:
|
|
1472
|
+
if words[index] in {"if", "while", "until"}:
|
|
1473
|
+
openings += 1
|
|
1474
|
+
if words[index] == "{":
|
|
1475
|
+
braces += 1
|
|
1476
|
+
index += 1
|
|
1477
|
+
return words[index:], openings, braces
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def directory_reach(text, context, inspect, inspect_payload):
|
|
1481
|
+
"""Follow commands in order, retaining only proven parent-shell facts.
|
|
1482
|
+
|
|
1483
|
+
Sourcing shares bindings; a new interpreter has its own bindings and $0.
|
|
1484
|
+
Assignments inside control flow, functions or subshells never authorize a
|
|
1485
|
+
later source. Possible writes invalidate facts, including writes in a
|
|
1486
|
+
library inspected immediately before its caller continues.
|
|
1487
|
+
"""
|
|
1488
|
+
try:
|
|
1489
|
+
statements = raw_reach_statements(text)
|
|
1490
|
+
except ValueError:
|
|
1491
|
+
paths, reasons = executed_scripts(shell_tokens(text))
|
|
1492
|
+
if context is not None:
|
|
1493
|
+
context["bindings"].clear()
|
|
1494
|
+
return paths, reasons
|
|
1495
|
+
bindings = context["bindings"] if context is not None else {}
|
|
1496
|
+
definition_headers = set()
|
|
1497
|
+
definition_bodies = {}
|
|
1498
|
+
if context is not None:
|
|
1499
|
+
discovered_functions = set()
|
|
1500
|
+
for index, (_, raw_header) in enumerate(statements):
|
|
1501
|
+
words, _, _ = directory_command(raw_header)
|
|
1502
|
+
header_offset = len(raw_header) - len(words)
|
|
1503
|
+
name = None
|
|
1504
|
+
if len(words) >= 2 and words[0] == "function":
|
|
1505
|
+
name = words[1]
|
|
1506
|
+
elif len(words) == 1 and index + 2 < len(statements):
|
|
1507
|
+
closing, body = statements[index + 2]
|
|
1508
|
+
if statements[index + 1] == ("(", []) and closing == ")" and body and body[0] == "{":
|
|
1509
|
+
name = words[0]
|
|
1510
|
+
if name is not None:
|
|
1511
|
+
try:
|
|
1512
|
+
decoded_name = shlex.split(name, posix=True)
|
|
1513
|
+
except ValueError:
|
|
1514
|
+
decoded_name = []
|
|
1515
|
+
if len(decoded_name) == 1 and not COMPUTED_VALUE.search(decoded_name[0]):
|
|
1516
|
+
name = decoded_name[0]
|
|
1517
|
+
else:
|
|
1518
|
+
context["shadowed"] = True
|
|
1519
|
+
context["assignment_types_unknown"] = True
|
|
1520
|
+
definition_headers.add(index)
|
|
1521
|
+
if "{" in words:
|
|
1522
|
+
definition_bodies[index] = header_offset + words.index("{")
|
|
1523
|
+
elif index + 2 < len(statements) and "{" in statements[index + 2][1]:
|
|
1524
|
+
definition_bodies[index + 2] = statements[index + 2][1].index("{")
|
|
1525
|
+
discovered_functions.add(name)
|
|
1526
|
+
if name in {"dirname", "cd", "pwd"}:
|
|
1527
|
+
context["shadowed"] = True
|
|
1528
|
+
context["functions"] = context["functions"] | discovered_functions
|
|
1529
|
+
control_scope = 0
|
|
1530
|
+
paren_scope = 0
|
|
1531
|
+
brace_scope = 0
|
|
1532
|
+
function_frames = []
|
|
1533
|
+
previous_stdin = False
|
|
1534
|
+
for statement_index, (separator, raw_words) in enumerate(statements):
|
|
1535
|
+
if separator == "(" and statement_index and statements[statement_index - 1] == ("(", []):
|
|
1536
|
+
# Arithmetic evaluates variable contents as expressions; even a
|
|
1537
|
+
# seemingly unrelated counter can conceal a write through them.
|
|
1538
|
+
bindings.clear()
|
|
1539
|
+
if context is not None:
|
|
1540
|
+
context["cdpath"] = True
|
|
1541
|
+
context["shadowed"] = True
|
|
1542
|
+
if separator == "(":
|
|
1543
|
+
paren_scope += 1
|
|
1544
|
+
elif separator == ")":
|
|
1545
|
+
paren_scope = max(0, paren_scope - 1)
|
|
1546
|
+
if not raw_words:
|
|
1547
|
+
continue
|
|
1548
|
+
head = raw_words[0]
|
|
1549
|
+
enclosing_control = control_scope
|
|
1550
|
+
enclosing_parens = paren_scope
|
|
1551
|
+
normalized, openings, brace_openings = directory_command(raw_words)
|
|
1552
|
+
control_scope += openings
|
|
1553
|
+
if normalized and normalized[0] in {"for", "select", "case"}:
|
|
1554
|
+
control_scope += 1
|
|
1555
|
+
elif head in {"fi", "done", "esac"}:
|
|
1556
|
+
control_scope = max(0, control_scope - 1)
|
|
1557
|
+
brace_scope += brace_openings
|
|
1558
|
+
if normalized and normalized[0] == "function" and "{" in normalized:
|
|
1559
|
+
brace_scope += 1
|
|
1560
|
+
if head == "}":
|
|
1561
|
+
brace_scope = max(0, brace_scope - 1)
|
|
1562
|
+
if function_frames and brace_scope < function_frames[-1][0]:
|
|
1563
|
+
_, context, control_scope, paren_scope = function_frames.pop()
|
|
1564
|
+
bindings = context["bindings"] if context is not None else {}
|
|
1565
|
+
raw_words = raw_words[1:]
|
|
1566
|
+
if not raw_words:
|
|
1567
|
+
continue
|
|
1568
|
+
head = raw_words[0]
|
|
1569
|
+
if statement_index in definition_bodies and context is not None:
|
|
1570
|
+
if len(function_frames) >= FOLLOW_MAX_DEPTH:
|
|
1571
|
+
return [], [("an unproved function body beyond the inspection cap", head)]
|
|
1572
|
+
function_frames.append((brace_scope, context, enclosing_control, enclosing_parens))
|
|
1573
|
+
# A definition does not execute its body. Inspect potential file
|
|
1574
|
+
# executions without borrowing directory facts from the defining
|
|
1575
|
+
# moment: the body may be called after the caller changes them.
|
|
1576
|
+
# Its speculative writes must not erase the caller's facts either.
|
|
1577
|
+
context = {
|
|
1578
|
+
**context, "bindings": {},
|
|
1579
|
+
"cwd_known": False, "assignment_types_unknown": True,
|
|
1580
|
+
"startup_unknown": True,
|
|
1581
|
+
}
|
|
1582
|
+
bindings = context["bindings"]
|
|
1583
|
+
raw_words = raw_words[definition_bodies[statement_index] + 1:]
|
|
1584
|
+
if not raw_words:
|
|
1585
|
+
continue
|
|
1586
|
+
head = raw_words[0]
|
|
1587
|
+
scope = control_scope + paren_scope + brace_scope
|
|
1588
|
+
outgoing = statements[statement_index + 1][0] if statement_index + 1 < len(statements) else ""
|
|
1589
|
+
parent_context = context
|
|
1590
|
+
isolated = separator == "|" or outgoing in {"|", "&"}
|
|
1591
|
+
if context is not None and isolated:
|
|
1592
|
+
context = {**context, "bindings": bindings.copy()}
|
|
1593
|
+
bindings = context["bindings"]
|
|
1594
|
+
raw_command, _, _ = directory_command(raw_words)
|
|
1595
|
+
if raw_command and raw_command[0] in {"for", "select"} and len(raw_command) > 1:
|
|
1596
|
+
directory_variable_write(context, raw_command[1])
|
|
1597
|
+
if any(parent_expansion_writes(raw) for raw in raw_words):
|
|
1598
|
+
bindings.clear()
|
|
1599
|
+
if context is not None:
|
|
1600
|
+
context["shadowed"] = True
|
|
1601
|
+
context["cdpath"] = True
|
|
1602
|
+
context["ifs_default"] = False
|
|
1603
|
+
# Function definitions may shadow the commands used by dirname. Do
|
|
1604
|
+
# not treat a definition as an executed directory assignment.
|
|
1605
|
+
for raw_assignment in raw_command:
|
|
1606
|
+
assignment = DIRECTORY_ASSIGNMENT.fullmatch(raw_assignment)
|
|
1607
|
+
if assignment is None:
|
|
1608
|
+
break
|
|
1609
|
+
variable, operator, value = assignment.groups()
|
|
1610
|
+
proven = directory_assignment_value(value, context)
|
|
1611
|
+
# Bash's dynamic, readonly and array variables do not have the
|
|
1612
|
+
# ordinary scalar assignment/expansion semantics this proof uses.
|
|
1613
|
+
if variable in SPECIAL_DIRECTORY_VARIABLES or variable.startswith("BASH_"):
|
|
1614
|
+
proven = None
|
|
1615
|
+
if context is not None and variable == "BASH_SOURCE":
|
|
1616
|
+
context["file"] = None
|
|
1617
|
+
if context is not None and variable == "BASH_ARGV0":
|
|
1618
|
+
context["zero"] = None
|
|
1619
|
+
if raw_assignment.startswith(variable + "[") and not CONSTANT_ARRAY_ASSIGNMENT.match(raw_assignment):
|
|
1620
|
+
bindings.clear()
|
|
1621
|
+
if context is not None:
|
|
1622
|
+
context["shadowed"] = True
|
|
1623
|
+
context["ifs_default"] = False
|
|
1624
|
+
context["cdpath"] = True
|
|
1625
|
+
bindings.pop(variable, None)
|
|
1626
|
+
if context is not None and variable == "CDPATH":
|
|
1627
|
+
context["cdpath"] = True
|
|
1628
|
+
if context is not None and variable == "PATH":
|
|
1629
|
+
context["shadowed"] = True
|
|
1630
|
+
if context is not None and variable == "IFS":
|
|
1631
|
+
context["ifs_default"] = False
|
|
1632
|
+
if context is not None and variable in {"BASH_ENV", "ENV"}:
|
|
1633
|
+
context["startup_unknown"] = value not in {"", "''", '\"\"'}
|
|
1634
|
+
if len(raw_words) == 1 and not scope and not isolated and separator not in {"&&", "||", "|", "&"} and operator == "=" and head.startswith(variable + "="):
|
|
1635
|
+
if proven is not None:
|
|
1636
|
+
bindings[variable] = proven
|
|
1637
|
+
words = []
|
|
1638
|
+
for raw in raw_words:
|
|
1639
|
+
try:
|
|
1640
|
+
decoded = shlex.split(raw, posix=True)
|
|
1641
|
+
word = decoded[0] if len(decoded) == 1 else raw
|
|
1642
|
+
except ValueError:
|
|
1643
|
+
word = raw
|
|
1644
|
+
# Expansion is confined to one proven variable plus a literal
|
|
1645
|
+
# suffix. Single quotes and escaped dollars never get substituted.
|
|
1646
|
+
expanded = bound_directory_operand(raw, context)
|
|
1647
|
+
if expanded is not None:
|
|
1648
|
+
word = expanded
|
|
1649
|
+
if word in COMMAND_SEPARATORS:
|
|
1650
|
+
word = "\x00" + word
|
|
1651
|
+
words.append(word)
|
|
1652
|
+
# Prefix identity comes from raw words, before quote removal. A quoted
|
|
1653
|
+
# exclamation mark is an executable name, not the negation operator.
|
|
1654
|
+
command_words = words[len(raw_words) - len(raw_command):]
|
|
1655
|
+
program, args, _ = command_word(command_words)
|
|
1656
|
+
eval_context = {**context, "bindings": bindings.copy()} if context is not None and program == "eval" else context
|
|
1657
|
+
actual_head = next((word for word in command_words if not DIRECTORY_ASSIGNMENT.fullmatch(word)), None)
|
|
1658
|
+
if context is not None:
|
|
1659
|
+
called_function = (program in context["functions"] or actual_head in context["functions"]) and statement_index not in definition_headers
|
|
1660
|
+
arithmetic_test = program in {"test", "[", "[["} and any(
|
|
1661
|
+
arg in {"-v", "-R"} and index + 1 < len(args)
|
|
1662
|
+
and "[" in args[index + 1]
|
|
1663
|
+
and not re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*\[[0-9]+\]", args[index + 1])
|
|
1664
|
+
for index, arg in enumerate(args)
|
|
1665
|
+
)
|
|
1666
|
+
if program == "[[":
|
|
1667
|
+
# [[ arithmetic comparisons evaluate variable contents as
|
|
1668
|
+
# expressions. Literal numbers and numeric special parameters
|
|
1669
|
+
# cannot conceal writes; test/[ instead parse integer strings.
|
|
1670
|
+
for index, arg in enumerate(args):
|
|
1671
|
+
if arg in {"-eq", "-ne", "-lt", "-le", "-gt", "-ge"}:
|
|
1672
|
+
operands = args[max(0, index - 1):index] + args[index + 1:index + 2]
|
|
1673
|
+
if len(operands) != 2 or any(not re.fullmatch(r"[+-]?[0-9]+|\$[?#$]", value) for value in operands):
|
|
1674
|
+
arithmetic_test = True
|
|
1675
|
+
if program in DIRECTORY_MUTATORS or called_function or arithmetic_test or (program == "printf" and "-v" in args) or (separator == "(" and head.startswith("(")):
|
|
1676
|
+
bindings.clear()
|
|
1677
|
+
context["cdpath"] = True
|
|
1678
|
+
context["ifs_default"] = False
|
|
1679
|
+
if program == "eval" or called_function or arithmetic_test:
|
|
1680
|
+
context["cwd_known"] = False
|
|
1681
|
+
context["shadowed"] = True
|
|
1682
|
+
context["assignment_types_unknown"] = True
|
|
1683
|
+
if program == "eval" or called_function:
|
|
1684
|
+
context["startup_unknown"] = True
|
|
1685
|
+
if program == "printf" and "-v" in args:
|
|
1686
|
+
target_index = args.index("-v") + 1
|
|
1687
|
+
directory_variable_write(context, args[target_index] if target_index < len(args) else "")
|
|
1688
|
+
elif program == "getopts":
|
|
1689
|
+
directory_variable_write(context, args[1] if len(args) > 1 else "")
|
|
1690
|
+
elif program in {"read", "readarray", "mapfile", "unset", "declare", "typeset", "local", "export", "readonly"}:
|
|
1691
|
+
for arg in args:
|
|
1692
|
+
if not arg.startswith("-"):
|
|
1693
|
+
directory_variable_write(context, arg)
|
|
1694
|
+
elif program == "let":
|
|
1695
|
+
directory_variable_write(context, "")
|
|
1696
|
+
invocation_context = context
|
|
1697
|
+
if context is not None and program in SHELL_PROGRAMS:
|
|
1698
|
+
operand = shell_execution_operand(args)
|
|
1699
|
+
if isinstance(operand, str) or operand is SHELL_COMMAND_STRING:
|
|
1700
|
+
prefix = command_words[:len(command_words) - len(args)]
|
|
1701
|
+
invocation_context = context.copy()
|
|
1702
|
+
if any(word.startswith("CDPATH=") for word in prefix):
|
|
1703
|
+
invocation_context["cdpath"] = True
|
|
1704
|
+
if any(word.startswith("PATH=") for word in prefix):
|
|
1705
|
+
invocation_context["shadowed"] = True
|
|
1706
|
+
if any(word.startswith(("BASH_ENV=", "ENV=")) and word.split("=", 1)[1] for word in prefix):
|
|
1707
|
+
invocation_context["startup_unknown"] = True
|
|
1708
|
+
startup_args = args[:args.index(operand)] if isinstance(operand, str) else args
|
|
1709
|
+
if shell_startup_unknown(startup_args):
|
|
1710
|
+
invocation_context["startup_unknown"] = True
|
|
1711
|
+
if context is not None:
|
|
1712
|
+
if program in {"alias", "unalias", "enable"}:
|
|
1713
|
+
context["shadowed"] = True
|
|
1714
|
+
if program in {"declare", "typeset", "local"} and any(arg.startswith("-") and any(flag in arg[1:] for flag in "nirlu") for arg in args):
|
|
1715
|
+
context["assignment_types_unknown"] = True
|
|
1716
|
+
trap_args = args[1:] if args and args[0] == "--" else args
|
|
1717
|
+
exit_only_trap = len(trap_args) >= 2 and all(signal in {"EXIT", "0"} for signal in trap_args[1:])
|
|
1718
|
+
if program == "readonly" or (program == "trap" and not exit_only_trap):
|
|
1719
|
+
context["assignment_types_unknown"] = True
|
|
1720
|
+
if program in {"cd", "pushd", "popd"}:
|
|
1721
|
+
context["cwd_known"] = False
|
|
1722
|
+
for variable, value in list(bindings.items()):
|
|
1723
|
+
if not os.path.isabs(value):
|
|
1724
|
+
del bindings[variable]
|
|
1725
|
+
# A conditional assignment is still a possible overwrite. Ignore
|
|
1726
|
+
# assignment-looking data belonging to an ordinary command.
|
|
1727
|
+
if head in {"then", "else", "do"}:
|
|
1728
|
+
for raw in raw_words[1:]:
|
|
1729
|
+
mutation = DIRECTORY_ASSIGNMENT.fullmatch(raw)
|
|
1730
|
+
if mutation:
|
|
1731
|
+
bindings.pop(mutation[1], None)
|
|
1732
|
+
tokens = (["bash", "<"] if separator == "<" and previous_stdin else []) + command_words
|
|
1733
|
+
paths, reasons = executed_scripts(tokens)
|
|
1734
|
+
if reasons:
|
|
1735
|
+
return [], reasons
|
|
1736
|
+
if program in SHELL_PROGRAMS:
|
|
1737
|
+
payload = nested_shell_payload([program, *args], 0)
|
|
1738
|
+
if isinstance(payload, str) and inspect_payload(payload, "execute", invocation_context):
|
|
1739
|
+
return [], [("a nested shell command bypasses git's verification hooks", program)]
|
|
1740
|
+
elif program == "eval":
|
|
1741
|
+
for payload in eval_payloads(command_words):
|
|
1742
|
+
if inspect_payload(payload, "source", eval_context):
|
|
1743
|
+
return [], [("an evaluated command bypasses git's verification hooks", program)]
|
|
1744
|
+
previous_stdin = program in SHELL_PROGRAMS and shell_execution_operand(args) is SHELL_STDIN
|
|
1745
|
+
for path, mode in paths:
|
|
1746
|
+
if context is not None and not context["cwd_known"] and not os.path.isabs(path):
|
|
1747
|
+
return [], [("a computed path after an unproved working-directory change", path)]
|
|
1748
|
+
before = bindings.copy()
|
|
1749
|
+
if inspect(path, mode, invocation_context):
|
|
1750
|
+
return [], [("a followed script bypasses git's verification hooks", path)]
|
|
1751
|
+
if mode == "source" and (scope or separator in {"&&", "||", "|", "&"}):
|
|
1752
|
+
for variable, value in list(bindings.items()):
|
|
1753
|
+
if variable not in before or before[variable] != value:
|
|
1754
|
+
del bindings[variable]
|
|
1755
|
+
if isolated:
|
|
1756
|
+
if separator == "|" and parent_context is not None:
|
|
1757
|
+
# The last pipeline command can run in the parent (`lastpipe`
|
|
1758
|
+
# or another shell). Retain only facts unchanged in both
|
|
1759
|
+
# execution models; never promote a child-only assignment.
|
|
1760
|
+
for variable, value in list(parent_context["bindings"].items()):
|
|
1761
|
+
if bindings.get(variable) != value:
|
|
1762
|
+
del parent_context["bindings"][variable]
|
|
1763
|
+
parent_context["cwd_known"] &= context["cwd_known"]
|
|
1764
|
+
parent_context["ifs_default"] &= context["ifs_default"]
|
|
1765
|
+
for fact in ("cdpath", "shadowed", "assignment_types_unknown", "startup_unknown"):
|
|
1766
|
+
parent_context[fact] |= context[fact]
|
|
1767
|
+
if parent_context["functions"] is not context["functions"]:
|
|
1768
|
+
parent_context["functions"] |= context["functions"]
|
|
1769
|
+
context = parent_context
|
|
1770
|
+
bindings = context["bindings"] if context is not None else {}
|
|
1771
|
+
return [], []
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
def git_argv_disables_verification(argv):
|
|
1775
|
+
"""Whether a git invocation's argv carries `--no-verify` or an abbreviation.
|
|
1776
|
+
|
|
1777
|
+
SCOPED to a git invocation, which is the change that made file reach usable.
|
|
1778
|
+
The token match used to fire anywhere on the line, so `echo --no-verify` and
|
|
1779
|
+
`grep -rl -- "--no-verify" scripts/` were both refused — ordinary commands
|
|
1780
|
+
that run no git at all. That was survivable while the guard read only argv.
|
|
1781
|
+
It is not once the guard reads FILE CONTENTS, because this file, every husky
|
|
1782
|
+
hook, and much of this repository carry the literal token in prose and in
|
|
1783
|
+
refusal text; an unscoped matcher plus reach refuses the repository's own
|
|
1784
|
+
operations on the first command.
|
|
1785
|
+
|
|
1786
|
+
Deliberately NOT narrowed to `commit`. `git push --no-verify` skips pre-push
|
|
1787
|
+
and `git am` / `git merge` skip their own hooks, so every subcommand's argv
|
|
1788
|
+
is scanned. Only the SHORT `-n` needs the commit scope, because `-n` is
|
|
1789
|
+
--dry-run to push and --no-stat to merge — see
|
|
1790
|
+
`commit_bypasses_verification`.
|
|
1791
|
+
|
|
1792
|
+
Args:
|
|
1793
|
+
argv: Tokens following the `git` token, to the end of the line.
|
|
1794
|
+
|
|
1795
|
+
Returns:
|
|
1796
|
+
True if git would read one of them as --no-verify.
|
|
1797
|
+
"""
|
|
1798
|
+
for token in argv:
|
|
1799
|
+
# `--` ends the options; a pathspec literally named `--no-verify` is a
|
|
1800
|
+
# file, not a bypass.
|
|
1801
|
+
if token in COMMAND_SEPARATORS or token == "--":
|
|
1802
|
+
return False
|
|
1803
|
+
if disables_verification(token):
|
|
1804
|
+
return True
|
|
1805
|
+
return False
|
|
1806
|
+
|
|
1807
|
+
|
|
1808
|
+
def git_skips_verification(text, depth=0):
|
|
1809
|
+
"""Whether the command runs a git invocation that skips verification.
|
|
1810
|
+
|
|
1811
|
+
Args:
|
|
1812
|
+
text: The command line, heredoc payloads already stripped.
|
|
1813
|
+
depth: Number of recognized shell payloads already traversed.
|
|
1814
|
+
|
|
1815
|
+
Returns:
|
|
1816
|
+
True if any `git commit` invocation on the line skips verification.
|
|
1817
|
+
"""
|
|
1818
|
+
try:
|
|
1819
|
+
scoped_tokens = shell_tokens(text)
|
|
1820
|
+
except ValueError:
|
|
1821
|
+
return False
|
|
1822
|
+
for index, token in enumerate(scoped_tokens):
|
|
1823
|
+
if token.rsplit("/", 1)[-1] == "env" and env_uses_split_string(
|
|
1824
|
+
scoped_tokens, index
|
|
1825
|
+
):
|
|
1826
|
+
return True
|
|
1827
|
+
for index, token in enumerate(scoped_tokens):
|
|
1828
|
+
# `/usr/bin/git` and a bare `git` are the same program; an env prefix
|
|
1829
|
+
# (`HUSKY=1 git commit -n`) simply sits in an earlier token.
|
|
1830
|
+
if token != "git" and not token.endswith("/git"):
|
|
1831
|
+
continue
|
|
1832
|
+
if printed_argument(scoped_tokens, index):
|
|
1833
|
+
continue
|
|
1834
|
+
# The LONG flag, scoped to this invocation's argv rather than matched
|
|
1835
|
+
# anywhere on the line. This replaces the unscoped token match that used
|
|
1836
|
+
# to live in the flat loop below.
|
|
1837
|
+
if git_argv_disables_verification(scoped_tokens[index + 1 :]):
|
|
1838
|
+
return True
|
|
1839
|
+
found = subcommand_after_git(scoped_tokens, index + 1)
|
|
1840
|
+
if found and found[0] == "commit" and commit_bypasses_verification(found[1]):
|
|
1841
|
+
return True
|
|
1842
|
+
if depth >= MAX_SHELL_NESTING:
|
|
1843
|
+
return True
|
|
1844
|
+
for index, token in enumerate(scoped_tokens):
|
|
1845
|
+
program = token.rsplit("/", 1)[-1]
|
|
1846
|
+
if program not in SHELL_PROGRAMS or not shell_starts_command(
|
|
1847
|
+
scoped_tokens, index
|
|
1848
|
+
):
|
|
1849
|
+
continue
|
|
1850
|
+
payload = nested_shell_payload(scoped_tokens, index)
|
|
1851
|
+
if payload is True:
|
|
1852
|
+
return True
|
|
1853
|
+
if isinstance(payload, str) and git_skips_verification(payload, depth + 1):
|
|
1854
|
+
return True
|
|
1855
|
+
return False
|
|
1856
|
+
|
|
1857
|
+
|
|
1858
|
+
def git_config_reads_key(tokens, index):
|
|
1859
|
+
"""Whether this key is a read operand of this exact Git config invocation.
|
|
1860
|
+
|
|
1861
|
+
Keep the command boundary: a query cannot authorize the next command or
|
|
1862
|
+
turn a later setting into a read. Unknown options remain conservative.
|
|
1863
|
+
"""
|
|
1864
|
+
start = index
|
|
1865
|
+
while start > 0 and tokens[start - 1] not in COMMAND_SEPARATORS:
|
|
1866
|
+
start -= 1
|
|
1867
|
+
end = index + 1
|
|
1868
|
+
while end < len(tokens) and tokens[end] not in COMMAND_SEPARATORS:
|
|
1869
|
+
end += 1
|
|
1870
|
+
program, args, opaque = command_word(tokens[start:end])
|
|
1871
|
+
if opaque or program != "git":
|
|
1872
|
+
return False
|
|
1873
|
+
found = subcommand_after_git(args, 0)
|
|
1874
|
+
if not found or found[0] != "config":
|
|
1875
|
+
return False
|
|
1876
|
+
argv = found[1]
|
|
1877
|
+
key_offset = index - (end - len(argv))
|
|
1878
|
+
read = bool(argv and argv[0] == "get")
|
|
1879
|
+
cursor = 1 if read else 0
|
|
1880
|
+
operands = []
|
|
1881
|
+
read_actions = {"--get", "--get-all", "--get-regexp", "--get-urlmatch"}
|
|
1882
|
+
modifiers = {
|
|
1883
|
+
"--local", "--global", "--system", "--worktree", "--includes",
|
|
1884
|
+
"--no-includes", "--show-origin", "--show-scope", "--null", "-z",
|
|
1885
|
+
"--name-only", "--all", "--regexp",
|
|
1886
|
+
}
|
|
1887
|
+
value_options = {"--file", "-f", "--blob", "--type", "--default", "--value"}
|
|
1888
|
+
while cursor < len(argv):
|
|
1889
|
+
token = argv[cursor]
|
|
1890
|
+
if token in read_actions:
|
|
1891
|
+
if read:
|
|
1892
|
+
return False
|
|
1893
|
+
read = True
|
|
1894
|
+
elif token in value_options:
|
|
1895
|
+
if cursor + 1 >= len(argv):
|
|
1896
|
+
return False
|
|
1897
|
+
cursor += 1
|
|
1898
|
+
elif token in modifiers:
|
|
1899
|
+
pass
|
|
1900
|
+
elif token.startswith("-"):
|
|
1901
|
+
if not any(token.startswith(option + "=") for option in value_options):
|
|
1902
|
+
return False
|
|
1903
|
+
else:
|
|
1904
|
+
operands.append(cursor)
|
|
1905
|
+
cursor += 1
|
|
1906
|
+
return bool(operands and operands[0] == key_offset and (read or len(operands) == 1))
|
|
1907
|
+
|
|
1908
|
+
|
|
1909
|
+
def token_bypass(tokens):
|
|
1910
|
+
"""Whether any token disables hooks by environment or by git config.
|
|
1911
|
+
|
|
1912
|
+
Printed echo/printf arguments are filtered before this scan. Assignments
|
|
1913
|
+
otherwise stay unscoped, unlike the `--no-verify` argv check. What matters is
|
|
1914
|
+
where it appears: `HUSKY=0` and `core.hooksPath=` are assignments that
|
|
1915
|
+
configure whatever git runs next, so they legitimately sit before the
|
|
1916
|
+
invocation and often on a different line of a script. `--no-verify` is an
|
|
1917
|
+
option, and an option belongs to an argv.
|
|
1918
|
+
|
|
1919
|
+
Args:
|
|
1920
|
+
tokens: Operator-aware tokens of one command or script.
|
|
1921
|
+
|
|
1922
|
+
Returns:
|
|
1923
|
+
True when a token disables verification.
|
|
1924
|
+
"""
|
|
1925
|
+
for i, token in enumerate(tokens):
|
|
1926
|
+
if token == "HUSKY=0" or token.startswith("HUSKY_SKIP_HOOKS="):
|
|
1927
|
+
return True
|
|
1928
|
+
# Allowlist the destinations, do not denylist the disabling ones.
|
|
1929
|
+
#
|
|
1930
|
+
# This used to block only "" and /dev/null. But hooks are disabled just
|
|
1931
|
+
# as completely by pointing hooksPath at any directory that happens to
|
|
1932
|
+
# contain none — `-c core.hooksPath=/tmp/empty` bypassed every hook while
|
|
1933
|
+
# reading as an ordinary path — so a denylist of "obviously disabling"
|
|
1934
|
+
# values can always be stepped around by naming a third thing. The set of
|
|
1935
|
+
# paths that DISABLE hooks is unbounded; the set that legitimately
|
|
1936
|
+
# relocates them is tiny and known, so the allowlist is the only side
|
|
1937
|
+
# that can be enumerated.
|
|
1938
|
+
#
|
|
1939
|
+
# Matched case-insensitively because git config variable names are:
|
|
1940
|
+
# `CORE.HOOKSPATH=/x` and `core.hookspath=/x` are the same setting to
|
|
1941
|
+
# git, so a case-sensitive check is bypassed by holding down shift.
|
|
1942
|
+
lowered = token.lower()
|
|
1943
|
+
if lowered.startswith("core.hookspath="):
|
|
1944
|
+
if not is_permitted_hooks_path(token.split("=", 1)[1]):
|
|
1945
|
+
return True
|
|
1946
|
+
if (lowered == "core.hookspath" and i + 1 < len(tokens)
|
|
1947
|
+
and tokens[i + 1] not in COMMAND_SEPARATORS
|
|
1948
|
+
and not git_config_reads_key(tokens, i)):
|
|
1949
|
+
if not is_permitted_hooks_path(tokens[i + 1]):
|
|
1950
|
+
return True
|
|
1951
|
+
# `git --config-env=core.hooksPath=SOMEVAR` sets the same config, reading
|
|
1952
|
+
# the value out of the named environment variable. The path therefore is
|
|
1953
|
+
# not in the command at all, so there is nothing to allowlist against —
|
|
1954
|
+
# SOMEVAR can hold anything by the time git reads it. Any core.hooksPath
|
|
1955
|
+
# routed through --config-env is refused outright.
|
|
1956
|
+
if lowered.startswith("--config-env="):
|
|
1957
|
+
spec = token.split("=", 1)[1]
|
|
1958
|
+
if spec.split("=", 1)[0].strip().lower() == "core.hookspath":
|
|
1959
|
+
return True
|
|
1960
|
+
# git accepts `--config-env <name>=<envvar>` as TWO tokens as well as
|
|
1961
|
+
# one, and guarding only the `=` spelling was worse than missing the
|
|
1962
|
+
# separate form outright: the trailing `core.hooksPath=.husky` then fell
|
|
1963
|
+
# through to the allowlist above, which reads `.husky` as a PATH and
|
|
1964
|
+
# permits it. But here it is an ENVIRONMENT VARIABLE NAME, and
|
|
1965
|
+
# `env '.husky=/dev/null' git --config-env core.hooksPath=.husky` really
|
|
1966
|
+
# does resolve hooksPath to /dev/null. The allowlist was being used as
|
|
1967
|
+
# the bypass.
|
|
1968
|
+
#
|
|
1969
|
+
# Checked at the `--config-env` token, which the loop reaches first, so
|
|
1970
|
+
# the refusal happens before the value token can be mistaken for a path.
|
|
1971
|
+
if lowered == "--config-env" and i + 1 < len(tokens):
|
|
1972
|
+
spec = tokens[i + 1]
|
|
1973
|
+
if spec.split("=", 1)[0].strip().strip("'\"").lower() == "core.hookspath":
|
|
1974
|
+
return True
|
|
1975
|
+
# `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath
|
|
1976
|
+
# GIT_CONFIG_VALUE_0=/dev/null git commit` sets command-scope config the
|
|
1977
|
+
# same way `-c core.hooksPath=...` does — env-var-style assignments ahead
|
|
1978
|
+
# of the invocation instead of a flag — so it disables every hook just as
|
|
1979
|
+
# completely while matching none of the token shapes above. Upstream
|
|
1980
|
+
# missed this until a downstream fork hardened its own copy against it,
|
|
1981
|
+
# which is the one direction a guard must never be caught in.
|
|
1982
|
+
#
|
|
1983
|
+
# The index is matched as `\d+` rather than pinned to 0: git accepts any
|
|
1984
|
+
# index below GIT_CONFIG_COUNT, so a single-index check is evaded by
|
|
1985
|
+
# typing a 1. Refused outright, like --config-env=, because the path
|
|
1986
|
+
# lives in a separate GIT_CONFIG_VALUE_<n> token that can be exported
|
|
1987
|
+
# earlier, reordered, or left out entirely — there is nothing here to
|
|
1988
|
+
# allowlist against.
|
|
1989
|
+
key_match = re.match(r"git_config_key_\d+=(.*)$", lowered, re.DOTALL)
|
|
1990
|
+
if key_match and key_match.group(1).strip().strip("'\"") == "core.hookspath":
|
|
1991
|
+
return True
|
|
1992
|
+
# `git -c` propagates command-scope config through GIT_CONFIG_PARAMETERS.
|
|
1993
|
+
# Parsing the value as Git's shell-quoted parameter list distinguishes
|
|
1994
|
+
# the key from an unrelated value that merely contains the same text.
|
|
1995
|
+
if re.match(r"git_config_parameters\+?=", lowered):
|
|
1996
|
+
parameters = token.split("=", 1)[1]
|
|
1997
|
+
# The guard sees the command before the shell expands assignments. A
|
|
1998
|
+
# variable or command substitution can therefore hide hooksPath from
|
|
1999
|
+
# this parser and reveal it only to Git. Refuse unresolved values;
|
|
2000
|
+
# the guard cannot safely prove what configuration they will become.
|
|
2001
|
+
if "$" in parameters or "`" in parameters:
|
|
2002
|
+
return True
|
|
2003
|
+
try:
|
|
2004
|
+
configured = shlex.split(parameters, posix=True)
|
|
2005
|
+
except ValueError:
|
|
2006
|
+
configured = []
|
|
2007
|
+
if any(
|
|
2008
|
+
parameter.split("=", 1)[0].strip().lower() == "core.hookspath"
|
|
2009
|
+
for parameter in configured
|
|
2010
|
+
):
|
|
2011
|
+
return True
|
|
2012
|
+
return False
|
|
2013
|
+
|
|
2014
|
+
|
|
2015
|
+
class OperatorStream(io.StringIO):
|
|
2016
|
+
"""Remember punctuation read while shlex is inside a quote or escape."""
|
|
2017
|
+
|
|
2018
|
+
lexer = None
|
|
2019
|
+
literal_operator = False
|
|
2020
|
+
|
|
2021
|
+
def read(self, size=-1):
|
|
2022
|
+
value = super().read(size)
|
|
2023
|
+
if (self.lexer is not None and self.lexer.state in {"'", '"', "\\"}
|
|
2024
|
+
and any(char in "();|&<>" for char in value)):
|
|
2025
|
+
self.literal_operator = True
|
|
2026
|
+
return value
|
|
2027
|
+
|
|
2028
|
+
|
|
2029
|
+
def flat_tokens(text):
|
|
2030
|
+
"""Operator-aware tokens without printed data, or None on a lex failure.
|
|
2031
|
+
|
|
2032
|
+
Args:
|
|
2033
|
+
text: A command line or a script's contents.
|
|
2034
|
+
|
|
2035
|
+
Returns:
|
|
2036
|
+
The token list, or None.
|
|
2037
|
+
"""
|
|
2038
|
+
try:
|
|
2039
|
+
stream = OperatorStream(line_boundaries_as_separators(text))
|
|
2040
|
+
lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
|
|
2041
|
+
lexer.whitespace_split = True
|
|
2042
|
+
lexer.commenters = ""
|
|
2043
|
+
stream.lexer = lexer
|
|
2044
|
+
tokens = []
|
|
2045
|
+
while True:
|
|
2046
|
+
stream.literal_operator = False
|
|
2047
|
+
token = lexer.get_token()
|
|
2048
|
+
if token is None:
|
|
2049
|
+
break
|
|
2050
|
+
# shlex otherwise makes quoted ";" indistinguishable from a real
|
|
2051
|
+
# command boundary. Keep literal destinations as data, so a setter
|
|
2052
|
+
# cannot masquerade as a one-operand configuration read.
|
|
2053
|
+
if stream.literal_operator:
|
|
2054
|
+
if token in COMMAND_SEPARATORS:
|
|
2055
|
+
token = "\x00" + token
|
|
2056
|
+
tokens.append(token)
|
|
2057
|
+
else:
|
|
2058
|
+
tokens.extend(split_operators(token))
|
|
2059
|
+
return [
|
|
2060
|
+
token
|
|
2061
|
+
for index, token in enumerate(tokens)
|
|
2062
|
+
if not printed_argument(tokens, index)
|
|
2063
|
+
]
|
|
2064
|
+
except ValueError:
|
|
2065
|
+
return None
|
|
2066
|
+
|
|
2067
|
+
|
|
2068
|
+
def eval_payloads(scoped_tokens):
|
|
2069
|
+
"""Command strings an `eval` in command position would execute.
|
|
2070
|
+
|
|
2071
|
+
`eval` takes no options: it concatenates its arguments and runs the result,
|
|
2072
|
+
so the payload is every token up to the next separator, joined by a space.
|
|
2073
|
+
Quoting is already gone by the time `shell_tokens` hands them over, which is
|
|
2074
|
+
exactly why the payload is invisible to every other scan here — the argv
|
|
2075
|
+
scan sees one opaque token and the git scan sees no `git` token at all.
|
|
2076
|
+
|
|
2077
|
+
Args:
|
|
2078
|
+
scoped_tokens: Tokens of one command line.
|
|
2079
|
+
|
|
2080
|
+
Returns:
|
|
2081
|
+
A list of payload strings, one per `eval` found in command position.
|
|
2082
|
+
"""
|
|
2083
|
+
payloads = []
|
|
2084
|
+
for index, token in enumerate(scoped_tokens):
|
|
2085
|
+
if token.rsplit("/", 1)[-1] != "eval":
|
|
2086
|
+
continue
|
|
2087
|
+
if not shell_starts_command(scoped_tokens, index):
|
|
2088
|
+
continue
|
|
2089
|
+
collected = []
|
|
2090
|
+
for follow in scoped_tokens[index + 1 :]:
|
|
2091
|
+
if follow in COMMAND_SEPARATORS:
|
|
2092
|
+
break
|
|
2093
|
+
collected.append(follow)
|
|
2094
|
+
if collected:
|
|
2095
|
+
payloads.append(" ".join(collected))
|
|
2096
|
+
return payloads
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
def shell_startup_unknown(args):
|
|
2100
|
+
"""Whether invocation options permit uninspected interactive/login startup."""
|
|
2101
|
+
options = []
|
|
2102
|
+
index = 0
|
|
2103
|
+
while index < len(args):
|
|
2104
|
+
option = args[index]
|
|
2105
|
+
if option == "--" or not option.startswith(("-", "+")):
|
|
2106
|
+
break
|
|
2107
|
+
options.append(option)
|
|
2108
|
+
if option.startswith("-") and not option.startswith("--") and "c" in option[1:]:
|
|
2109
|
+
break
|
|
2110
|
+
index += 2 if option in SHELL_OPTIONS_SEPARATE_VALUE else 1
|
|
2111
|
+
interactive = any(option == "--interactive" or (option.startswith("-") and not option.startswith("--") and "i" in option[1:]) for option in options)
|
|
2112
|
+
login = any(option == "--login" or (option.startswith("-") and not option.startswith("--") and "l" in option[1:]) for option in options)
|
|
2113
|
+
return (interactive and "--norc" not in options) or (login and "--noprofile" not in options)
|
|
2114
|
+
|
|
2115
|
+
|
|
2116
|
+
def initial_directory_context(startup_unknown=False):
|
|
2117
|
+
startup_unknown |= bool(os.environ.get("BASH_ENV") or os.environ.get("ENV"))
|
|
2118
|
+
return {
|
|
2119
|
+
"file": None, "zero": None, "bindings": {}, "cwd_known": True,
|
|
2120
|
+
"cdpath": bool(os.environ.get("CDPATH")), "shadowed": False,
|
|
2121
|
+
"functions": frozenset(), "ifs_default": True,
|
|
2122
|
+
"assignment_types_unknown": startup_unknown,
|
|
2123
|
+
"startup_unknown": startup_unknown,
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
|
|
2127
|
+
def verdict(text, depth=0, followed=None, context=None, follow_scripts=True):
|
|
2128
|
+
"""Whether this text, or a script it runs, bypasses verification.
|
|
2129
|
+
|
|
2130
|
+
Args:
|
|
2131
|
+
text: A command line or a followed script's contents.
|
|
2132
|
+
depth: Number of scripts already followed.
|
|
2133
|
+
followed: Shared inspection count, including repeats and nested payloads.
|
|
2134
|
+
|
|
2135
|
+
Returns:
|
|
2136
|
+
True when the command must be refused.
|
|
2137
|
+
"""
|
|
2138
|
+
if followed is None:
|
|
2139
|
+
followed = []
|
|
2140
|
+
if git_skips_verification(text):
|
|
2141
|
+
return True
|
|
2142
|
+
# RECURSION, NOT A TWELFTH SPELLING (#3531). `eval` is the builtin that takes
|
|
2143
|
+
# a command string, and it was absent from both recursion sets: it is not a
|
|
2144
|
+
# SHELL_PROGRAM (no `-c` payload) and, unlike `builtin`, not a prefix wrapper
|
|
2145
|
+
# whose argv carries through. So every vector this guard blocks was restored
|
|
2146
|
+
# by writing it inside `eval "..."` — measured on origin/main, nine of them,
|
|
2147
|
+
# including `git push --no-verify`, `core.hooksPath`, `HUSKY=0`, one behind a
|
|
2148
|
+
# separator and one behind a `command` wrapper.
|
|
2149
|
+
#
|
|
2150
|
+
# Enumerating spelling twelve is how spelling thirteen appears, and this
|
|
2151
|
+
# repository has twice declined an arms race of that shape. Recursing through
|
|
2152
|
+
# `verdict` instead gives the payload the IDENTICAL analysis, so anything
|
|
2153
|
+
# covered outside is covered inside — including checks the reporter's own
|
|
2154
|
+
# suggested patch would have missed, because it recursed into the git half
|
|
2155
|
+
# only and `HUSKY=0` and `core.hooksPath` live in `token_bypass`.
|
|
2156
|
+
#
|
|
2157
|
+
# Bounded by the same budget as a followed script: unbounded recursion on
|
|
2158
|
+
# adversarial input is itself a denial of the guard, and a Python traceback
|
|
2159
|
+
# exits non-zero, which the wrapper turns into a spurious block.
|
|
2160
|
+
if depth < FOLLOW_MAX_DEPTH:
|
|
2161
|
+
try:
|
|
2162
|
+
eval_scoped = shell_tokens(text)
|
|
2163
|
+
except ValueError:
|
|
2164
|
+
eval_scoped = []
|
|
2165
|
+
for payload in eval_payloads(eval_scoped):
|
|
2166
|
+
if verdict(payload, depth + 1, followed, follow_scripts=False):
|
|
2167
|
+
return True
|
|
2168
|
+
# AND INSIDE A NESTED SHELL. `git_skips_verification` already recurses
|
|
2169
|
+
# into a `bash -c` payload, but only into ITSELF — so it carries the git
|
|
2170
|
+
# half down and leaves `eval`, `HUSKY=0` and `core.hooksPath` behind.
|
|
2171
|
+
# `bash -c 'eval "git commit --no-verify -m x"'` survived round one for
|
|
2172
|
+
# exactly that reason, and it was found by running the adversarial round
|
|
2173
|
+
# rather than by reasoning about it. Recursing the payload through
|
|
2174
|
+
# `verdict` gives every level the whole analysis.
|
|
2175
|
+
for index, token in enumerate(eval_scoped):
|
|
2176
|
+
if token.rsplit("/", 1)[-1] not in SHELL_PROGRAMS:
|
|
2177
|
+
continue
|
|
2178
|
+
if not shell_starts_command(eval_scoped, index):
|
|
2179
|
+
continue
|
|
2180
|
+
nested = nested_shell_payload(eval_scoped, index)
|
|
2181
|
+
if isinstance(nested, str) and verdict(nested, depth + 1, followed, follow_scripts=False):
|
|
2182
|
+
return True
|
|
2183
|
+
|
|
2184
|
+
tokens = flat_tokens(text)
|
|
2185
|
+
if tokens is not None and token_bypass(tokens):
|
|
2186
|
+
return True
|
|
2187
|
+
if not follow_scripts:
|
|
2188
|
+
return False
|
|
2189
|
+
try:
|
|
2190
|
+
scoped_tokens = shell_tokens(text)
|
|
2191
|
+
except ValueError:
|
|
2192
|
+
# Text this parser cannot lex is left to the checks above, which is the
|
|
2193
|
+
# same accepted fail-open the header records for an unlexable command.
|
|
2194
|
+
return False
|
|
2195
|
+
|
|
2196
|
+
def inspect(path, mode, parent_context):
|
|
2197
|
+
key = os.path.realpath(path)
|
|
2198
|
+
sourced = mode == "source"
|
|
2199
|
+
if len(followed) >= FOLLOW_MAX_FILES or depth >= FOLLOW_MAX_DEPTH:
|
|
2200
|
+
print(
|
|
2201
|
+
"block-no-verify: refusing at the script-following cap rather "
|
|
2202
|
+
f"than skipping past it ({path})", file=sys.stderr,
|
|
2203
|
+
)
|
|
2204
|
+
return True
|
|
2205
|
+
# Count inspections rather than unique names: a repeated source must
|
|
2206
|
+
# be read under its current bindings, and still consumes the budget.
|
|
2207
|
+
followed.append(key)
|
|
2208
|
+
inherited = sourced and parent_context is not None
|
|
2209
|
+
child = {
|
|
2210
|
+
"file": None if mode == "stdin" else path,
|
|
2211
|
+
"zero": parent_context["zero"] if inherited else (path if mode == "execute" else None),
|
|
2212
|
+
"bindings": parent_context["bindings"] if inherited else {},
|
|
2213
|
+
"cwd_known": parent_context["cwd_known"] if parent_context else True,
|
|
2214
|
+
"cdpath": parent_context["cdpath"] if parent_context else bool(os.environ.get("CDPATH")),
|
|
2215
|
+
"shadowed": parent_context["shadowed"] if parent_context else False,
|
|
2216
|
+
# Exported functions can survive a new interpreter. Retain the
|
|
2217
|
+
# caller's known candidates conservatively; an unused definition
|
|
2218
|
+
# does not invalidate a binding, while a possible call does.
|
|
2219
|
+
"functions": parent_context["functions"],
|
|
2220
|
+
"ifs_default": parent_context["ifs_default"] if inherited else True,
|
|
2221
|
+
"assignment_types_unknown": parent_context["assignment_types_unknown"] if inherited else parent_context["startup_unknown"],
|
|
2222
|
+
"startup_unknown": parent_context["startup_unknown"],
|
|
2223
|
+
}
|
|
2224
|
+
refused = verdict(read_script(path), depth + 1, followed, child)
|
|
2225
|
+
if inherited:
|
|
2226
|
+
for fact in ("cwd_known", "cdpath", "shadowed", "ifs_default", "assignment_types_unknown", "startup_unknown", "functions", "file", "zero"):
|
|
2227
|
+
if fact == "file":
|
|
2228
|
+
if child[fact] is None:
|
|
2229
|
+
parent_context[fact] = None
|
|
2230
|
+
continue
|
|
2231
|
+
parent_context[fact] = child[fact]
|
|
2232
|
+
if refused:
|
|
2233
|
+
print(
|
|
2234
|
+
f"block-no-verify: {path} — a script this command runs — "
|
|
2235
|
+
"bypasses git's verification hooks.", file=sys.stderr,
|
|
2236
|
+
)
|
|
2237
|
+
return refused
|
|
2238
|
+
|
|
2239
|
+
if context is None:
|
|
2240
|
+
context = initial_directory_context()
|
|
2241
|
+
|
|
2242
|
+
def inspect_payload(payload, mode, parent_context):
|
|
2243
|
+
if depth >= FOLLOW_MAX_DEPTH:
|
|
2244
|
+
print("block-no-verify: refusing at the script-following cap for a nested payload", file=sys.stderr)
|
|
2245
|
+
return True
|
|
2246
|
+
child = parent_context
|
|
2247
|
+
if mode == "execute":
|
|
2248
|
+
child = {
|
|
2249
|
+
**parent_context, "file": None, "zero": None, "bindings": {},
|
|
2250
|
+
"ifs_default": True,
|
|
2251
|
+
"assignment_types_unknown": parent_context["startup_unknown"],
|
|
2252
|
+
}
|
|
2253
|
+
return verdict(payload, depth + 1, followed, child)
|
|
2254
|
+
|
|
2255
|
+
paths, reasons = directory_reach(text, context, inspect, inspect_payload)
|
|
2256
|
+
if reasons:
|
|
2257
|
+
reason, operand = reasons[0]
|
|
2258
|
+
print(
|
|
2259
|
+
f"block-no-verify: refusing an execution it cannot inspect — {reason}"
|
|
2260
|
+
+ (f" ({operand})" if operand else ""),
|
|
2261
|
+
file=sys.stderr,
|
|
2262
|
+
)
|
|
2263
|
+
return True
|
|
2264
|
+
for path, mode in paths:
|
|
2265
|
+
if inspect(path, mode, context):
|
|
2266
|
+
return True
|
|
2267
|
+
return False
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
if verdict(strip_heredocs(command)):
|
|
2271
|
+
sys.exit(1)
|
|
2272
|
+
|
|
2273
|
+
sys.exit(0)
|
|
2274
|
+
PY
|
|
2275
|
+
then
|
|
2276
|
+
cat >&2 <<'EOF'
|
|
2277
|
+
Blocked: this command bypasses pre-commit/pre-push hooks (--no-verify or its
|
|
2278
|
+
short form -n, HUSKY=0, or core.hooksPath disabling). `git commit -n` and a
|
|
2279
|
+
cluster such as `-nm "msg"` skip pre-commit exactly as completely as the long
|
|
2280
|
+
flag. Fix the underlying issue (security audit, lint,
|
|
2281
|
+
typecheck, tests, formatting) instead. If a fix is genuinely impossible, ask the
|
|
2282
|
+
user to make the risk-acceptance decision and add a specific documented ignore;
|
|
2283
|
+
never bypass the hook.
|
|
2284
|
+
EOF
|
|
2285
|
+
exit 2
|
|
2286
|
+
fi
|
|
2287
|
+
|
|
2288
|
+
exit 0
|