@codyswann/lisa 4.74.9 → 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/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 +730 -5
- 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
|
@@ -0,0 +1,3275 @@
|
|
|
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: a safety net that blocks destructive shell commands
|
|
6
|
+
# before they run. Lisa-native reimplementation of the upstream
|
|
7
|
+
# `safety-net@cc-marketplace` plugin's PreToolUse Bash-guard (parity work,
|
|
8
|
+
# issues #1059 and #1960). It does NOT port upstream code — it re-expresses the
|
|
9
|
+
# behavior in Lisa's hook conventions, modeled on block-no-verify.sh.
|
|
10
|
+
#
|
|
11
|
+
# It reads the hook stdin JSON, inspects the proposed Bash command, and EXITS
|
|
12
|
+
# NON-ZERO (2) to BLOCK when a known-destructive pattern matches:
|
|
13
|
+
# - `rm -rf` of a root / home / wildcard path (quote-aware boundaries, so
|
|
14
|
+
# `bash -c "rm -rf /"` and interpreter one-liners are caught too; the rm
|
|
15
|
+
# guards also match path-prefixed invocations like `/bin/rm` and `./rm`)
|
|
16
|
+
# - `rm -rf` of the cwd (`.`), a `..`-traversal path, a `~/`-anchored path,
|
|
17
|
+
# an absolute path outside the project (with a /tmp, /var/tmp, $TMPDIR
|
|
18
|
+
# allowance, each also matched by its symlink-resolved spelling so /tmp and
|
|
19
|
+
# /private/tmp are ONE location), a `$VAR` target that neither is $TMPDIR
|
|
20
|
+
# nor resolves through an unambiguous in-command assignment, or ANY
|
|
21
|
+
# recursive forced delete while cwd is $HOME
|
|
22
|
+
# - force-pushing a protected branch (main/master/production/prod/release) —
|
|
23
|
+
# feature-branch force-push stays allowed (sanctioned rebase workflow;
|
|
24
|
+
# deliberate divergence from upstream's any-branch block). Every git guard
|
|
25
|
+
# sees through leading git GLOBAL options (`-C <path>`, `-c <k>=<v>`,
|
|
26
|
+
# `--git-dir[=…]`, `--no-pager`, …), so `git -C /path <destructive>` cannot
|
|
27
|
+
# dodge the subcommand anchor
|
|
28
|
+
# - `git reset --hard` / `--merge` while the working tree is dirty. Deliberate
|
|
29
|
+
# divergence: upstream blocks unconditionally; Lisa allows clean-tree resets.
|
|
30
|
+
# Residual risk (documented, accepted): the dirty check runs in the hook's
|
|
31
|
+
# cwd at hook time, so a `cd elsewhere && git reset --hard` evades it.
|
|
32
|
+
# - `git read-tree` with an update flag AND `--reset` while the working tree
|
|
33
|
+
# is dirty (issue #3978): the plumbing spelling of the same discard, on the
|
|
34
|
+
# same condition, printing the same remedy. Index-only `read-tree`, and
|
|
35
|
+
# `read-tree -m -u` without `--reset` (which git itself refuses rather than
|
|
36
|
+
# clobber a modified file), stay allowed. Lisa-only guard.
|
|
37
|
+
# - `git rebase --abort`/`--quit` while the in-progress rebase holds
|
|
38
|
+
# human-made conflict resolutions (AUTO_MERGE discriminator; issue #1956).
|
|
39
|
+
# Clean or untouched rebase state stays abortable; the apply backend and a
|
|
40
|
+
# missing AUTO_MERGE ref fail closed. Deliberate divergence: upstream
|
|
41
|
+
# blocks `rebase --abort` unconditionally, which strands agents mid-rebase.
|
|
42
|
+
# - `git checkout` discards (`--`, `-f/--force`, `--pathspec-from-file`,
|
|
43
|
+
# bare `.` — the bare-`.` block exceeds upstream)
|
|
44
|
+
# - `git switch --discard-changes` / `-f/--force`
|
|
45
|
+
# - `git restore` touching the worktree (allowed only with `--staged` and
|
|
46
|
+
# without `--worktree`)
|
|
47
|
+
# - `git stash drop` / `git stash clear`
|
|
48
|
+
# - `git clean` with force and no dry-run (`-n`/`--dry-run` anywhere wins)
|
|
49
|
+
# - `git branch -D` (or `-d` + `-f` in any spelling)
|
|
50
|
+
# - `git tag -d`, `git reflog delete`, `git worktree remove --force`
|
|
51
|
+
# - `find ... -delete`, `find ... -exec rm -rf`, `xargs ... rm -rf`
|
|
52
|
+
# - disk destroyers: `dd of=/dev/...`, `mkfs ... /dev/...`, `shred`
|
|
53
|
+
# - dropping or truncating a database/schema/table (Lisa-only guard)
|
|
54
|
+
# Otherwise it exits 0 and the command proceeds. Malformed hook input fails
|
|
55
|
+
# CLOSED: any parse error exits 2 (a non-2 exit would be a non-blocking hook
|
|
56
|
+
# error in Claude Code, silently failing open).
|
|
57
|
+
#
|
|
58
|
+
# Narrowed false-positive class (issue #3106): the rm TARGET WALK is now scoped
|
|
59
|
+
# to the quoting region that contains the rm, so a recursive delete quoted as
|
|
60
|
+
# PROSE no longer turns every later token on the line into a deletion target. A
|
|
61
|
+
# variable in an unrelated argument — a `gh issue create --body-file "$G/x"`, a
|
|
62
|
+
# `git commit -F "$MSG"` — is no longer read as the thing being deleted. An rm
|
|
63
|
+
# outside quotes, or first inside its quoted run (`bash -c "rm -rf /"`,
|
|
64
|
+
# `echo "$(rm -rf /)"`), still hands the whole statement to the walk. See
|
|
65
|
+
# rm_scan_scope below for the rule and its accepted residual.
|
|
66
|
+
#
|
|
67
|
+
# Known accepted false-positive class: this is a text scan, not a shell engine,
|
|
68
|
+
# so display commands quoting a destructive string (`echo "docs about rm -rf /"`)
|
|
69
|
+
# can match — including one whose target sits in the SAME quoted run as the
|
|
70
|
+
# prose, which the #3106 scoping deliberately does not reach. Upstream exempts
|
|
71
|
+
# those through an engine-only DISPLAY_COMMANDS list that a grep hook cannot
|
|
72
|
+
# replicate. WHAT TO DO WHEN YOU HIT IT (issue #3191): do not reword the prose.
|
|
73
|
+
# When the destructive text is a commit message, an issue body or a results
|
|
74
|
+
# table, the string IS the deliverable and there is no narrower spelling that
|
|
75
|
+
# still says what it must say. Write the text to a file with the Write tool and
|
|
76
|
+
# pass it BY PATH — `gh issue comment --body-file <file>`, `git commit -F
|
|
77
|
+
# <file>` — because these guards scan the COMMAND and never the file. block()
|
|
78
|
+
# prints this at the wall so the operator is told rather than left to find it.
|
|
79
|
+
# The same text-scan limit means the rm guard treats
|
|
80
|
+
# a substitution-wrapped catastrophic delete as verdict-neutral (issue #1982): an
|
|
81
|
+
# executable `echo "$(rm -rf /)"` is blocked, but so are inert twins the shell
|
|
82
|
+
# would never expand — a single-quoted `echo '$(rm -rf /)'` or an escaped
|
|
83
|
+
# `echo "\$(rm -rf /)"` — because the scan has no quote-context awareness. That
|
|
84
|
+
# over-block stays inside this accepted class. Workaround: the write-to-a-file
|
|
85
|
+
# and pass-by-path form above, or quote-break the string,
|
|
86
|
+
# or use the gh-writer heredoc form, whose payload is stripped before the guards
|
|
87
|
+
# run — spelled with a QUOTE-PAIR delimiter (`<<'EOF'`). That exact spelling is
|
|
88
|
+
# what the SAFE path recognises; `<<"EOF"` and `<<\EOF` do not reach it, and
|
|
89
|
+
# since #1993 a `gh … <<\EOF` writer fails closed instead (see parse_marker).
|
|
90
|
+
#
|
|
91
|
+
# Known accepted BYPASS class (the symmetric error direction, issue #1993): the
|
|
92
|
+
# heredoc classifier in parity-safety-net-heredoc.py re-implements bash's
|
|
93
|
+
# here-doc lexing in Python, and any divergence from real bash is a shape it
|
|
94
|
+
# mis-classifies. Five rounds on #1958 and three more on #1993 (R5a swallowed
|
|
95
|
+
# terminator, R5b body-apostrophe quote-state poisoning, R5c backslash-quoted
|
|
96
|
+
# delimiter) established that enumerating those divergences is an open-ended
|
|
97
|
+
# arms race, so the class is CLOSED as accepted rather than chased further. This
|
|
98
|
+
# is survivable because of where a mis-classification lands: ONLY the narrow
|
|
99
|
+
# gh-writer SAFE path strips payload text, and every other classifier exit hands
|
|
100
|
+
# the RAW command to the guards below. A missed here-doc therefore degrades to
|
|
101
|
+
# the content guards; it does not bypass them. Since #1982 taught those guards to
|
|
102
|
+
# see through substitution wrappers, every destructive class this net exists to
|
|
103
|
+
# stop is blocked on the raw text regardless of how the here-doc was read. The
|
|
104
|
+
# net catches ACCIDENTAL catastrophic commands; it is not a wall against an
|
|
105
|
+
# adversary who knows bash's lexer, and it was never the only thing standing
|
|
106
|
+
# between an agent and arbitrary execution.
|
|
107
|
+
#
|
|
108
|
+
# Operators extend the built-in rules with a project-local rule file — one
|
|
109
|
+
# extended-regex (ERE) per line, blank lines and `#` comments ignored — managed
|
|
110
|
+
# by the parity-safety-net-rules skill. Default location (overridable via
|
|
111
|
+
# SAFETY_NET_RULES_FILE):
|
|
112
|
+
# ${CLAUDE_PROJECT_DIR:-$PWD}/.claude/safety-net-rules.txt
|
|
113
|
+
set -euo pipefail
|
|
114
|
+
|
|
115
|
+
# Fail CLOSED on any unexpected error (malformed JSON, missing jq, …): exit 2 so
|
|
116
|
+
# the tool call is denied instead of surfacing a non-blocking hook warning.
|
|
117
|
+
# Deliberately NOT `set -E`: errtrace would propagate this trap into command
|
|
118
|
+
# substitutions (e.g. the heredoc parser call, whose non-zero exit codes are
|
|
119
|
+
# meaningful protocol) and rewrite their status to 2 before it can be read.
|
|
120
|
+
trap 'printf "%s\n" "Blocked by safety-net: hook failed while parsing its input; denying fail-closed." >&2; exit 2' ERR
|
|
121
|
+
|
|
122
|
+
input="$(cat)"
|
|
123
|
+
|
|
124
|
+
# Evaluate once per tool call when this guard is registered on both channels.
|
|
125
|
+
#
|
|
126
|
+
# Lisa reaches an agent through the repository dispatcher AND the plugin
|
|
127
|
+
# manifest, and where both are live the harness runs this guard twice for one
|
|
128
|
+
# tool call. `guard-dedupe.bash` short-circuits the second run ONLY when a
|
|
129
|
+
# byte-identical copy already ALLOWED this exact payload on this exact tool
|
|
130
|
+
# call; a differing vintage, a refusal, and a host with one channel all
|
|
131
|
+
# evaluate exactly as before. Nothing is de-registered by it
|
|
132
|
+
# (CodySwannGT/lisa#3814).
|
|
133
|
+
#
|
|
134
|
+
# Absent library means no dedupe, which is the pre-existing behaviour, so an
|
|
135
|
+
# older channel copy that predates it is unaffected.
|
|
136
|
+
lisa_guard_hook_dir="${BASH_SOURCE[0]%/*}"
|
|
137
|
+
lisa_guard_dedupe_lib="$lisa_guard_hook_dir/guard-dedupe.bash"
|
|
138
|
+
if [ -r "$lisa_guard_dedupe_lib" ]; then
|
|
139
|
+
# shellcheck source=guard-dedupe.bash
|
|
140
|
+
. "$lisa_guard_dedupe_lib"
|
|
141
|
+
trap 'lisa_guard_dedupe_record $?' EXIT
|
|
142
|
+
lisa_guard_dedupe parity-safety-net "$input" \
|
|
143
|
+
"$lisa_guard_hook_dir/parity-safety-net-heredoc.py"
|
|
144
|
+
fi
|
|
145
|
+
|
|
146
|
+
tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
|
|
147
|
+
if [ "$tool_name" != "Bash" ]; then
|
|
148
|
+
exit 0
|
|
149
|
+
fi
|
|
150
|
+
|
|
151
|
+
command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')"
|
|
152
|
+
if [ -z "$command_str" ]; then
|
|
153
|
+
exit 0
|
|
154
|
+
fi
|
|
155
|
+
|
|
156
|
+
# Guidance printed under an ordinary destructive-guard refusal.
|
|
157
|
+
#
|
|
158
|
+
# It teaches the remedy at the wall, the way block_heredoc() does (gardener
|
|
159
|
+
# #1789, extended by issue #3191). It used to end in "narrow the command so it
|
|
160
|
+
# no longer matches the guard", which is advice the accepted prose class CANNOT
|
|
161
|
+
# follow: when the destructive text is a commit message, an issue body or a
|
|
162
|
+
# results table, the string IS the deliverable and there is no narrower spelling
|
|
163
|
+
# that still says what it must say. An operator acts on a printed remedy, so a
|
|
164
|
+
# remedy that cannot be performed is worse than none — it spends the detour and
|
|
165
|
+
# teaches that the refusal was a formality. The remedy that does work is the
|
|
166
|
+
# sibling heredoc one, one surface over: write the text with the Write tool,
|
|
167
|
+
# then pass it BY PATH. The guards scan the COMMAND and never the file, so the
|
|
168
|
+
# path form carries the text through intact.
|
|
169
|
+
#
|
|
170
|
+
# Both branches are printed on every ordinary refusal, deliberately: this guard
|
|
171
|
+
# is a text scan and CANNOT tell which one applies, so naming both and letting
|
|
172
|
+
# the reader pick is the only honest shape. Deciding for them is what the
|
|
173
|
+
# engine-only DISPLAY_COMMANDS list does, and the header explains why a grep
|
|
174
|
+
# hook cannot have one.
|
|
175
|
+
DESTRUCTIVE_GUIDANCE="$(
|
|
176
|
+
cat <<'EOF'
|
|
177
|
+
This command matched a destructive-operation guard. The guard is a text scan,
|
|
178
|
+
not a shell engine, so it cannot tell a destructive command you are RUNNING
|
|
179
|
+
from the same text quoted as CONTENT you are writing. Take the branch that
|
|
180
|
+
describes yours:
|
|
181
|
+
|
|
182
|
+
- The destructive text is only QUOTED CONTENT — a commit message, an issue or
|
|
183
|
+
PR body, a report, a results table, a note. Do not try to reword it; the
|
|
184
|
+
string is the deliverable and no narrower spelling still says what it must.
|
|
185
|
+
Write the text to a file with the Write tool and pass it BY PATH instead:
|
|
186
|
+
`gh issue comment --body-file <file>`, `gh pr create --body-file <file>`,
|
|
187
|
+
`git commit -F <file>`, or read the file back with the Read tool. This guard
|
|
188
|
+
scans the command, never the file, so the path form goes through intact.
|
|
189
|
+
|
|
190
|
+
- You genuinely intend to run the destructive operation. Ask the user to
|
|
191
|
+
confirm, then run it manually outside the agent.
|
|
192
|
+
EOF
|
|
193
|
+
)"
|
|
194
|
+
|
|
195
|
+
# How to preserve uncommitted work before an operation that would discard it.
|
|
196
|
+
#
|
|
197
|
+
# This is appended to the reasons that refuse a discarding operation, and its
|
|
198
|
+
# whole point is what it does NOT say (issue #3722). The obvious advice is
|
|
199
|
+
# "stash it first", and on a single-checkout repository that is fine. Here it
|
|
200
|
+
# is the most dangerous thing an agent can be told: the stash is ONE STACK PER
|
|
201
|
+
# CLONE, shared by every worktree, so with concurrent agents a sibling's push
|
|
202
|
+
# can shift your entry and a sibling's pop can consume it. `lint-staged` pushes
|
|
203
|
+
# one on every commit too, so agent and tooling entries interleave.
|
|
204
|
+
#
|
|
205
|
+
# A remedy fires at the exact moment an agent is holding the work most worth
|
|
206
|
+
# protecting, and a guard's own output reads as authoritative — so pointing it
|
|
207
|
+
# at shared mutable state there is worse than the refusal it accompanies.
|
|
208
|
+
#
|
|
209
|
+
# The patch file is per-worktree: `mktemp` names it, so nothing has to invent a
|
|
210
|
+
# filename, and it lives in this agent's own TMPDIR where no sibling can reach
|
|
211
|
+
# it. Untracked files are unaffected by both refused operations, so `git diff`
|
|
212
|
+
# captures everything at risk. `HEAD` includes staged and unstaged changes, and
|
|
213
|
+
# `--binary` keeps binary edits recoverable rather than emitting a text-only
|
|
214
|
+
# placeholder that cannot be applied.
|
|
215
|
+
#
|
|
216
|
+
# Three corrections from issue #3696, each found by EXECUTING the text below
|
|
217
|
+
# rather than reading it:
|
|
218
|
+
#
|
|
219
|
+
# 1. It preserved but never RECOVERED. The text taught how to save the work and
|
|
220
|
+
# how to restore it later, and stopped there — so an agent that followed it
|
|
221
|
+
# exactly and successfully was returned to the identical refusal, because the
|
|
222
|
+
# tree was still dirty. A remedy that does not clear the wall it is printed
|
|
223
|
+
# at is a detour, and #3722 shipped only half the path. The discard branch
|
|
224
|
+
# below is the other half, and every command in it is one THIS GUARD ALREADY
|
|
225
|
+
# PERMITS: mixed `git reset`, `git diff`, and `git apply` are matched by no
|
|
226
|
+
# guard in this file. Nothing was loosened to make room for it — a blind
|
|
227
|
+
# `git reset --hard` on a dirty tree is still refused, unconditionally.
|
|
228
|
+
#
|
|
229
|
+
# That the operator must first WRITE DOWN what they are discarding is the
|
|
230
|
+
# feature, not a cost. It is a better acknowledgement than a marker the guard
|
|
231
|
+
# could look for, because a marker agents can type is a marker agents learn
|
|
232
|
+
# to type reflexively; this one cannot be satisfied without producing the
|
|
233
|
+
# artifact that makes the discard reversible.
|
|
234
|
+
#
|
|
235
|
+
# 2. It did not run as printed. `[-- <path>]` is conventional notation for an
|
|
236
|
+
# optional argument, but bash expands `[...]` as a glob, so pasting the line
|
|
237
|
+
# verbatim failed with `bash: path: No such file or directory` and wrote no
|
|
238
|
+
# patch at all. Guard remedies are unreviewed prose that agents execute
|
|
239
|
+
# literally, so the optional part is now prose and every line is runnable.
|
|
240
|
+
#
|
|
241
|
+
# 3. `mktemp` was silently a no-op. BSD/macOS mktemp substitutes only TRAILING
|
|
242
|
+
# `X`s, so the `.patch` suffix meant the template came back as the literal
|
|
243
|
+
# string `lisa-preserve-XXXXXX.patch` — one FIXED filename, where successive
|
|
244
|
+
# preserves overwrite each other, in the exact place the comment above claims
|
|
245
|
+
# uniqueness. The suffix is gone rather than spelled portably: GNU mktemp
|
|
246
|
+
# offers `--suffix` and BSD does not, and a patch file does not need an
|
|
247
|
+
# extension. The regression test asserts the produced name DIFFERS from the
|
|
248
|
+
# template, because asserting that the word `mktemp` appears is what let this
|
|
249
|
+
# ship.
|
|
250
|
+
#
|
|
251
|
+
# NO APOSTROPHES IN THE BODY BELOW. It is a quoted heredoc nested inside a `$( )`
|
|
252
|
+
# command substitution, and bash tracks quote state through the body when it
|
|
253
|
+
# scans for the closing paren — so a single `'` (in "the agent's TMPDIR", say)
|
|
254
|
+
# poisons the parse from there to the next quote and the whole file stops being
|
|
255
|
+
# valid shell, with the reported error landing hundreds of lines away in
|
|
256
|
+
# untouched code. Every guidance body in this file avoids them for this reason.
|
|
257
|
+
# Write "of this session" rather than a possessive.
|
|
258
|
+
PRESERVE_GUIDANCE="$(
|
|
259
|
+
cat <<'EOF'
|
|
260
|
+
Preserve the work first, per-worktree. `mktemp` names the file, so nothing has
|
|
261
|
+
to invent one, and it lands in the TMPDIR of this session, where no sibling
|
|
262
|
+
agent can reach it:
|
|
263
|
+
|
|
264
|
+
patch="$(mktemp "${TMPDIR:-/tmp}/lisa-preserve-XXXXXX")"
|
|
265
|
+
git diff --binary HEAD > "$patch"
|
|
266
|
+
|
|
267
|
+
Add `-- <path>` before the `>` to scope the capture to one path. Restore it
|
|
268
|
+
later with `git apply "$patch"`.
|
|
269
|
+
|
|
270
|
+
If the work is NOT yours -- a concurrent agent dirtied this worktree and your
|
|
271
|
+
judgement is "none of this is mine" -- that same capture is how you return the
|
|
272
|
+
tree to HEAD. This block is self-contained; paste it as it stands:
|
|
273
|
+
|
|
274
|
+
patch="$(mktemp "${TMPDIR:-/tmp}/lisa-preserve-XXXXXX")"
|
|
275
|
+
git reset
|
|
276
|
+
git diff --binary HEAD > "$patch"
|
|
277
|
+
git apply -R "$patch"
|
|
278
|
+
|
|
279
|
+
It names the file itself rather than inheriting one from the block above,
|
|
280
|
+
because the two blocks are alternatives and only one of them gets run. A remedy
|
|
281
|
+
printed at the moment an agent is blocked is followed literally; one that
|
|
282
|
+
depends on a variable the reader never set fails on its first line, and the
|
|
283
|
+
guard that printed it stops being believed.
|
|
284
|
+
|
|
285
|
+
`git reset` clears staged and unmerged entries first, so the capture sees the
|
|
286
|
+
whole tree as one diff; reversing it leaves the tree at HEAD with "$patch" as
|
|
287
|
+
the record of what you discarded. Untracked files survive, exactly as they
|
|
288
|
+
survive `git reset --hard`.
|
|
289
|
+
|
|
290
|
+
Do NOT reach for `git stash`. One stash stack is shared by every worktree of a
|
|
291
|
+
clone, so a concurrently running agent can consume the entry you just pushed.
|
|
292
|
+
EOF
|
|
293
|
+
)"
|
|
294
|
+
|
|
295
|
+
# block() prints the reason to stderr (surfaced to the model) and exits 2 so the
|
|
296
|
+
# Bash tool call is denied. $1 = human-readable reason for the block.
|
|
297
|
+
#
|
|
298
|
+
# $2 = optional guidance REPLACING $DESTRUCTIVE_GUIDANCE. block_heredoc() passes
|
|
299
|
+
# its own, because the two remedies are mutually exclusive by construction
|
|
300
|
+
# (issue #1958 F6): a heredoc payload IS executable shell, so the quoted-content
|
|
301
|
+
# branch is exactly the misdirection that finding removed, and the file-based
|
|
302
|
+
# execution advice is exactly what an ordinary prose refusal must not be given.
|
|
303
|
+
block() {
|
|
304
|
+
cat >&2 <<EOF
|
|
305
|
+
Blocked by safety-net: $1
|
|
306
|
+
|
|
307
|
+
${2:-$DESTRUCTIVE_GUIDANCE}
|
|
308
|
+
EOF
|
|
309
|
+
exit 2
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
# grep answers three ways, not two: 0 it matched, 1 it did not match, and >=2 "I
|
|
313
|
+
# could not do this" — it was never exec'd, the pattern was rejected, the input
|
|
314
|
+
# could not be read. Every guard in this file used to reach its verdict through
|
|
315
|
+
# a bare `| grep -q` in a condition, where >=2 is indistinguishable from 1. So
|
|
316
|
+
# "the scan could not run" was read as "this command is safe", and the safety
|
|
317
|
+
# net stopped guarding without ever saying so.
|
|
318
|
+
#
|
|
319
|
+
# Measured rather than feared (CodySwannGT/lisa#3054). With a `grep` that exits
|
|
320
|
+
# 2 first on PATH, this hook ALLOWED `rm -rf /`, `git push --force origin main`,
|
|
321
|
+
# `dd of=/dev/disk0` and `git branch -D main` — every built-in guard, silently,
|
|
322
|
+
# exit 0. And the condition that gets there is live rather than theoretical: on
|
|
323
|
+
# the machine this was found on, 7,679 orphaned processes were outstanding
|
|
324
|
+
# against a `kern.maxprocperuid` of 10,666, putting a fork() that returns EAGAIN
|
|
325
|
+
# a few hundred spawns away.
|
|
326
|
+
#
|
|
327
|
+
# The reasoning is ALREADY in this file, at the bottom, for the project custom
|
|
328
|
+
# rules: a malformed ERE used to silently disable a rule the project believed it
|
|
329
|
+
# had, and that was fixed by reading grep's STATUS instead of its truthiness. It
|
|
330
|
+
# was never extended to the built-in guards. This is that extension.
|
|
331
|
+
#
|
|
332
|
+
# Fails CLOSED, which is this hook's stated contract everywhere else, and NAMES
|
|
333
|
+
# the environment failure — a guard that reports "safe" when it means "I could
|
|
334
|
+
# not tell" is the exact shape #3054 exists to remove.
|
|
335
|
+
#
|
|
336
|
+
# $1 = grep flags (-Ei / -E), $2 = text to scan, $3 = pattern.
|
|
337
|
+
scan() {
|
|
338
|
+
local flags="$1" text="$2" pattern="$3" status=0
|
|
339
|
+
printf '%s' "$text" | grep "$flags" -q -- "$pattern" || status=$?
|
|
340
|
+
case "$status" in
|
|
341
|
+
0) return 0 ;;
|
|
342
|
+
1) return 1 ;;
|
|
343
|
+
*) scan_failed "$status" ;;
|
|
344
|
+
esac
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
# Deny, and say what actually happened. Deliberately NOT block(): block() tells
|
|
348
|
+
# the reader their command matched a destructive pattern, which is a claim about
|
|
349
|
+
# the COMMAND. This is a claim about the MACHINE, and printing the first when
|
|
350
|
+
# the second is true is precisely what sends an operator to re-run instead of to
|
|
351
|
+
# look — the reporting failure #3054 is about.
|
|
352
|
+
scan_failed() {
|
|
353
|
+
cat >&2 <<EOF
|
|
354
|
+
Blocked by safety-net: a guard's pattern scan FAILED TO RUN (grep exited $1); denying fail-closed.
|
|
355
|
+
|
|
356
|
+
This is an ENVIRONMENT failure, not a property of your command. The guard could
|
|
357
|
+
not be evaluated at all, so the safety net denied rather than guessed — a scan
|
|
358
|
+
that cannot answer must never read as "safe".
|
|
359
|
+
|
|
360
|
+
The usual cause is a machine at its per-user process limit, where fork() starts
|
|
361
|
+
returning EAGAIN. Compare:
|
|
362
|
+
|
|
363
|
+
ps -u \$(id -u) | wc -l
|
|
364
|
+
sysctl -n kern.maxprocperuid # Linux: ulimit -u
|
|
365
|
+
|
|
366
|
+
Re-running is reasonable. If it repeats, the machine cannot fork and no number
|
|
367
|
+
of re-runs will change that.
|
|
368
|
+
EOF
|
|
369
|
+
exit 2
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
# Heredoc payloads are data only for a deliberately narrow set of GitHub CLI
|
|
373
|
+
# write commands. A companion parser proves that shape before removing payload
|
|
374
|
+
# text from the destructive-command scans below. Unknown executable heredocs
|
|
375
|
+
# remain visible to every built-in and custom rule. Ambiguous or malformed
|
|
376
|
+
# heredocs fail closed instead of guessing which text the shell would execute.
|
|
377
|
+
#
|
|
378
|
+
# block_heredoc() teaches the remediation the moment the wall is hit: a bare
|
|
379
|
+
# denial strands the agent with no path forward (gardener #1789). The remedy
|
|
380
|
+
# depends on the command shape (issue #1958): `git commit -m "$(cat <<EOF …)"`
|
|
381
|
+
# attempts get the commit -F text; every other heredoc denial gets the
|
|
382
|
+
# file-based execution guidance instead — the commit text is misleading there.
|
|
383
|
+
# Both are passed as block()'s SECOND argument, which REPLACES the ordinary
|
|
384
|
+
# destructive guidance rather than appending to it (issue #3191): that guidance
|
|
385
|
+
# offers a quoted-content branch, and a heredoc payload is executable shell, so
|
|
386
|
+
# printing it here would reintroduce the misdirection #1958 F6 removed.
|
|
387
|
+
# The git-commit detection inlines the GIT_GLOBAL_OPTS shape (defined later in
|
|
388
|
+
# this file, after the heredoc dispatch runs) so `git -C <path> commit` and
|
|
389
|
+
# `git -c k=v commit` spellings are still recognized.
|
|
390
|
+
block_heredoc() {
|
|
391
|
+
if scan -E "$command_str" \
|
|
392
|
+
'(^|[^[:alnum:]_-])git[[:space:]]+(-[^;&|[:space:]]+([[:space:]]+[^-;&|[:space:]][^;&|[:space:]]*)?[[:space:]]+)*commit([^[:alnum:]_-]|$)'; then
|
|
393
|
+
block "$1" "Heredoc commit invocations are blocked (the payload is executable shell).
|
|
394
|
+
Fix: write the commit message to a file and run \`git commit -F <file>\`.
|
|
395
|
+
Every commit must also carry a Co-authored-by trailer for a supported agent
|
|
396
|
+
(Claude/Codex/Cursor/OpenCode/Antigravity/Copilot). OpenCode, and any agent
|
|
397
|
+
outside that fleet (e.g. Devin), must also carry AI-Agent/AI-Model/AI-Effort
|
|
398
|
+
trailers — the commit-msg hook enforces this."
|
|
399
|
+
fi
|
|
400
|
+
block "$1" "Heredoc payloads are blocked here (the payload is executable shell).
|
|
401
|
+
Fix: write the payload to a file with the Write tool, then run the file
|
|
402
|
+
(\`bash <file>\`). These guards READ the shell script an invocation runs, so a
|
|
403
|
+
destructive command inside that file is refused exactly as the inline form is:
|
|
404
|
+
the file is a way to get the payload out of a heredoc, never a way past the
|
|
405
|
+
guards."
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
command_for_guards="$command_str"
|
|
409
|
+
case "$command_str" in
|
|
410
|
+
*'<<'*)
|
|
411
|
+
hook_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
412
|
+
heredoc_parser="$hook_dir/parity-safety-net-heredoc.py"
|
|
413
|
+
if ! command -v python3 >/dev/null 2>&1 || [ ! -r "$heredoc_parser" ]; then
|
|
414
|
+
block_heredoc "cannot safely classify heredoc command because its parser runtime is unavailable"
|
|
415
|
+
fi
|
|
416
|
+
if ! printf '%s\n' "$command_str" | /bin/bash -n >/dev/null 2>&1; then
|
|
417
|
+
block_heredoc "malformed heredoc command failed shell syntax validation"
|
|
418
|
+
fi
|
|
419
|
+
|
|
420
|
+
parser_status=0
|
|
421
|
+
if parser_output="$(printf '%s' "$command_str" | python3 "$heredoc_parser" 2>/dev/null)"; then
|
|
422
|
+
parser_status=0
|
|
423
|
+
else
|
|
424
|
+
parser_status=$?
|
|
425
|
+
fi
|
|
426
|
+
|
|
427
|
+
case "$parser_status" in
|
|
428
|
+
0) command_for_guards="$parser_output" ;;
|
|
429
|
+
10) command_for_guards="$command_str" ;;
|
|
430
|
+
20) block_heredoc "malformed or ambiguous heredoc command cannot be safely classified" ;;
|
|
431
|
+
*) block_heredoc "heredoc parser failed; command was denied fail-closed" ;;
|
|
432
|
+
esac
|
|
433
|
+
;;
|
|
434
|
+
esac
|
|
435
|
+
|
|
436
|
+
custom_command_for_guards="$command_for_guards"
|
|
437
|
+
# A single literal printer writes data, not shell commands. Keep its actual
|
|
438
|
+
# redirection in the scan; unsupported syntax and unavailable parsing retain
|
|
439
|
+
# the original text. Chained commands and shell substitutions never qualify.
|
|
440
|
+
if [[ "$command_for_guards" =~ ^[[:blank:]]*(printf|echo)[[:blank:]] ]] \
|
|
441
|
+
&& command -v python3 >/dev/null 2>&1; then
|
|
442
|
+
if display_projection="$(printf '%s' "$command_for_guards" | python3 \
|
|
443
|
+
"$lisa_guard_hook_dir/parity-safety-net-heredoc.py" --literal-display 2>/dev/null)"; then
|
|
444
|
+
command_for_guards="$display_projection"
|
|
445
|
+
fi
|
|
446
|
+
fi
|
|
447
|
+
|
|
448
|
+
# ─── Follow execution into the file a command RUNS (issue #3612) ─────────────
|
|
449
|
+
#
|
|
450
|
+
# Every guard below classifies SHELL COMMAND TEXT, and until this block existed
|
|
451
|
+
# the only text they ever saw was the command string. A destructive line
|
|
452
|
+
# written into a file and then executed reached them as an interpreter name and
|
|
453
|
+
# a path, and was ALLOWED — silently, with no output at all — while the same
|
|
454
|
+
# line typed inline was refused. Nine spellings were measured against 4.31.1 and
|
|
455
|
+
# all nine allowed: `bash <file>`, `sh <file>`, `zsh <file>`, `env bash <file>`,
|
|
456
|
+
# `source <file>`, `. <file>`, a shebang script invoked directly,
|
|
457
|
+
# `bash -c 'bash <file>'`, and `eval "$(cat <file>)"`. Two more found while
|
|
458
|
+
# fixing it — `bash < <file>` and `cat <file> | bash` — allowed as well.
|
|
459
|
+
#
|
|
460
|
+
# Three more found by review before this shipped, all one shape: a WRAPPER'S OWN
|
|
461
|
+
# OPERAND closing the command position before the program is reached, so
|
|
462
|
+
# `nice -n 5 bash <file>`, `sudo -u nobody bash <file>` and `timeout 5 bash
|
|
463
|
+
# <file>` walked past the invocation. See the wrapper-prefix arms below.
|
|
464
|
+
#
|
|
465
|
+
# A guard that fails CLOSED spends time; this one failed OPEN on destructive
|
|
466
|
+
# actions and reported nothing, so an operator who installed it to gate `rm -rf`
|
|
467
|
+
# outside the project got no signal that the check had not run.
|
|
468
|
+
#
|
|
469
|
+
# WHY IT IS THE DEFAULT PATH rather than an edge case: block_heredoc() above
|
|
470
|
+
# refuses heredocs with an instruction to write the payload to a file and
|
|
471
|
+
# execute that file. The tooling therefore routed agents onto exactly the shape
|
|
472
|
+
# the destructive scan could not see. Nobody had to evade anything. That remedy
|
|
473
|
+
# is reworded above, and this block is the half that makes the reworded remedy
|
|
474
|
+
# true.
|
|
475
|
+
#
|
|
476
|
+
# THE RULE: the blind spot is precisely FILE CONTENTS. Every other part of a
|
|
477
|
+
# command — including the inner command of a substitution — is text these guards
|
|
478
|
+
# already read. So resolve the file an invocation actually RUNS, read it under a
|
|
479
|
+
# size cap, and hand it to the guards as further command text.
|
|
480
|
+
#
|
|
481
|
+
# WHAT THIS DELIBERATELY DOES NOT DO — the known-wrong fix, already made once in
|
|
482
|
+
# this family. #3484 was fixed by teaching a sibling guard to read the contents
|
|
483
|
+
# of any file a command NAMES, and that produced #3604: the guard now opens any
|
|
484
|
+
# readable file named by any argument and attributes its contents to the
|
|
485
|
+
# command, including a path quoted as prose inside a fenced code block in a
|
|
486
|
+
# `--body-file` markdown. A path is followed here ONLY when the command EXECUTES
|
|
487
|
+
# it. `grep -n x <file>`, `gh pr create --body-file <file>` and
|
|
488
|
+
# `git commit -F <file>` name a file as DATA — and $DESTRUCTIVE_GUIDANCE above
|
|
489
|
+
# tells operators to use exactly those forms when the destructive text is a
|
|
490
|
+
# deliverable, so reading them would break the one remedy that works.
|
|
491
|
+
#
|
|
492
|
+
# FAIL CLOSED ON WHAT IT CANNOT FOLLOW. At the top level a computed target
|
|
493
|
+
# (`bash "$SCRIPT"`), a target that does not exist or cannot be read, a file
|
|
494
|
+
# past the size cap, and an operand that is not a path at all (`find … -exec
|
|
495
|
+
# bash {} \;`) are each REFUSED with a reason. Silence on an unclassifiable
|
|
496
|
+
# destructive command is the bug being fixed, and a truncated scan would report
|
|
497
|
+
# a confident ALLOW about text it never read.
|
|
498
|
+
#
|
|
499
|
+
# NOT FOLLOWED, deliberately. Each one is text these guards were never able to
|
|
500
|
+
# classify in the first place, so failing closed on it would spend the operator
|
|
501
|
+
# a refusal it cannot buy anything with:
|
|
502
|
+
# - Non-shell interpreters (`python3 <file>`, `node <file>`). These patterns
|
|
503
|
+
# are shell syntax; running them over a Python or JS file buys
|
|
504
|
+
# mis-attribution, not coverage. The heredoc remedy no longer names one.
|
|
505
|
+
# - A payload piped from a program's OUTPUT (`curl … | bash`). There is no
|
|
506
|
+
# file to read, and the producing command's own text is already scanned.
|
|
507
|
+
# - An indirection nested inside an ALREADY-FOLLOWED file whose target is
|
|
508
|
+
# computed — `source "$(dirname "$0")/lib.sh"` is the universal shell idiom,
|
|
509
|
+
# and failing closed on it would refuse most real scripts. Top-level
|
|
510
|
+
# indirection is authored by the agent in the command it just wrote, which
|
|
511
|
+
# is where a refusal is both actionable and rare.
|
|
512
|
+
# Each is a fail-open the header's ACCEPTED-BYPASS reasoning already covers: the
|
|
513
|
+
# net catches ACCIDENTAL catastrophic commands.
|
|
514
|
+
readonly FOLLOW_MAX_BYTES=262144
|
|
515
|
+
readonly FOLLOW_MAX_TOTAL_BYTES=524288
|
|
516
|
+
readonly FOLLOW_MAX_FILES=8
|
|
517
|
+
readonly FOLLOW_MAX_DEPTH=3
|
|
518
|
+
readonly FOLLOW_MAX_FUNCTION_DEPTH=16
|
|
519
|
+
readonly FOLLOW_MAX_FUNCTION_CALLS=128
|
|
520
|
+
|
|
521
|
+
# Effective working directory established by a literal `cd` in the command, and
|
|
522
|
+
# the flag that says a `cd` happened whose target could NOT be read literally.
|
|
523
|
+
# Empty + 0 means no `cd` has been seen and the session's own cwd governs.
|
|
524
|
+
follow_cwd=""
|
|
525
|
+
follow_cwd_unknown=0
|
|
526
|
+
follow_normalized=""
|
|
527
|
+
follow_paths=$'\n'
|
|
528
|
+
follow_names=""
|
|
529
|
+
follow_count=0
|
|
530
|
+
follow_bytes=0
|
|
531
|
+
follow_function_calls=0
|
|
532
|
+
follow_function_bytes=0
|
|
533
|
+
followed_text=""
|
|
534
|
+
followed_rm_text=""
|
|
535
|
+
# Generated from the canonical reviewed supervisor, never from a host manifest.
|
|
536
|
+
# Regenerate through scripts/generate-scratch-supervisor-profile.mjs.
|
|
537
|
+
readonly SCRATCH_SUPERVISOR_SHA256='76ccf8ca6f98b214361f02ebf6ad58041129b76ed29128455e810095db6c292f'
|
|
538
|
+
readonly SCRATCH_SUPERVISOR_CLEANUP_COUNT='7'
|
|
539
|
+
|
|
540
|
+
# An authenticated public supervisor owns its fixed cleanup sites. Its original
|
|
541
|
+
# source remains visible to every other policy; its actual payload is followed.
|
|
542
|
+
follow_supervisor() {
|
|
543
|
+
local token="$1" depth="$2" statement="$3" path="" profile="" payload="" rm_source="" original=""
|
|
544
|
+
case "$token" in *lisa-scratch-run.sh) ;; *) return 1 ;; esac
|
|
545
|
+
follow_locate "$token" || follow_refuse "$token" "supervisor source could not be resolved"
|
|
546
|
+
path="$follow_located"
|
|
547
|
+
profile="$(python3 - "$path" "$SCRATCH_SUPERVISOR_SHA256" "$statement" "$token" "$SCRATCH_SUPERVISOR_CLEANUP_COUNT" <<'PY'
|
|
548
|
+
import hashlib
|
|
549
|
+
import json
|
|
550
|
+
import os
|
|
551
|
+
import re
|
|
552
|
+
import shlex
|
|
553
|
+
import stat
|
|
554
|
+
import sys
|
|
555
|
+
|
|
556
|
+
path, digest, statement, operand, expected_count = sys.argv[1:]
|
|
557
|
+
def refuse():
|
|
558
|
+
sys.exit(2)
|
|
559
|
+
|
|
560
|
+
try:
|
|
561
|
+
info = os.lstat(path)
|
|
562
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size > 262144:
|
|
563
|
+
refuse()
|
|
564
|
+
raw = open(path, 'rb').read(262145)
|
|
565
|
+
if len(raw) != info.st_size or not re.fullmatch(r'[0-9a-f]{64}', digest):
|
|
566
|
+
refuse()
|
|
567
|
+
if hashlib.sha256(raw).hexdigest() != digest:
|
|
568
|
+
refuse()
|
|
569
|
+
tokens = shlex.split(statement)
|
|
570
|
+
indices = [index for index, token in enumerate(tokens) if token == operand]
|
|
571
|
+
if len(indices) != 1 or '$' in operand or '`' in operand:
|
|
572
|
+
refuse()
|
|
573
|
+
index = indices[0]
|
|
574
|
+
if index < 1 or tokens[index - 1] not in ('sh', 'bash', '/bin/sh', '/bin/bash'):
|
|
575
|
+
refuse()
|
|
576
|
+
prefix = tokens[:index - 1]
|
|
577
|
+
if prefix[:1] == ['env']:
|
|
578
|
+
prefix = prefix[1:]
|
|
579
|
+
assignments = {}
|
|
580
|
+
for token in prefix:
|
|
581
|
+
if '=' not in token or re.search(r'[$`\r\n]', token):
|
|
582
|
+
refuse()
|
|
583
|
+
key, value = token.split('=', 1)
|
|
584
|
+
if key not in ('LISA_SCRATCH_BASE', 'LISA_SCRATCH_TRACE', 'TMPDIR') or key in assignments:
|
|
585
|
+
refuse()
|
|
586
|
+
assignments[key] = value
|
|
587
|
+
# Match actual shell :- precedence, including inherited and
|
|
588
|
+
# explicitly empty values. TMPDIR cannot override a nonempty inherited base.
|
|
589
|
+
effective = dict(os.environ, **assignments)
|
|
590
|
+
base = effective.get('LISA_SCRATCH_BASE') or effective.get('TMPDIR') or '/tmp'
|
|
591
|
+
if not base or not os.path.isabs(base) or re.search(r'[$`\r\n]', base):
|
|
592
|
+
refuse()
|
|
593
|
+
real_base = os.path.realpath(base)
|
|
594
|
+
roots = ['/tmp', '/var/tmp', os.environ.get('TMPDIR', '/tmp')]
|
|
595
|
+
if not any(os.path.commonpath([real_base, os.path.realpath(root)]) == os.path.realpath(root) for root in roots if root and os.path.isabs(root)):
|
|
596
|
+
refuse()
|
|
597
|
+
args = tokens[index + 1:]
|
|
598
|
+
if len(args) < 4 or args[0] != '--suite' or args[2] != '--':
|
|
599
|
+
refuse()
|
|
600
|
+
if not re.fullmatch(r'[A-Za-z0-9._-]{1,64}', args[1]):
|
|
601
|
+
refuse()
|
|
602
|
+
payload = args[3:]
|
|
603
|
+
if not payload[0] or payload[0].startswith('-') or any(re.search(r'[$`\x00\r\n]', value) for value in payload):
|
|
604
|
+
refuse()
|
|
605
|
+
source = raw.decode('utf8')
|
|
606
|
+
cleaned, count = re.subn(r'(?m)^(\s*)rm -rf "(\$_quarantine|\$_root|\$_entry|\$LISA_SCRATCH_ROOT)"', r'\1:', source)
|
|
607
|
+
if not re.fullmatch(r'[1-9][0-9]*', expected_count) or count != int(expected_count):
|
|
608
|
+
refuse()
|
|
609
|
+
print(json.dumps({'source': cleaned, 'original': source, 'payload': shlex.join(payload)}))
|
|
610
|
+
except (OSError, UnicodeError, ValueError):
|
|
611
|
+
refuse()
|
|
612
|
+
PY
|
|
613
|
+
)" || follow_refuse "$path" "supervisor source or public entry authority could not be authenticated"
|
|
614
|
+
rm_source="$(printf '%s' "$profile" | jq -er '.source')" || follow_refuse "$path" "invalid supervisor profile"
|
|
615
|
+
original="$(printf '%s' "$profile" | jq -er '.original')" || follow_refuse "$path" "invalid supervisor source"
|
|
616
|
+
payload="$(printf '%s' "$profile" | jq -er '.payload')" || follow_refuse "$path" "invalid supervisor payload"
|
|
617
|
+
follow_take "$path" "$depth" "$rm_source" "$original"
|
|
618
|
+
[ "$depth" -lt "$FOLLOW_MAX_DEPTH" ] || follow_refuse "$path" "supervisor payload exceeds inspection depth"
|
|
619
|
+
follow_scan "$payload" "$((depth + 1))"
|
|
620
|
+
return 0
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
FOLLOW_GUIDANCE="This command EXECUTES a file. These guards classify shell text, so the script an
|
|
624
|
+
invocation runs is read and scanned as part of the command — a destructive line
|
|
625
|
+
inside the file is refused exactly as the inline form is. This one could not be
|
|
626
|
+
read, so the guard denied rather than passed: a destructive command it cannot
|
|
627
|
+
see is the thing it exists to stop.
|
|
628
|
+
|
|
629
|
+
- Name the script by a LITERAL path that exists and is readable — \`bash
|
|
630
|
+
./scripts/x.sh\` rather than \`bash \"\$SCRIPT\"\` — and the guard can classify it.
|
|
631
|
+
- A leading \`cd\` the guard cannot read literally — \`cd \"\$DIR\" && bash x.sh\` —
|
|
632
|
+
leaves it unable to say WHICH copy of \`x.sh\` would run, and a same-named file
|
|
633
|
+
in another directory is a different file. Spell the directory out, or give the
|
|
634
|
+
script its full path.
|
|
635
|
+
- A file past the inspection cap is refused rather than half-scanned, because a
|
|
636
|
+
truncated scan reports a confident ALLOW about text it never read. Split it,
|
|
637
|
+
or run it manually outside the agent.
|
|
638
|
+
- You genuinely intend to run it as it stands. Ask the user to confirm, then run
|
|
639
|
+
it manually outside the agent."
|
|
640
|
+
|
|
641
|
+
# Deny, naming the file the guard could not classify. Deliberately NOT the
|
|
642
|
+
# ordinary destructive guidance: that guidance is about a pattern the COMMAND
|
|
643
|
+
# matched, and this is a claim that the guard could not look at all.
|
|
644
|
+
follow_refuse() {
|
|
645
|
+
block "cannot classify the file this command executes ($1): $2" "$FOLLOW_GUIDANCE"
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
# Result of the last follow_unwrap / follow_next_operand. These two SET a global
|
|
649
|
+
# rather than printing, and every caller reads it.
|
|
650
|
+
#
|
|
651
|
+
# Printing would be the natural shape, but `$(follow_unwrap …)` is a FORK, and
|
|
652
|
+
# the walk runs it once per token of every followed file. Measured: a script
|
|
653
|
+
# just under the 256 KiB inspection cap holds ~48k tokens and cost 27 SECONDS of
|
|
654
|
+
# wall clock — inside a PreToolUse hook that gates every Bash call. Both
|
|
655
|
+
# functions are pure parameter expansion, so the subshell bought nothing.
|
|
656
|
+
#
|
|
657
|
+
# The concurrent-process ceiling was never the risk: the forks are sequential,
|
|
658
|
+
# so peak process count never rises. The latency was the whole problem.
|
|
659
|
+
follow_unwrapped=""
|
|
660
|
+
follow_operand_index=0
|
|
661
|
+
follow_located=""
|
|
662
|
+
|
|
663
|
+
# Strip substitution, quote and alias wrappers off one token. A sibling of
|
|
664
|
+
# strip_subst_wrappers() below rather than a call to it: that one is defined
|
|
665
|
+
# after this block runs, and it exists for a different job (normalizing a
|
|
666
|
+
# DELETION TARGET). Kept separate so tightening either cannot silently move the
|
|
667
|
+
# other's verdicts.
|
|
668
|
+
follow_unwrap() {
|
|
669
|
+
local t="$1" prev=""
|
|
670
|
+
while [ "$t" != "$prev" ]; do
|
|
671
|
+
prev="$t"
|
|
672
|
+
case "$t" in
|
|
673
|
+
'$(('*) break ;;
|
|
674
|
+
'\'*) t="${t#\\}" ;;
|
|
675
|
+
'$('*) t="${t#'$('}" ;;
|
|
676
|
+
'<('*) t="${t#'<('}" ;;
|
|
677
|
+
'>('*) t="${t#'>('}" ;;
|
|
678
|
+
'"'*) t="${t#'"'}" ;;
|
|
679
|
+
"'"*) t="${t#\'}" ;;
|
|
680
|
+
'`'*) t="${t#'`'}" ;;
|
|
681
|
+
'('*) t="${t#(}" ;;
|
|
682
|
+
esac
|
|
683
|
+
done
|
|
684
|
+
prev=""
|
|
685
|
+
while [ "$t" != "$prev" ]; do
|
|
686
|
+
prev="$t"
|
|
687
|
+
case "$t" in
|
|
688
|
+
*')') t="${t%)}" ;;
|
|
689
|
+
*'`') t="${t%'`'}" ;;
|
|
690
|
+
*'"') t="${t%'"'}" ;;
|
|
691
|
+
*"'") t="${t%\'}" ;;
|
|
692
|
+
*';') t="${t%;}" ;;
|
|
693
|
+
esac
|
|
694
|
+
done
|
|
695
|
+
follow_unwrapped="$t"
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
# A shell interpreter, bare or path-prefixed. Only shells: see NOT FOLLOWED.
|
|
699
|
+
follow_is_interpreter() {
|
|
700
|
+
case "$1" in
|
|
701
|
+
bash | sh | zsh | ksh | ksh93 | dash | ash \
|
|
702
|
+
| */bash | */sh | */zsh | */ksh | */ksh93 | */dash | */ash) return 0 ;;
|
|
703
|
+
esac
|
|
704
|
+
return 1
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
# Could this token itself be the thing a wrapper prefix goes on to invoke?
|
|
708
|
+
#
|
|
709
|
+
# Used ONLY to yield when stepping over a wrapper's own operand, so that
|
|
710
|
+
# step-over can never swallow the invocation. Deliberately generous, because the
|
|
711
|
+
# cost of the two answers is asymmetric: yielding wrongly leaves the walk where
|
|
712
|
+
# it already was, while consuming wrongly loses the program and the guard falls
|
|
713
|
+
# open — which is the entire defect this block exists to close. Anything
|
|
714
|
+
# path-shaped yields too, so `env -i <file>` still reaches the script rather
|
|
715
|
+
# than eating it as a value `-i` does not take.
|
|
716
|
+
follow_may_invoke() {
|
|
717
|
+
follow_is_interpreter "$1" && return 0
|
|
718
|
+
# shellcheck disable=SC2088
|
|
719
|
+
case "$1" in
|
|
720
|
+
"" | -* | [A-Za-z_]*=*) return 0 ;;
|
|
721
|
+
/* | ./* | ../* | '~/'* | */*) return 0 ;;
|
|
722
|
+
sudo | env | command | exec | nohup | nice | time | builtin | timeout \
|
|
723
|
+
| do | then | else | while | until | if | '!' | '{' \
|
|
724
|
+
| eval | find | xargs | cd | source | . | cat) return 0 ;;
|
|
725
|
+
esac
|
|
726
|
+
return 1
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
# Split the command into statements at UNQUOTED separators, printing one
|
|
730
|
+
# statement per line as "<separator><TAB><statement>". Quote-aware on purpose: a
|
|
731
|
+
# `;` or `|` inside a quoted string is not a statement boundary, and splitting
|
|
732
|
+
# there would invent a command position in the middle of a quoted argument and
|
|
733
|
+
# make prose look like an invocation — the #3604 failure mode, arrived at from
|
|
734
|
+
# the other side. Literal subshell parentheses carry scope events, while quoted
|
|
735
|
+
# parentheses and substitutions stay in their original statement so `eval
|
|
736
|
+
# "$(cat <file>)"` retains its command position and executed-file inspection.
|
|
737
|
+
#
|
|
738
|
+
# awk rather than a bash char loop because this runs on EVERY intercepted Bash
|
|
739
|
+
# command, and it addresses the text by index instead of accumulating it a
|
|
740
|
+
# character at a time so the cost stays linear on a followed file. The text
|
|
741
|
+
# arrives through the ENVIRONMENT: `awk -v` processes backslash escapes in the
|
|
742
|
+
# value it is given and would rewrite the command before it was ever read.
|
|
743
|
+
follow_split() {
|
|
744
|
+
LC_ALL=C SN_FOLLOW_TEXT="$1" awk '
|
|
745
|
+
# Lexical lookahead keeps an asynchronous return from ending its parent
|
|
746
|
+
# scan before the trailing & event. Aggregate work is capped linearly in
|
|
747
|
+
# input size, including overlap from nested scopes; no partial parse passes.
|
|
748
|
+
function lookahead_step() {
|
|
749
|
+
if (++lookahead_work > 8 * n + 1024) { exit 2 }
|
|
750
|
+
}
|
|
751
|
+
function brace_pipe(from, opening, closing, j, c, quote, escape, braces, nesting, tail, tail_cases, tail_header, word, word_end) {
|
|
752
|
+
quote = ""; escape = 0; braces = 1; nesting = 0; brace_redirect = ""; tail_cases = 0; tail_header = 0;
|
|
753
|
+
for (j = from; j <= n; j++) {
|
|
754
|
+
lookahead_step(); c = substr(s, j, 1);
|
|
755
|
+
if (escape) { escape = 0; continue }
|
|
756
|
+
if (c == "\\" && quote != "\047") { escape = 1; continue }
|
|
757
|
+
if (quote != "") { if (c == quote) { quote = "" } continue }
|
|
758
|
+
if (c == "\"" || c == "\047" || c == "`") { quote = c; continue }
|
|
759
|
+
if (braces > 0) {
|
|
760
|
+
# A case-pattern ) is not the closing parenthesis of its shell.
|
|
761
|
+
if (opening == "(" && c ~ /[A-Za-z_]/) {
|
|
762
|
+
word_end = j + 1;
|
|
763
|
+
while (word_end <= n && substr(s, word_end, 1) ~ /[A-Za-z_0-9-]/) { lookahead_step(); word_end++ }
|
|
764
|
+
word = substr(s, j, word_end - j); j = word_end - 1;
|
|
765
|
+
if (word == "case" && (tail_cases == 0 || !tail_patterns[tail_cases])) { tail_header++ }
|
|
766
|
+
if (word == "in" && tail_header > 0) {
|
|
767
|
+
tail_header--; tail_patterns[++tail_cases] = 1; tail_levels[tail_cases] = braces;
|
|
768
|
+
}
|
|
769
|
+
if (word == "esac" && tail_cases > 0) { tail_cases-- }
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (tail_cases > 0 && tail_levels[tail_cases] == braces) {
|
|
773
|
+
if (c == ")" && tail_patterns[tail_cases]) { tail_patterns[tail_cases] = 0; continue }
|
|
774
|
+
if (c == ";" && substr(s, j + 1, 1) == ";") { tail_patterns[tail_cases] = 1; j++; continue }
|
|
775
|
+
}
|
|
776
|
+
if (c == opening) { braces++ }
|
|
777
|
+
if (c == closing && --braces == 0) { tail = j + 1 }
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
# Redirections belong to the compound command too. Quoted operator
|
|
781
|
+
# bytes and nested substitutions are operands, not the pipeline edge.
|
|
782
|
+
if (c == "(") { nesting++; continue }
|
|
783
|
+
if (c == ")") { if (nesting == 0) { brace_redirect = substr(s, tail, j - tail); return 0 } nesting--; continue }
|
|
784
|
+
if (nesting > 0) { continue }
|
|
785
|
+
if (c == "|") { brace_redirect = substr(s, tail, j - tail); return substr(s, j + 1, 1) != "|" }
|
|
786
|
+
if (c == ";" || c == "\n") { brace_redirect = substr(s, tail, j - tail); return 0 }
|
|
787
|
+
if (c == "&" && substr(s, j - 1, 1) !~ /[<>]/ && substr(s, j + 1, 1) != ">") { brace_redirect = substr(s, tail, j - tail); return 0 }
|
|
788
|
+
}
|
|
789
|
+
if (tail > 0) { brace_redirect = substr(s, tail) }
|
|
790
|
+
return 0;
|
|
791
|
+
}
|
|
792
|
+
function list_async(from, j, c, quote, escape, nesting, braces) {
|
|
793
|
+
quote = ""; escape = 0; nesting = 0; braces = 0;
|
|
794
|
+
for (j = from; j <= n; j++) {
|
|
795
|
+
lookahead_step();
|
|
796
|
+
c = substr(s, j, 1);
|
|
797
|
+
if (escape) { escape = 0; continue }
|
|
798
|
+
if (c == "\\" && quote != "\047") { escape = 1; continue }
|
|
799
|
+
if (quote != "") { if (c == quote) { quote = "" } continue }
|
|
800
|
+
if (c == "\"" || c == "\047" || c == "`") { quote = c; continue }
|
|
801
|
+
if (c == "(") { nesting++; continue }
|
|
802
|
+
if (c == ")") { if (nesting == 0) { return 0 } nesting--; continue }
|
|
803
|
+
if (c == "{") { braces++; continue }
|
|
804
|
+
if (c == "}") { if (braces > 0) { braces-- } continue }
|
|
805
|
+
if (nesting > 0 || braces > 0) { continue }
|
|
806
|
+
if (c == ";" || c == "\n") { return 0 }
|
|
807
|
+
if (c == "&" && substr(s, j - 1, 1) !~ /[&<>]/ && substr(s, j + 1, 1) !~ /[&>]/) { return 1 }
|
|
808
|
+
}
|
|
809
|
+
return 0;
|
|
810
|
+
}
|
|
811
|
+
BEGIN {
|
|
812
|
+
s = ENVIRON["SN_FOLLOW_TEXT"]; n = length(s);
|
|
813
|
+
q = ""; esc = 0; start = 1; sep = "o"; parens = 0;
|
|
814
|
+
functions = 0; cases = 0; backtick = 0; brace_depth = 0; post_brace = 0;
|
|
815
|
+
printf "L\t%d\n", list_async(1);
|
|
816
|
+
for (i = 1; i <= n; i++) {
|
|
817
|
+
c = substr(s, i, 1);
|
|
818
|
+
if (esc == 1) { if (c == "$") { literal_dollars[i] = 1 } esc = 0; continue }
|
|
819
|
+
if (c == "\\" && q != "\047") { esc = 1; continue }
|
|
820
|
+
if (functions > 0) {
|
|
821
|
+
if (q != "") { if (c == q) { q = "" } continue }
|
|
822
|
+
if (c == "\"" || c == "\047") { q = c; continue }
|
|
823
|
+
if (c == "{") { functions++ }
|
|
824
|
+
if (c == "}" && --functions == 0) {
|
|
825
|
+
printf "N\t%s\t%s\n", sep, function_name;
|
|
826
|
+
body = substr(s, function_start, i - function_start);
|
|
827
|
+
count = split(body, body_lines, "\n");
|
|
828
|
+
for (j = 1; j <= count; j++) { printf "D\t%s\n", body_lines[j] }
|
|
829
|
+
printf "F\t\n"; start = i + 1; sep = "o";
|
|
830
|
+
}
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
quoted_expression = q == "\"" && ((c == "(" && substr(s, i - 1, 1) == "$" && !literal_dollars[i - 1]) || c == "`");
|
|
834
|
+
if (q != "" && !quoted_expression) { if (c == q) { q = "" } continue }
|
|
835
|
+
if (c == "\"" || c == "\047") { q = c; continue }
|
|
836
|
+
if (c == "`") {
|
|
837
|
+
if (backtick) {
|
|
838
|
+
piece = substr(s, start, i - start);
|
|
839
|
+
if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
|
|
840
|
+
printf "]\t\n"; start = backtick_start; q = backtick_quote; backtick = 0;
|
|
841
|
+
} else {
|
|
842
|
+
backtick_start = start; backtick_quote = q; q = ""; backtick = 1;
|
|
843
|
+
printf "[\t\nL\t%d\n", list_async(i + 1); start = i + 1;
|
|
844
|
+
}
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
if (i == start && c ~ /[[:space:]]/) { start = i + 1; continue }
|
|
848
|
+
if (c ~ /[[:space:]]/ && substr(s, i - 1, 1) !~ /[[:space:]]/ && substr(s, start, 4) == "case") {
|
|
849
|
+
piece = substr(s, start, i - start);
|
|
850
|
+
if (piece ~ /^case[[:space:]].*[[:space:]]in$/) {
|
|
851
|
+
case_scopes[++cases] = parens;
|
|
852
|
+
case_patterns[cases] = 1; printf "C\t\n"; start = i + 1; continue;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (cases > 0 && c ~ /[[:space:];&|)]/ && substr(s, start, 4) == "esac") {
|
|
856
|
+
piece = substr(s, start, i - start);
|
|
857
|
+
if (piece ~ /^esac[[:space:]]*$/) { cases--; printf "K\t\n" }
|
|
858
|
+
}
|
|
859
|
+
if (cases > 0 && case_scopes[cases] == parens && case_patterns[cases]) {
|
|
860
|
+
if (c == ")") { case_patterns[cases] = 0; start = i + 1 }
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
if (c == "{") {
|
|
864
|
+
piece = substr(s, start, i - start);
|
|
865
|
+
definition = piece ~ /^[[:space:]]*([A-Za-z_][A-Za-z_0-9]*[[:space:]]*\([[:space:]]*\)|function[[:space:]]+[A-Za-z_][A-Za-z_0-9]*([[:space:]]*\([[:space:]]*\))?)[[:space:]]*$/;
|
|
866
|
+
if (definition) {
|
|
867
|
+
function_name = piece;
|
|
868
|
+
sub(/^[[:space:]]*(function[[:space:]]+)?/, "", function_name);
|
|
869
|
+
sub(/[[:space:]\(].*$/, "", function_name);
|
|
870
|
+
functions = 1; function_start = i + 1;
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
previous = i > 1 ? substr(s, i - 1, 1) : "";
|
|
874
|
+
group = (i == 1 || previous ~ /[[:space:];&|()]/) && substr(s, i + 1, 1) ~ /[[:space:];]/ &&
|
|
875
|
+
piece ~ /^[[:space:]]*((then|do|else|!|time)[[:space:]]+)*$/;
|
|
876
|
+
brace_groups[++brace_depth] = group;
|
|
877
|
+
if (group) {
|
|
878
|
+
brace_sep = sep;
|
|
879
|
+
if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
|
|
880
|
+
# A pipeline owns the whole compound command, not just the final
|
|
881
|
+
# statement before |. Snapshot before any brace-body cd/return.
|
|
882
|
+
outgoing = brace_pipe(i + 1, "{", "}");
|
|
883
|
+
brace_pipes[brace_depth] = brace_sep == "|" || outgoing;
|
|
884
|
+
if (brace_pipes[brace_depth]) { printf "(\t%s%s\nL\t%d\n", (brace_sep == "|" ? "|" : ""), (outgoing ? "+" : ""), list_async(i + 1) }
|
|
885
|
+
printf "G\t%s\nL\t%d\n", brace_sep, list_async(i + 1);
|
|
886
|
+
if (brace_redirect ~ /[^[:space:]]/) { printf "I\t%s\n", brace_redirect }
|
|
887
|
+
# P retains incoming stdin provenance without wrapping each body
|
|
888
|
+
# statement in another child: all statements share the brace cwd.
|
|
889
|
+
if (brace_sep == "|") { sep = "P" }
|
|
890
|
+
start = i + 1; continue;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
if (c == "}" && brace_depth > 0) {
|
|
894
|
+
group = brace_groups[brace_depth--];
|
|
895
|
+
if (group) {
|
|
896
|
+
piece = substr(s, start, i - start);
|
|
897
|
+
if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
|
|
898
|
+
printf "H\t\n";
|
|
899
|
+
if (brace_pipes[brace_depth + 1]) { printf ")\t\n" }
|
|
900
|
+
start = i + 1; post_brace = 1; continue;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
if (c == "(") {
|
|
904
|
+
previous = i > 1 ? substr(s, i - 1, 1) : "";
|
|
905
|
+
piece = substr(s, start, i - start);
|
|
906
|
+
header = piece ~ /^[[:space:]]*(function[[:space:]]+)?[A-Za-z_][A-Za-z_0-9]*[[:space:]]*$/ && substr(s, i + 1) ~ /^[[:space:]]*\)/;
|
|
907
|
+
expression = previous ~ /[$<>]/ && !literal_dollars[i - 1] && substr(s, i + 1, 1) != "(";
|
|
908
|
+
group = (parens == 0 || scopes[parens] != 0) &&
|
|
909
|
+
(i == 1 || previous ~ /[[:space:];&|()]/) && substr(s, i + 1, 1) != "(" && !header;
|
|
910
|
+
scopes[++parens] = expression ? 2 : group;
|
|
911
|
+
if (expression) {
|
|
912
|
+
expression_starts[parens] = start;
|
|
913
|
+
expression_quotes[parens] = q; q = "";
|
|
914
|
+
printf "[\t\nL\t%d\n", list_async(i + 1); start = i + 1; continue;
|
|
915
|
+
}
|
|
916
|
+
if (group) {
|
|
917
|
+
piece = substr(s, start, i - start);
|
|
918
|
+
if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
|
|
919
|
+
outgoing = brace_pipe(i + 1, "(", ")");
|
|
920
|
+
printf "(\t%s%s\nL\t%d\n", sep, (outgoing ? "+" : ""), list_async(i + 1);
|
|
921
|
+
if (brace_redirect ~ /[^[:space:]]/) { printf "I\t%s\n", brace_redirect }
|
|
922
|
+
if (sep == "|") { sep = "P" }
|
|
923
|
+
start = i + 1; continue;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
if (c == ")" && parens > 0) {
|
|
927
|
+
piece = substr(s, start, i - start);
|
|
928
|
+
group = scopes[parens--];
|
|
929
|
+
if (group == 2) {
|
|
930
|
+
if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
|
|
931
|
+
printf "]\t\n"; start = expression_starts[parens + 1]; q = expression_quotes[parens + 1]; continue;
|
|
932
|
+
}
|
|
933
|
+
if (group) {
|
|
934
|
+
piece = substr(s, start, i - start);
|
|
935
|
+
if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", sep, piece; sep = "o" }
|
|
936
|
+
printf ")\t\n"; start = i + 1; post_brace = 1; continue;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if (c == ";" || c == "&" || c == "|" || c == "\n") {
|
|
940
|
+
if (parens > 0 && scopes[parens] == 0) { continue }
|
|
941
|
+
if (c == "&" && (substr(s, i - 1, 1) ~ /[<>]/ || substr(s, i + 1, 1) == ">")) { continue }
|
|
942
|
+
piece = substr(s, start, i - start);
|
|
943
|
+
pipe = c == "|" && substr(s, i + 1, 1) != "|";
|
|
944
|
+
if (pipe || sep == "|") { printf "(\t%s%s\nL\t%d\n", (sep == "|" ? "|" : ""), (pipe ? "+" : ""), list_async(start) }
|
|
945
|
+
if (piece ~ /[^[:space:]]/) { printf "%s\t%s\n", post_brace ? "R" : sep, piece }
|
|
946
|
+
post_brace = 0;
|
|
947
|
+
if (pipe || sep == "|") { printf ")\t\n" }
|
|
948
|
+
if (c == ";" && substr(s, i + 1, 1) == ";" && cases > 0) {
|
|
949
|
+
case_patterns[cases] = 1; printf "A\t\n"; i++;
|
|
950
|
+
}
|
|
951
|
+
if (c == "|" && substr(s, i + 1, 1) == "|") {
|
|
952
|
+
i++; sep = "?";
|
|
953
|
+
} else if (c == "&" && substr(s, i + 1, 1) == "&") {
|
|
954
|
+
i++; sep = "&";
|
|
955
|
+
} else if (c == "|") { sep = "|" } else { sep = "o" }
|
|
956
|
+
if (c == "&" && sep == "o" && substr(s, i - 1, 1) !~ /[&<>]/ && substr(s, i + 1, 1) != ">") {
|
|
957
|
+
printf "B\t\n";
|
|
958
|
+
}
|
|
959
|
+
if ((c == ";" || c == "\n" || c == "&") && sep == "o") { printf "L\t%d\n", list_async(i + 1) }
|
|
960
|
+
start = i + 1;
|
|
961
|
+
continue
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
if (sep == "|") { printf "(\t|\nL\t%d\n", list_async(start) }
|
|
965
|
+
printf "%s\t%s\n", sep, substr(s, start, n - start + 1);
|
|
966
|
+
if (sep == "|") { printf ")\t\n" }
|
|
967
|
+
}
|
|
968
|
+
'
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
# Collapse `.`, `..` and empty segments so the path a verdict NAMES is the one a
|
|
972
|
+
# reader recognises. Purely lexical, and it cannot move a verdict: every
|
|
973
|
+
# component has already been proven to exist by the `-d` test that follows, and
|
|
974
|
+
# the `-f`/`-r` tests are applied to the collapsed path either way.
|
|
975
|
+
follow_normalize() {
|
|
976
|
+
local p="$1" out="" seg
|
|
977
|
+
local IFS=/
|
|
978
|
+
set -f
|
|
979
|
+
# The word split is the point: `$p` is being addressed one path segment at a
|
|
980
|
+
# time, and the glob guard above keeps a literal `*` from expanding.
|
|
981
|
+
# shellcheck disable=SC2086
|
|
982
|
+
for seg in $p; do
|
|
983
|
+
case "$seg" in
|
|
984
|
+
"" | .) ;;
|
|
985
|
+
..) out="${out%/*}" ;;
|
|
986
|
+
*) out="$out/$seg" ;;
|
|
987
|
+
esac
|
|
988
|
+
done
|
|
989
|
+
set +f
|
|
990
|
+
follow_normalized="${out:-/}"
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
# Track the working directory a literal `cd` establishes, so a relative executed
|
|
994
|
+
# path resolves against the directory the command will actually have.
|
|
995
|
+
#
|
|
996
|
+
# This REPLACES the session's own cwd rather than adding to it. It used to be an
|
|
997
|
+
# extra base appended AFTER `$PWD`, so `$PWD` always won and
|
|
998
|
+
# `cd <other-checkout> && bash scripts/x.sh` was classified against the session
|
|
999
|
+
# checkout's same-named copy. That is not a resolution failure — it succeeds,
|
|
1000
|
+
# against a real and readable file, and the verdict is reported with full
|
|
1001
|
+
# confidence about text nobody was about to run (CodySwannGT/lisa#3933). Two
|
|
1002
|
+
# checkouts of one repository differ by exactly the work in flight, which is the
|
|
1003
|
+
# entire point of the second one, so the copy that would RUN is the only copy
|
|
1004
|
+
# whose verdict means anything. Wrong in both directions, and the expensive
|
|
1005
|
+
# direction is silent: the dangerous line sits in the copy that would run and
|
|
1006
|
+
# the guard reads the pristine one.
|
|
1007
|
+
#
|
|
1008
|
+
# A target this hook cannot turn into a literal directory marks the cwd UNKNOWN
|
|
1009
|
+
# instead of leaving a stale one in place. `follow_locate` then refuses a
|
|
1010
|
+
# relative token rather than guessing, which is the honest-refusal doctrine the
|
|
1011
|
+
# rest of this block already follows: a confident scan of the wrong file is
|
|
1012
|
+
# strictly worse than saying it could not tell.
|
|
1013
|
+
follow_cd() {
|
|
1014
|
+
local d="$1" home="${HOME:-}"
|
|
1015
|
+
# The tilde arms are LITERAL text — the unexpanded spelling the hook was
|
|
1016
|
+
# handed — for the reason follow_locate's arms document.
|
|
1017
|
+
# shellcheck disable=SC2088
|
|
1018
|
+
case "$d" in
|
|
1019
|
+
'~') d="$home" ;;
|
|
1020
|
+
'~/'*) d="$home/${d#'~/'}" ;;
|
|
1021
|
+
esac
|
|
1022
|
+
case "$d" in
|
|
1023
|
+
"" | -* | *'$'* | *'*'* | *'?'* | *'`'* | *'{'* | *'['*)
|
|
1024
|
+
follow_cwd=""
|
|
1025
|
+
follow_cwd_unknown=1
|
|
1026
|
+
return 0
|
|
1027
|
+
;;
|
|
1028
|
+
esac
|
|
1029
|
+
case "$d" in
|
|
1030
|
+
/*) ;;
|
|
1031
|
+
*)
|
|
1032
|
+
# A relative cd cannot recover an unknown base. Falling back to this
|
|
1033
|
+
# hook's PWD would confidently inspect another directory (#4299).
|
|
1034
|
+
[ "$follow_cwd_unknown" -eq 0 ] || return 0
|
|
1035
|
+
d="${follow_cwd:-$PWD}/$d"
|
|
1036
|
+
;;
|
|
1037
|
+
esac
|
|
1038
|
+
follow_normalize "$d"
|
|
1039
|
+
d="$follow_normalized"
|
|
1040
|
+
if [ -d "$d" ]; then
|
|
1041
|
+
follow_cwd="$d"
|
|
1042
|
+
follow_cwd_unknown=0
|
|
1043
|
+
else
|
|
1044
|
+
follow_cwd=""
|
|
1045
|
+
follow_cwd_unknown=1
|
|
1046
|
+
fi
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
# Resolve one executed-file operand to a readable regular file, or fail.
|
|
1050
|
+
#
|
|
1051
|
+
# The variable expansions are LOCATE-ONLY and cannot widen a verdict: a wrong
|
|
1052
|
+
# guess simply fails to find a file, and an unresolved target is refused, not
|
|
1053
|
+
# passed. They are here because an agent's own scratch path arrives spelled with
|
|
1054
|
+
# one of them, and refusing every `bash "$TMPDIR/x.sh"` would be a wall with no
|
|
1055
|
+
# door. Nothing else is expanded — any remaining `$`, glob or brace is a target
|
|
1056
|
+
# this hook cannot evaluate.
|
|
1057
|
+
follow_locate() {
|
|
1058
|
+
local token="$1" home="${HOME:-}" tmp="${TMPDIR:-/tmp}" proj="${CLAUDE_PROJECT_DIR:-$PWD}" base
|
|
1059
|
+
local cwd="${follow_cwd:-$PWD}"
|
|
1060
|
+
follow_located=""
|
|
1061
|
+
tmp="${tmp%/}"
|
|
1062
|
+
[ -n "$tmp" ] || tmp="/tmp"
|
|
1063
|
+
# The tilde arms below are LITERAL text: this is the unexpanded spelling the
|
|
1064
|
+
# hook was handed, and turning it into a path is the whole point of the arm.
|
|
1065
|
+
# shellcheck disable=SC2088
|
|
1066
|
+
case "$token" in
|
|
1067
|
+
'~') token="$home" ;;
|
|
1068
|
+
'~/'*) token="$home/${token#'~/'}" ;;
|
|
1069
|
+
'$TMPDIR' | '$TMPDIR'/*) token="$tmp${token#'$TMPDIR'}" ;;
|
|
1070
|
+
'${TMPDIR}' | '${TMPDIR}'/*) token="$tmp${token#'${TMPDIR}'}" ;;
|
|
1071
|
+
'$HOME' | '$HOME'/*) token="$home${token#'$HOME'}" ;;
|
|
1072
|
+
'${HOME}' | '${HOME}'/*) token="$home${token#'${HOME}'}" ;;
|
|
1073
|
+
'$PWD' | '$PWD'/*)
|
|
1074
|
+
[ "$follow_cwd_unknown" -eq 0 ] || return 1
|
|
1075
|
+
token="$cwd${token#'$PWD'}"
|
|
1076
|
+
;;
|
|
1077
|
+
'${PWD}' | '${PWD}'/*)
|
|
1078
|
+
[ "$follow_cwd_unknown" -eq 0 ] || return 1
|
|
1079
|
+
token="$cwd${token#'${PWD}'}"
|
|
1080
|
+
;;
|
|
1081
|
+
'$CLAUDE_PROJECT_DIR' | '$CLAUDE_PROJECT_DIR'/*) token="$proj${token#'$CLAUDE_PROJECT_DIR'}" ;;
|
|
1082
|
+
'${CLAUDE_PROJECT_DIR}' | '${CLAUDE_PROJECT_DIR}'/*) token="$proj${token#'${CLAUDE_PROJECT_DIR}'}" ;;
|
|
1083
|
+
esac
|
|
1084
|
+
case "$token" in
|
|
1085
|
+
"" | *'$'* | *'*'* | *'?'* | *'`'* | *'{'* | *'['*) return 1 ;;
|
|
1086
|
+
/*)
|
|
1087
|
+
[ -f "$token" ] && [ -r "$token" ] || return 1
|
|
1088
|
+
follow_located="$token"
|
|
1089
|
+
return 0
|
|
1090
|
+
;;
|
|
1091
|
+
esac
|
|
1092
|
+
# Past this point the token is RELATIVE, so which directory it is resolved
|
|
1093
|
+
# from decides which file the verdict is about.
|
|
1094
|
+
if [ "$follow_cwd_unknown" -eq 1 ]; then
|
|
1095
|
+
return 1
|
|
1096
|
+
fi
|
|
1097
|
+
if [ -n "$follow_cwd" ]; then
|
|
1098
|
+
# A literal `cd` names the directory the shell will be in. It is the only
|
|
1099
|
+
# candidate — falling back to `$PWD` here is what made the guard report
|
|
1100
|
+
# confidently about a same-named file in another checkout.
|
|
1101
|
+
[ -f "$follow_cwd/$token" ] && [ -r "$follow_cwd/$token" ] || return 1
|
|
1102
|
+
follow_located="$follow_cwd/$token"
|
|
1103
|
+
return 0
|
|
1104
|
+
fi
|
|
1105
|
+
while IFS= read -r base; do
|
|
1106
|
+
[ -n "$base" ] || continue
|
|
1107
|
+
if [ -f "$base/$token" ] && [ -r "$base/$token" ]; then
|
|
1108
|
+
follow_located="$base/$token"
|
|
1109
|
+
return 0
|
|
1110
|
+
fi
|
|
1111
|
+
done <<<"$PWD"$'\n'"$proj"
|
|
1112
|
+
return 1
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
# Read one executed file into the text the guards will scan, then follow what IT
|
|
1116
|
+
# executes, to a bounded depth. Full-line comments are dropped: a commented line
|
|
1117
|
+
# is never executed, so scanning it can only manufacture a false refusal.
|
|
1118
|
+
follow_take() {
|
|
1119
|
+
local path="$1" depth="$2" size="" content="" rm_content="${3-}"
|
|
1120
|
+
local invocation_kind=child
|
|
1121
|
+
if [ "$#" -eq 3 ]; then invocation_kind="$3"; fi
|
|
1122
|
+
case "$follow_paths" in
|
|
1123
|
+
*$'\n'"$path"$'\n'*)
|
|
1124
|
+
# Sourcing executes in the caller each time; a read cache cannot erase
|
|
1125
|
+
# its directory changes or newly defined functions.
|
|
1126
|
+
[ "$invocation_kind" = source ] || return 0
|
|
1127
|
+
;;
|
|
1128
|
+
esac
|
|
1129
|
+
follow_paths="$follow_paths$path"$'\n'
|
|
1130
|
+
follow_count=$((follow_count + 1))
|
|
1131
|
+
if [ "$follow_count" -gt "$FOLLOW_MAX_FILES" ]; then
|
|
1132
|
+
follow_refuse "$path" "this command executes more files than the guard will inspect (${FOLLOW_MAX_FILES})"
|
|
1133
|
+
fi
|
|
1134
|
+
size="$(wc -c <"$path" 2>/dev/null | tr -cd '0-9')" || size=""
|
|
1135
|
+
case "$size" in
|
|
1136
|
+
"") follow_refuse "$path" "its size could not be determined" ;;
|
|
1137
|
+
esac
|
|
1138
|
+
if [ "$size" -gt "$FOLLOW_MAX_BYTES" ]; then
|
|
1139
|
+
follow_refuse "$path" "it is larger than the ${FOLLOW_MAX_BYTES}-byte inspection cap"
|
|
1140
|
+
fi
|
|
1141
|
+
follow_bytes=$((follow_bytes + size))
|
|
1142
|
+
if [ "$follow_bytes" -gt "$FOLLOW_MAX_TOTAL_BYTES" ]; then
|
|
1143
|
+
follow_refuse "$path" "the files this command executes total more than the ${FOLLOW_MAX_TOTAL_BYTES}-byte inspection budget"
|
|
1144
|
+
fi
|
|
1145
|
+
if [ "$#" -eq 4 ]; then
|
|
1146
|
+
# Scan the same complete bytes whose digest was authenticated, not a
|
|
1147
|
+
# second file read that could pair changed source with the cleanup view.
|
|
1148
|
+
content="$(printf '%s' "$4" | sed 's/^[[:space:]]*#.*$//')" || follow_refuse "$path" "authenticated source could not be read"
|
|
1149
|
+
else
|
|
1150
|
+
content="$(LC_ALL=C tr -d '\000' <"$path" | sed 's/^[[:space:]]*#.*$//')" \
|
|
1151
|
+
|| follow_refuse "$path" "it could not be read"
|
|
1152
|
+
fi
|
|
1153
|
+
follow_names="$follow_names $path"
|
|
1154
|
+
followed_text="$followed_text"$'\n'"$content"
|
|
1155
|
+
if [ "$#" -ne 4 ]; then
|
|
1156
|
+
rm_content="$content"
|
|
1157
|
+
else
|
|
1158
|
+
rm_content="$(printf '%s' "$rm_content" | sed 's/^[[:space:]]*#.*$//')" || follow_refuse "$path" "supervisor cleanup view could not be read"
|
|
1159
|
+
fi
|
|
1160
|
+
followed_rm_text="$followed_rm_text"$'\n'"$rm_content"
|
|
1161
|
+
if [ "$depth" -lt "$FOLLOW_MAX_DEPTH" ]; then
|
|
1162
|
+
# Child scripts inherit cwd but cannot change their parent. A sourced
|
|
1163
|
+
# script runs in the caller and keeps both cwd and function definitions.
|
|
1164
|
+
local saved_cwd="$follow_cwd" saved_unknown="$follow_cwd_unknown"
|
|
1165
|
+
if [ "$#" -eq 4 ]; then
|
|
1166
|
+
# Only follow_supervisor reaches this form, after exact byte identity and
|
|
1167
|
+
# the public argv profile were authenticated. Its owned bootstrap is
|
|
1168
|
+
# already scanned in full by every text policy; its user payload is
|
|
1169
|
+
# followed separately by follow_supervisor. Re-expanding all bootstrap
|
|
1170
|
+
# functions would re-interpret its internal protocol, not inspect a new
|
|
1171
|
+
# user-controlled file, and multiplied its native hook time fourfold.
|
|
1172
|
+
follow_scan "$content" "$((depth + 1))" 0 1
|
|
1173
|
+
else
|
|
1174
|
+
follow_scan "$content" "$((depth + 1))" 0 0 "$invocation_kind"
|
|
1175
|
+
fi
|
|
1176
|
+
if [ "$invocation_kind" != source ]; then
|
|
1177
|
+
follow_cwd="$saved_cwd"
|
|
1178
|
+
follow_cwd_unknown="$saved_unknown"
|
|
1179
|
+
fi
|
|
1180
|
+
fi
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
# An operand a command EXECUTES. Unresolvable at the top level is a refusal;
|
|
1184
|
+
# nested inside an already-followed script it is the documented residual above.
|
|
1185
|
+
follow_target() {
|
|
1186
|
+
local token="$1" depth="$2" path=""
|
|
1187
|
+
local invocation_kind="${3:-child}"
|
|
1188
|
+
[ -n "$token" ] || return 0
|
|
1189
|
+
if [ "$follow_cwd_unknown" -ne 0 ]; then
|
|
1190
|
+
case "$token" in
|
|
1191
|
+
/* | '~/'*) ;;
|
|
1192
|
+
*) follow_refuse "$token" "the execution directory is unknown" ;;
|
|
1193
|
+
esac
|
|
1194
|
+
fi
|
|
1195
|
+
# Entry authority belongs to each invocation, not the file-read cache. Only
|
|
1196
|
+
# follow_supervisor may admit the exact public interpreter/argv profile.
|
|
1197
|
+
case "$token" in
|
|
1198
|
+
*lisa-scratch-run.sh) follow_refuse "$token" "supervisor requires its authenticated public interpreter entry" ;;
|
|
1199
|
+
esac
|
|
1200
|
+
if ! follow_locate "$token"; then
|
|
1201
|
+
if [ "$depth" -eq 0 ]; then
|
|
1202
|
+
follow_refuse "$token" "no readable file could be resolved for it"
|
|
1203
|
+
fi
|
|
1204
|
+
return 0
|
|
1205
|
+
fi
|
|
1206
|
+
path="$follow_located"
|
|
1207
|
+
follow_take "$path" "$depth" "$invocation_kind"
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
# A command word that IS the executable (`./deploy.sh`, `/tmp/x.sh`). Followed
|
|
1211
|
+
# only when the file's shebang names a shell: a compiled binary or a Python/Node
|
|
1212
|
+
# script is not text these patterns can classify, and `/usr/bin/git` sitting at
|
|
1213
|
+
# a command position must stay an ordinary command.
|
|
1214
|
+
#
|
|
1215
|
+
# This arm alone does NOT fail closed on an unresolvable token, and the line is
|
|
1216
|
+
# deliberate. Elsewhere the command TEXT declares that a file is being executed
|
|
1217
|
+
# — `bash <file>` says so whether or not the file can be read — so "I cannot
|
|
1218
|
+
# read it" is a real inability to classify a stated execution. Here the only
|
|
1219
|
+
# evidence that the token is a script at all is the shebang inside it, so an
|
|
1220
|
+
# unreadable token is not an unclassifiable execution; it is a command word this
|
|
1221
|
+
# hook cannot see, and a shell that cannot see it cannot run it either. Refusing
|
|
1222
|
+
# on shape alone would deny `/usr/bin/charm x` and `./scripts/confirm -rf /`,
|
|
1223
|
+
# both of which the guard-parity matrix pins as ALLOW (#1960 PR-A2/PR-A3).
|
|
1224
|
+
# Accepted residual: a shebang script whose relative path resolves only from a
|
|
1225
|
+
# working directory this hook does not share is not followed.
|
|
1226
|
+
follow_direct() {
|
|
1227
|
+
local token="$1" depth="$2" path="" first="" raw="" char="" k=0
|
|
1228
|
+
local LC_ALL=C
|
|
1229
|
+
if ! follow_locate "$token"; then
|
|
1230
|
+
return 0
|
|
1231
|
+
fi
|
|
1232
|
+
path="$follow_located"
|
|
1233
|
+
case "$path" in
|
|
1234
|
+
*lisa-scratch-run.sh) follow_refuse "$path" "supervisor requires its authenticated public interpreter entry" ;;
|
|
1235
|
+
esac
|
|
1236
|
+
IFS= read -r -n 256 raw <"$path" 2>/dev/null || true
|
|
1237
|
+
while [ "$k" -lt "${#raw}" ]; do
|
|
1238
|
+
char="${raw:k:1}"
|
|
1239
|
+
case "$char" in [[:print:]]) first="$first$char" ;; esac
|
|
1240
|
+
k=$((k + 1))
|
|
1241
|
+
done
|
|
1242
|
+
case "$first" in
|
|
1243
|
+
'#!'*[/[:space:]]bash* | '#!'*[/[:space:]]sh* | '#!'*[/[:space:]]zsh* \
|
|
1244
|
+
| '#!'*[/[:space:]]ksh* | '#!'*[/[:space:]]dash* | '#!'*[/[:space:]]ash*) ;;
|
|
1245
|
+
*) return 0 ;;
|
|
1246
|
+
esac
|
|
1247
|
+
follow_take "$path" "$depth"
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
# Index of the next non-option token after $1, or $2 when there is none.
|
|
1251
|
+
follow_next_operand() {
|
|
1252
|
+
local from="$1" upto="$2" j
|
|
1253
|
+
j=$((from + 1))
|
|
1254
|
+
while [ "$j" -lt "$upto" ]; do
|
|
1255
|
+
follow_unwrap "${toks[j]}"
|
|
1256
|
+
case "$follow_unwrapped" in
|
|
1257
|
+
-*) j=$((j + 1)) ;;
|
|
1258
|
+
*) break ;;
|
|
1259
|
+
esac
|
|
1260
|
+
done
|
|
1261
|
+
follow_operand_index="$j"
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
# Resolve a piped file in the PRODUCER's directory, which can differ from the
|
|
1265
|
+
# interpreter's after a subshell closes. Preserve unknown-state provenance too.
|
|
1266
|
+
follow_pipe_target() {
|
|
1267
|
+
local saved_cwd="$follow_cwd" saved_unknown="$follow_cwd_unknown"
|
|
1268
|
+
follow_cwd="$3"
|
|
1269
|
+
follow_cwd_unknown="$4"
|
|
1270
|
+
follow_target "$1" "$2"
|
|
1271
|
+
follow_cwd="$saved_cwd"
|
|
1272
|
+
follow_cwd_unknown="$saved_unknown"
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
# Carry branch uncertainty into sourced declarations as well as cwd changes.
|
|
1276
|
+
# The local projection lasts only for this source invocation, not later scans.
|
|
1277
|
+
follow_source_target() {
|
|
1278
|
+
local follow_source_flow_unknown="$3"
|
|
1279
|
+
follow_target "$1" "$2" source
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
# Extract the literal -c operand without evaluating it or treating positional
|
|
1283
|
+
# arguments after its closing quote as commands. Prefix indexing matches the
|
|
1284
|
+
# whitespace tokens used by follow_scan; the operand keeps its internal bytes.
|
|
1285
|
+
follow_inline() {
|
|
1286
|
+
local stmt="$1" from="$2" depth="$3" body
|
|
1287
|
+
local follow_inline_depth="${follow_inline_depth:-0}"
|
|
1288
|
+
[ "$follow_inline_depth" -lt "$FOLLOW_MAX_FUNCTION_DEPTH" ] || follow_refuse "-c" "inline shells exceed the inspection depth"
|
|
1289
|
+
follow_inline_depth=$((follow_inline_depth + 1))
|
|
1290
|
+
body="$(LC_ALL=C SN_INLINE_TEXT="$stmt" SN_INLINE_FROM="$from" awk '
|
|
1291
|
+
BEGIN {
|
|
1292
|
+
s = ENVIRON["SN_INLINE_TEXT"]; n = length(s); i = 1;
|
|
1293
|
+
for (word = 0; word < ENVIRON["SN_INLINE_FROM"]; word++) {
|
|
1294
|
+
while (i <= n && substr(s, i, 1) ~ /[[:space:]]/) { i++ }
|
|
1295
|
+
while (i <= n && substr(s, i, 1) !~ /[[:space:]]/) { i++ }
|
|
1296
|
+
}
|
|
1297
|
+
while (i <= n && substr(s, i, 1) ~ /[[:space:]]/) { i++ }
|
|
1298
|
+
q = substr(s, i, 1);
|
|
1299
|
+
if (q == "\047" || q == "\"") {
|
|
1300
|
+
start = ++i; esc = 0;
|
|
1301
|
+
for (; i <= n; i++) {
|
|
1302
|
+
c = substr(s, i, 1);
|
|
1303
|
+
if (esc) { esc = 0; continue }
|
|
1304
|
+
if (c == "\\" && q == "\"") { esc = 1; continue }
|
|
1305
|
+
if (c == q) {
|
|
1306
|
+
# Adjacent quoted/unquoted fragments are the same shell operand.
|
|
1307
|
+
# Refuse unsupported concatenation instead of scanning a prefix.
|
|
1308
|
+
if (i < n && substr(s, i + 1, 1) !~ /[[:space:]]/) { exit 1 }
|
|
1309
|
+
printf "%s", substr(s, start, i - start); exit;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
exit 1;
|
|
1313
|
+
}
|
|
1314
|
+
start = i;
|
|
1315
|
+
while (i <= n && substr(s, i, 1) !~ /[[:space:]]/) {
|
|
1316
|
+
c = substr(s, i, 1);
|
|
1317
|
+
if (c == "\047" || c == "\"" || c == "\\") { exit 1 }
|
|
1318
|
+
i++;
|
|
1319
|
+
}
|
|
1320
|
+
printf "%s", substr(s, start, i - start);
|
|
1321
|
+
}
|
|
1322
|
+
')" || follow_refuse "-c" "its command string could not be determined"
|
|
1323
|
+
local saved_cwd="$follow_cwd" saved_unknown="$follow_cwd_unknown"
|
|
1324
|
+
follow_scan "$body" "$depth"
|
|
1325
|
+
follow_cwd="$saved_cwd"
|
|
1326
|
+
follow_cwd_unknown="$saved_unknown"
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
# Resolve literal descriptor duplication without running the proposed shell.
|
|
1330
|
+
# Compound input is established before its body; output is projected when the
|
|
1331
|
+
# producer reaches the pipe. Unknown descriptors refuse rather than drop bytes.
|
|
1332
|
+
follow_redirect_io() {
|
|
1333
|
+
local statement="$1" mode="$2" result
|
|
1334
|
+
result="$(SN_COMPOUND_REDIRECT="$statement" SN_DESCRIPTOR_MAP="$follow_descriptor_map" SN_REDIRECT_CWD="$follow_cwd" SN_REDIRECT_UNKNOWN="$follow_cwd_unknown" python3 - "$mode" <<'PY'
|
|
1335
|
+
import json
|
|
1336
|
+
import os
|
|
1337
|
+
import sys
|
|
1338
|
+
try:
|
|
1339
|
+
mode = sys.argv[1]
|
|
1340
|
+
text = os.environ['SN_COMPOUND_REDIRECT']
|
|
1341
|
+
tokens = []
|
|
1342
|
+
cursor = 0
|
|
1343
|
+
# Keep syntax provenance and byte adjacency: quoted/escaped > is data,
|
|
1344
|
+
# and `cat 2 >&2` names a file rather than redirecting descriptor 2.
|
|
1345
|
+
while cursor < len(text):
|
|
1346
|
+
if text[cursor].isspace():
|
|
1347
|
+
cursor += 1
|
|
1348
|
+
continue
|
|
1349
|
+
start = cursor
|
|
1350
|
+
syntax = text[cursor] in '<>&'
|
|
1351
|
+
plain = True
|
|
1352
|
+
value = ''
|
|
1353
|
+
quote = ''
|
|
1354
|
+
if syntax:
|
|
1355
|
+
while cursor < len(text) and text[cursor] in '<>&':
|
|
1356
|
+
value += text[cursor]; cursor += 1
|
|
1357
|
+
else:
|
|
1358
|
+
while cursor < len(text):
|
|
1359
|
+
char = text[cursor]
|
|
1360
|
+
if not quote and (char.isspace() or char in '<>&'):
|
|
1361
|
+
break
|
|
1362
|
+
if char == '\\' and quote != "'":
|
|
1363
|
+
plain = False
|
|
1364
|
+
cursor += 1
|
|
1365
|
+
if cursor == len(text):
|
|
1366
|
+
raise ValueError('trailing escape')
|
|
1367
|
+
if text[cursor] == '\n':
|
|
1368
|
+
cursor += 1
|
|
1369
|
+
continue
|
|
1370
|
+
if quote == '"' and text[cursor] not in '$`"\\':
|
|
1371
|
+
value += '\\'
|
|
1372
|
+
value += text[cursor]; cursor += 1
|
|
1373
|
+
continue
|
|
1374
|
+
if char in ('"', "'"):
|
|
1375
|
+
if not quote:
|
|
1376
|
+
quote = char; plain = False; cursor += 1
|
|
1377
|
+
continue
|
|
1378
|
+
if quote == char:
|
|
1379
|
+
quote = ''; cursor += 1
|
|
1380
|
+
continue
|
|
1381
|
+
value += char; cursor += 1
|
|
1382
|
+
if quote:
|
|
1383
|
+
raise ValueError('unterminated quote')
|
|
1384
|
+
tokens.append((value, syntax, plain, start, cursor))
|
|
1385
|
+
descriptors = {int(fd): value for fd, value in json.loads(os.environ['SN_DESCRIPTOR_MAP']).items()}
|
|
1386
|
+
operators = ('<', '>', '>>', '<>', '<&', '>&', '&>', '&>>')
|
|
1387
|
+
index = 0
|
|
1388
|
+
while index < len(tokens):
|
|
1389
|
+
fd = None
|
|
1390
|
+
token, syntax, plain, start, end = tokens[index]
|
|
1391
|
+
if token.isdigit() and plain and index + 1 < len(tokens) and tokens[index + 1][1] and end == tokens[index + 1][3]:
|
|
1392
|
+
fd = int(token); index += 1
|
|
1393
|
+
token, syntax, plain, start, end = tokens[index]
|
|
1394
|
+
if not syntax or token not in operators:
|
|
1395
|
+
if mode == 'cat':
|
|
1396
|
+
index += 1
|
|
1397
|
+
continue
|
|
1398
|
+
sys.exit(2)
|
|
1399
|
+
if index + 1 >= len(tokens):
|
|
1400
|
+
sys.exit(2)
|
|
1401
|
+
operator = token
|
|
1402
|
+
operand, operand_syntax, _, _, _ = tokens[index + 1]
|
|
1403
|
+
if operand_syntax:
|
|
1404
|
+
sys.exit(2)
|
|
1405
|
+
index += 2
|
|
1406
|
+
if fd is None:
|
|
1407
|
+
fd = 0 if operator.startswith('<') else 1
|
|
1408
|
+
if operator in ('<&', '>&'):
|
|
1409
|
+
if operand == '-':
|
|
1410
|
+
descriptors[fd] = {'kind': 'closed'}
|
|
1411
|
+
elif operand.isdigit():
|
|
1412
|
+
descriptors[fd] = descriptors.get(int(operand), {'kind': 'unknown'})
|
|
1413
|
+
else:
|
|
1414
|
+
sys.exit(2)
|
|
1415
|
+
else:
|
|
1416
|
+
descriptors[fd] = {'kind': 'file', 'path': operand, 'cwd': os.environ['SN_REDIRECT_CWD'], 'unknown': int(os.environ['SN_REDIRECT_UNKNOWN'])}
|
|
1417
|
+
if operator.startswith('&>'):
|
|
1418
|
+
descriptors[2] = descriptors[fd]
|
|
1419
|
+
output = descriptors[1]['kind']
|
|
1420
|
+
if output == 'unknown' or descriptors[0]['kind'] == 'unknown':
|
|
1421
|
+
sys.exit(2)
|
|
1422
|
+
print(json.dumps({'input': descriptors[0], 'output': output, 'descriptors': descriptors}))
|
|
1423
|
+
except (ValueError, OSError):
|
|
1424
|
+
sys.exit(2)
|
|
1425
|
+
PY
|
|
1426
|
+
)" || follow_refuse "compound redirection" "its input or pipeline output could not be determined"
|
|
1427
|
+
follow_redirect_input="$(printf '%s' "$result" | jq -er '.input.kind')" || follow_refuse "compound redirection" "invalid input projection"
|
|
1428
|
+
follow_redirect_path="$(printf '%s' "$result" | jq -r '.input.path // ""')" || follow_refuse "compound redirection" "invalid input path"
|
|
1429
|
+
follow_redirect_map="$(printf '%s' "$result" | jq -ce '.descriptors')" || follow_refuse "compound redirection" "invalid descriptor projection"
|
|
1430
|
+
follow_redirect_input_cwd="$(printf '%s' "$result" | jq -r '.input.cwd // ""')" || follow_refuse "compound redirection" "invalid input cwd"
|
|
1431
|
+
follow_redirect_input_unknown="$(printf '%s' "$result" | jq -r '.input.unknown // 1')" || follow_refuse "compound redirection" "invalid input uncertainty"
|
|
1432
|
+
follow_redirect_output="$(printf '%s' "$result" | jq -er '.output')" || follow_refuse "compound redirection" "invalid output projection"
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
# Walk one text for the files it executes. Runs in the PARENT shell, never in a
|
|
1436
|
+
# command substitution: follow_refuse() must be able to deny, and an exit inside
|
|
1437
|
+
# a subshell ends that subshell rather than this hook — the mistake documented
|
|
1438
|
+
# on the rm segmentation below.
|
|
1439
|
+
follow_scan() {
|
|
1440
|
+
local text="$1" depth="$2"
|
|
1441
|
+
local function_call_depth="${3:-0}"
|
|
1442
|
+
local authenticated_bootstrap="${4:-0}"
|
|
1443
|
+
local invocation_kind="${5:-child}"
|
|
1444
|
+
local function_flow_unknown="${6:-0}" function_lookup_allowed statement_conditional function_conditional=0
|
|
1445
|
+
local and_cwd="" and_unknown=1 and_ready=0 join_cwd join_unknown
|
|
1446
|
+
if [ "$invocation_kind" = source ]; then
|
|
1447
|
+
function_flow_unknown="${follow_source_flow_unknown:-0}"
|
|
1448
|
+
fi
|
|
1449
|
+
local return_scope_depth=-1
|
|
1450
|
+
local return_list_depth=-1
|
|
1451
|
+
local return_flow_unknown=0
|
|
1452
|
+
local list_depth=0 list_index
|
|
1453
|
+
local split_out line sep stmt t tj i j n cmd_pos eval_mode inline arg_exec
|
|
1454
|
+
local heredoc redirected pipe_cat prev_cat pending_operand stdin_mode
|
|
1455
|
+
local pipe_cwd pipe_unknown prev_cwd prev_unknown
|
|
1456
|
+
local follow_stdin_cat="${follow_stdin_cat:-}" follow_stdin_cwd="${follow_stdin_cwd:-}" follow_stdin_unknown="${follow_stdin_unknown:-1}"
|
|
1457
|
+
local follow_redirect_input follow_redirect_path follow_redirect_output follow_redirect_map follow_redirect_input_cwd follow_redirect_input_unknown
|
|
1458
|
+
local follow_descriptor_map="${follow_descriptor_map:-}"
|
|
1459
|
+
if [ -z "$follow_descriptor_map" ]; then
|
|
1460
|
+
follow_descriptor_map='{"0":{"kind":"input"},"1":{"kind":"pipe"},"2":{"kind":"stderr"}}'
|
|
1461
|
+
fi
|
|
1462
|
+
local find_mode dispatch_pending
|
|
1463
|
+
local scope_depth=0 case_depth=0 function_name="" function_body="" function_index function_found
|
|
1464
|
+
local -a toks=()
|
|
1465
|
+
local -a cwd_scopes=() unknown_scopes=() cat_scopes=() cat_cwd_scopes=() cat_unknown_scopes=() function_scopes=() flow_scopes=() return_flows=()
|
|
1466
|
+
local -a list_cwds=() list_unknowns=() list_functions=() list_flows=() list_asyncs=() list_scope_depths=() list_return_flows=() brace_flows=()
|
|
1467
|
+
local -a brace_descriptor_maps=() scope_descriptor_maps=()
|
|
1468
|
+
local -a brace_stdin_cats=() brace_stdin_cwds=() brace_stdin_unknowns=() scope_stdin_cats=() scope_stdin_cwds=() scope_stdin_unknowns=()
|
|
1469
|
+
local -a case_cwds=() case_unknowns=() case_changes=()
|
|
1470
|
+
if [ "$function_call_depth" -eq 0 ] && [ "$invocation_kind" != source ]; then
|
|
1471
|
+
# Ordinary child shells do not inherit unexported functions. Sourcing and
|
|
1472
|
+
# function calls share the caller's definitions instead.
|
|
1473
|
+
local function_count=0
|
|
1474
|
+
local -a function_names=() function_bodies=() function_uncertainties=()
|
|
1475
|
+
fi
|
|
1476
|
+
split_out="$(follow_split "$text")" || follow_refuse "shell scope" "scope parsing exceeds the inspection budget"
|
|
1477
|
+
prev_cat=""
|
|
1478
|
+
while IFS= read -r line; do
|
|
1479
|
+
sep="${line%%$'\t'*}"
|
|
1480
|
+
stmt="${line#*$'\t'}"
|
|
1481
|
+
statement_conditional=0
|
|
1482
|
+
case "$sep" in '&' | '?') statement_conditional=1 ;; esac
|
|
1483
|
+
# A reached && successor observes the preceding successful command's cwd.
|
|
1484
|
+
# Scope/list events invalidate this projection; it must never carry a
|
|
1485
|
+
# child's or a background list's directory into another shell/list.
|
|
1486
|
+
case "$sep" in
|
|
1487
|
+
L | G | H | B | N | F | C | A | K | '(' | '[' | ')' | ']') and_ready=0 ;;
|
|
1488
|
+
esac
|
|
1489
|
+
if [ "$return_list_depth" -ge 0 ]; then
|
|
1490
|
+
case "$sep" in
|
|
1491
|
+
B) [ "$list_depth" -eq "$return_list_depth" ] || continue ;;
|
|
1492
|
+
'(' | '[' | ')' | ']' | G | H) ;;
|
|
1493
|
+
*) continue ;;
|
|
1494
|
+
esac
|
|
1495
|
+
fi
|
|
1496
|
+
if [ "$return_scope_depth" -ge 0 ]; then
|
|
1497
|
+
# A return in a child scope ends that child, not its enclosing function.
|
|
1498
|
+
# Keep only scope events until the matching close restores the parent.
|
|
1499
|
+
case "$sep" in '(' | '[' | ')' | ']' | G | H) ;; *) continue ;; esac
|
|
1500
|
+
fi
|
|
1501
|
+
case "$sep" in
|
|
1502
|
+
L)
|
|
1503
|
+
list_cwds[list_depth]="$follow_cwd"
|
|
1504
|
+
list_unknowns[list_depth]="$follow_cwd_unknown"
|
|
1505
|
+
list_functions[list_depth]=$function_count
|
|
1506
|
+
list_flows[list_depth]=$function_flow_unknown
|
|
1507
|
+
list_asyncs[list_depth]="$stmt"
|
|
1508
|
+
list_scope_depths[list_depth]=$scope_depth
|
|
1509
|
+
list_return_flows[list_depth]=$return_flow_unknown
|
|
1510
|
+
continue
|
|
1511
|
+
;;
|
|
1512
|
+
G)
|
|
1513
|
+
brace_flows[list_depth]=$function_flow_unknown
|
|
1514
|
+
brace_descriptor_maps[list_depth]="$follow_descriptor_map"
|
|
1515
|
+
brace_stdin_cats[list_depth]="$follow_stdin_cat"
|
|
1516
|
+
brace_stdin_cwds[list_depth]="$follow_stdin_cwd"
|
|
1517
|
+
brace_stdin_unknowns[list_depth]="$follow_stdin_unknown"
|
|
1518
|
+
if [ "$stmt" = "|" ]; then
|
|
1519
|
+
follow_stdin_cat="$prev_cat"; follow_stdin_cwd="${prev_cwd:-}"; follow_stdin_unknown="${prev_unknown:-1}"
|
|
1520
|
+
fi
|
|
1521
|
+
case "$stmt" in '&' | '?') function_flow_unknown=1 ;; esac
|
|
1522
|
+
list_depth=$((list_depth + 1)); continue
|
|
1523
|
+
;;
|
|
1524
|
+
H)
|
|
1525
|
+
list_depth=$((list_depth - 1))
|
|
1526
|
+
function_flow_unknown="${brace_flows[list_depth]}"
|
|
1527
|
+
follow_descriptor_map="${brace_descriptor_maps[list_depth]}"
|
|
1528
|
+
follow_stdin_cat="${brace_stdin_cats[list_depth]}"
|
|
1529
|
+
follow_stdin_cwd="${brace_stdin_cwds[list_depth]}"
|
|
1530
|
+
follow_stdin_unknown="${brace_stdin_unknowns[list_depth]}"
|
|
1531
|
+
# A conditional return may exit this function/source before commands
|
|
1532
|
+
# after the brace. Unlike a child return, that uncertainty persists.
|
|
1533
|
+
if [ "$return_flow_unknown" -ne 0 ]; then function_flow_unknown=1; fi
|
|
1534
|
+
continue
|
|
1535
|
+
;;
|
|
1536
|
+
B)
|
|
1537
|
+
# A single & runs the whole AND/OR list asynchronously. Its cwd and
|
|
1538
|
+
# definitions cannot escape into the parent's next list.
|
|
1539
|
+
follow_cwd="${list_cwds[list_depth]}"
|
|
1540
|
+
follow_cwd_unknown="${list_unknowns[list_depth]}"
|
|
1541
|
+
function_flow_unknown="${list_flows[list_depth]}"
|
|
1542
|
+
return_flow_unknown="${list_return_flows[list_depth]}"
|
|
1543
|
+
return_list_depth=-1
|
|
1544
|
+
while [ "$function_count" -gt "${list_functions[list_depth]}" ]; do
|
|
1545
|
+
function_count=$((function_count - 1))
|
|
1546
|
+
function_index=$function_count
|
|
1547
|
+
unset 'function_names[function_index]' 'function_bodies[function_index]' 'function_uncertainties[function_index]'
|
|
1548
|
+
done
|
|
1549
|
+
continue
|
|
1550
|
+
;;
|
|
1551
|
+
N)
|
|
1552
|
+
function_name="${stmt#*$'\t'}"; function_body=""
|
|
1553
|
+
function_conditional="$function_flow_unknown"
|
|
1554
|
+
case "${stmt%%$'\t'*}" in '&' | '?') function_conditional=1 ;; esac
|
|
1555
|
+
continue
|
|
1556
|
+
;;
|
|
1557
|
+
D)
|
|
1558
|
+
if [ "$authenticated_bootstrap" -eq 0 ]; then
|
|
1559
|
+
function_body="$function_body$stmt"$'\n'
|
|
1560
|
+
fi
|
|
1561
|
+
continue
|
|
1562
|
+
;;
|
|
1563
|
+
F)
|
|
1564
|
+
[ "$authenticated_bootstrap" -eq 0 ] || continue
|
|
1565
|
+
function_index=$function_count
|
|
1566
|
+
function_names[function_index]="$function_name"
|
|
1567
|
+
function_bodies[function_index]="$function_body"
|
|
1568
|
+
function_uncertainties[function_index]="$function_conditional"
|
|
1569
|
+
function_count=$((function_count + 1))
|
|
1570
|
+
continue
|
|
1571
|
+
;;
|
|
1572
|
+
C)
|
|
1573
|
+
function_flow_unknown=1
|
|
1574
|
+
case_cwds[case_depth]="$follow_cwd"
|
|
1575
|
+
case_unknowns[case_depth]="$follow_cwd_unknown"
|
|
1576
|
+
case_changes[case_depth]=0
|
|
1577
|
+
case_depth=$((case_depth + 1))
|
|
1578
|
+
continue
|
|
1579
|
+
;;
|
|
1580
|
+
A | K)
|
|
1581
|
+
function_index=$((case_depth - 1))
|
|
1582
|
+
if [ "$follow_cwd" != "${case_cwds[function_index]}" ] || [ "$follow_cwd_unknown" != "${case_unknowns[function_index]}" ]; then
|
|
1583
|
+
case_changes[function_index]=1
|
|
1584
|
+
fi
|
|
1585
|
+
follow_cwd="${case_cwds[function_index]}"
|
|
1586
|
+
follow_cwd_unknown="${case_unknowns[function_index]}"
|
|
1587
|
+
if [ "$sep" = K ]; then
|
|
1588
|
+
if [ "${case_changes[function_index]}" -ne 0 ]; then
|
|
1589
|
+
follow_cwd=""; follow_cwd_unknown=1
|
|
1590
|
+
fi
|
|
1591
|
+
case_depth=$((case_depth - 1))
|
|
1592
|
+
fi
|
|
1593
|
+
continue
|
|
1594
|
+
;;
|
|
1595
|
+
'(' | '[')
|
|
1596
|
+
cwd_scopes[scope_depth]="$follow_cwd"
|
|
1597
|
+
unknown_scopes[scope_depth]="$follow_cwd_unknown"
|
|
1598
|
+
cat_scopes[scope_depth]="$prev_cat"
|
|
1599
|
+
cat_cwd_scopes[scope_depth]="${prev_cwd:-}"
|
|
1600
|
+
cat_unknown_scopes[scope_depth]="${prev_unknown:-1}"
|
|
1601
|
+
scope_descriptor_maps[scope_depth]="$follow_descriptor_map"
|
|
1602
|
+
function_scopes[scope_depth]=$function_count
|
|
1603
|
+
flow_scopes[scope_depth]=$function_flow_unknown
|
|
1604
|
+
return_flows[scope_depth]=$return_flow_unknown
|
|
1605
|
+
scope_stdin_cats[scope_depth]="$follow_stdin_cat"
|
|
1606
|
+
scope_stdin_cwds[scope_depth]="$follow_stdin_cwd"
|
|
1607
|
+
scope_stdin_unknowns[scope_depth]="$follow_stdin_unknown"
|
|
1608
|
+
if [ "$stmt" = "|" ] || [ "$stmt" = "|+" ]; then
|
|
1609
|
+
follow_stdin_cat="$prev_cat"; follow_stdin_cwd="${prev_cwd:-}"; follow_stdin_unknown="${prev_unknown:-1}"
|
|
1610
|
+
follow_descriptor_map="$(printf '%s' "$follow_descriptor_map" | jq -ce '.["0"] = {kind:"input"}')" || follow_refuse "pipeline input" "its descriptor could not be established"
|
|
1611
|
+
fi
|
|
1612
|
+
case "$stmt" in
|
|
1613
|
+
*+)
|
|
1614
|
+
follow_descriptor_map="$(printf '%s' "$follow_descriptor_map" | jq -ce '.["1"] = {kind:"pipe"}')" || follow_refuse "pipeline output" "its descriptor could not be established"
|
|
1615
|
+
;;
|
|
1616
|
+
esac
|
|
1617
|
+
scope_depth=$((scope_depth + 1))
|
|
1618
|
+
list_depth=$((list_depth + 1))
|
|
1619
|
+
continue
|
|
1620
|
+
;;
|
|
1621
|
+
')' | ']')
|
|
1622
|
+
scope_depth=$((scope_depth - 1))
|
|
1623
|
+
list_depth=$((list_depth - 1))
|
|
1624
|
+
follow_cwd="${cwd_scopes[scope_depth]}"
|
|
1625
|
+
follow_descriptor_map="${scope_descriptor_maps[scope_depth]}"
|
|
1626
|
+
follow_cwd_unknown="${unknown_scopes[scope_depth]}"
|
|
1627
|
+
function_flow_unknown="${flow_scopes[scope_depth]}"
|
|
1628
|
+
return_flow_unknown="${return_flows[scope_depth]}"
|
|
1629
|
+
follow_stdin_cat="${scope_stdin_cats[scope_depth]}"
|
|
1630
|
+
follow_stdin_cwd="${scope_stdin_cwds[scope_depth]}"
|
|
1631
|
+
follow_stdin_unknown="${scope_stdin_unknowns[scope_depth]}"
|
|
1632
|
+
while [ "$function_count" -gt "${function_scopes[scope_depth]}" ]; do
|
|
1633
|
+
function_count=$((function_count - 1))
|
|
1634
|
+
function_index=$function_count
|
|
1635
|
+
unset 'function_names[function_index]' 'function_bodies[function_index]' 'function_uncertainties[function_index]'
|
|
1636
|
+
done
|
|
1637
|
+
if [ "$sep" = ']' ]; then
|
|
1638
|
+
prev_cat="${cat_scopes[scope_depth]}"
|
|
1639
|
+
prev_cwd="${cat_cwd_scopes[scope_depth]}"
|
|
1640
|
+
prev_unknown="${cat_unknown_scopes[scope_depth]}"
|
|
1641
|
+
fi
|
|
1642
|
+
if [ "$return_scope_depth" -ge 0 ] && [ "$scope_depth" -lt "$return_scope_depth" ]; then
|
|
1643
|
+
return_scope_depth=-1
|
|
1644
|
+
fi
|
|
1645
|
+
continue
|
|
1646
|
+
;;
|
|
1647
|
+
I)
|
|
1648
|
+
follow_redirect_io "$stmt" compound
|
|
1649
|
+
follow_descriptor_map="$follow_redirect_map"
|
|
1650
|
+
case "$follow_redirect_input" in
|
|
1651
|
+
file)
|
|
1652
|
+
follow_stdin_cat="$follow_redirect_path"
|
|
1653
|
+
follow_stdin_cwd="$follow_redirect_input_cwd"; follow_stdin_unknown="$follow_redirect_input_unknown"
|
|
1654
|
+
;;
|
|
1655
|
+
closed) follow_stdin_cat="" ;;
|
|
1656
|
+
input) ;;
|
|
1657
|
+
*) follow_refuse "compound redirection" "its input source could not be determined" ;;
|
|
1658
|
+
esac
|
|
1659
|
+
continue
|
|
1660
|
+
;;
|
|
1661
|
+
R)
|
|
1662
|
+
# The whole compound tail was applied before its body by I. Its cat
|
|
1663
|
+
# producer already records whether its effective output reaches pipe.
|
|
1664
|
+
continue
|
|
1665
|
+
;;
|
|
1666
|
+
esac
|
|
1667
|
+
join_cwd="$follow_cwd"; join_unknown="$follow_cwd_unknown"
|
|
1668
|
+
if [ "$sep" = '&' ] && [ "$and_ready" -eq 1 ]; then
|
|
1669
|
+
follow_cwd="$and_cwd"; follow_cwd_unknown="$and_unknown"
|
|
1670
|
+
fi
|
|
1671
|
+
and_ready=0
|
|
1672
|
+
pipe_cat="$follow_stdin_cat"
|
|
1673
|
+
pipe_cwd="$follow_stdin_cwd"
|
|
1674
|
+
pipe_unknown="$follow_stdin_unknown"
|
|
1675
|
+
if [ "$sep" = "|" ]; then
|
|
1676
|
+
pipe_cat="$prev_cat"
|
|
1677
|
+
pipe_cwd="${prev_cwd:-}"
|
|
1678
|
+
pipe_unknown="${prev_unknown:-1}"
|
|
1679
|
+
fi
|
|
1680
|
+
prev_cat=""
|
|
1681
|
+
case "$stmt" in
|
|
1682
|
+
*[![:space:]]*) ;;
|
|
1683
|
+
*) continue ;;
|
|
1684
|
+
esac
|
|
1685
|
+
set -f
|
|
1686
|
+
# The word split is the point: this is shell tokenization, and the glob
|
|
1687
|
+
# guard above keeps a literal `*` from expanding against the hook's cwd.
|
|
1688
|
+
# shellcheck disable=SC2206
|
|
1689
|
+
toks=($stmt)
|
|
1690
|
+
set +f
|
|
1691
|
+
n=${#toks[@]}
|
|
1692
|
+
[ "$n" -gt 0 ] || continue
|
|
1693
|
+
i=0
|
|
1694
|
+
cmd_pos=1
|
|
1695
|
+
function_lookup_allowed=1
|
|
1696
|
+
eval_mode=0
|
|
1697
|
+
arg_exec=0
|
|
1698
|
+
pending_operand=0
|
|
1699
|
+
find_mode=0
|
|
1700
|
+
dispatch_pending=0
|
|
1701
|
+
while [ "$i" -lt "$n" ]; do
|
|
1702
|
+
# Only a COMMAND POSITION can execute something. A path anywhere else is
|
|
1703
|
+
# an argument, and an argument is data (#3604).
|
|
1704
|
+
#
|
|
1705
|
+
# The one exception is a dispatcher that BUILDS a command out of its own
|
|
1706
|
+
# arguments: `find … -exec bash {} \;` and `xargs bash` put an interpreter
|
|
1707
|
+
# at a command position the text does not spell as one. Scoping the
|
|
1708
|
+
# exception to a `find`/`xargs` statement is what keeps it from becoming
|
|
1709
|
+
# "an interpreter name anywhere is an invocation", which would read
|
|
1710
|
+
# `echo "run bash <file>"` as one.
|
|
1711
|
+
if [ "$cmd_pos" -ne 1 ]; then
|
|
1712
|
+
# A dispatcher BUILDS a command out of its own arguments, putting an
|
|
1713
|
+
# interpreter at a command position the text does not spell as one.
|
|
1714
|
+
# That position is exactly ONE token: the word after `find -exec` or
|
|
1715
|
+
# `-execdir`, and the first operand of `xargs`.
|
|
1716
|
+
#
|
|
1717
|
+
# Latching "any interpreter NAME until the statement ends" instead was
|
|
1718
|
+
# a false positive, because an interpreter name is far more often data
|
|
1719
|
+
# than a command: it refused `find . -name bash`, where `bash` is a
|
|
1720
|
+
# filename PATTERN, and `find … | xargs grep bash`, where it is a
|
|
1721
|
+
# search term. Both are ordinary commands that delete nothing.
|
|
1722
|
+
if [ "$dispatch_pending" -ne 1 ]; then
|
|
1723
|
+
if [ "$find_mode" -eq 1 ]; then
|
|
1724
|
+
follow_unwrap "${toks[i]}"
|
|
1725
|
+
case "$follow_unwrapped" in
|
|
1726
|
+
-exec | -execdir) dispatch_pending=1 ;;
|
|
1727
|
+
esac
|
|
1728
|
+
fi
|
|
1729
|
+
i=$((i + 1))
|
|
1730
|
+
continue
|
|
1731
|
+
fi
|
|
1732
|
+
follow_unwrap "${toks[i]}"
|
|
1733
|
+
t="$follow_unwrapped"
|
|
1734
|
+
# `xargs -n1 bash` — the dispatcher's own options precede its command.
|
|
1735
|
+
case "$t" in
|
|
1736
|
+
-*)
|
|
1737
|
+
i=$((i + 1))
|
|
1738
|
+
continue
|
|
1739
|
+
;;
|
|
1740
|
+
esac
|
|
1741
|
+
dispatch_pending=0
|
|
1742
|
+
cmd_pos=1
|
|
1743
|
+
else
|
|
1744
|
+
follow_unwrap "${toks[i]}"
|
|
1745
|
+
t="$follow_unwrapped"
|
|
1746
|
+
fi
|
|
1747
|
+
function_found=0
|
|
1748
|
+
for ((function_index = function_count - 1; function_lookup_allowed && function_index >= 0; function_index--)); do
|
|
1749
|
+
if [ "$t" = "${function_names[function_index]}" ]; then
|
|
1750
|
+
[ "${function_uncertainties[function_index]}" -eq 0 ] || follow_refuse "$t" "its function definition is conditional"
|
|
1751
|
+
# Calls execute in this shell and do not consume a file-follow depth.
|
|
1752
|
+
# A separate bound refuses recursion without truncating the body.
|
|
1753
|
+
[ "$function_call_depth" -lt "$FOLLOW_MAX_FUNCTION_DEPTH" ] || follow_refuse "$t" "function calls exceed the inspection depth"
|
|
1754
|
+
follow_function_calls=$((follow_function_calls + 1))
|
|
1755
|
+
follow_function_bytes=$((follow_function_bytes + ${#function_bodies[function_index]}))
|
|
1756
|
+
if [ "$follow_function_calls" -gt "$FOLLOW_MAX_FUNCTION_CALLS" ] || [ "$follow_function_bytes" -gt "$FOLLOW_MAX_TOTAL_BYTES" ]; then
|
|
1757
|
+
follow_refuse "$t" "function expansion exceeds the inspection budget"
|
|
1758
|
+
fi
|
|
1759
|
+
local call_cwd="$follow_cwd" call_unknown="$follow_cwd_unknown"
|
|
1760
|
+
follow_scan "${function_bodies[function_index]}" "$depth" "$((function_call_depth + 1))" 0 child "$((function_flow_unknown || statement_conditional))"
|
|
1761
|
+
if { [ "$function_flow_unknown" -ne 0 ] || [ "$statement_conditional" -ne 0 ]; } && { [ "$follow_cwd" != "$call_cwd" ] || [ "$follow_cwd_unknown" != "$call_unknown" ]; }; then
|
|
1762
|
+
follow_cwd=""; follow_cwd_unknown=1
|
|
1763
|
+
fi
|
|
1764
|
+
function_found=1
|
|
1765
|
+
break
|
|
1766
|
+
fi
|
|
1767
|
+
done
|
|
1768
|
+
if [ "$function_found" -eq 1 ]; then
|
|
1769
|
+
cmd_pos=0; i=$((i + 1)); continue
|
|
1770
|
+
fi
|
|
1771
|
+
case "$t" in
|
|
1772
|
+
return)
|
|
1773
|
+
if [ "$statement_conditional" -ne 0 ]; then function_flow_unknown=1; fi
|
|
1774
|
+
if { [ "$function_call_depth" -gt 0 ] || [ "$invocation_kind" = source ]; } && [ "$function_flow_unknown" -ne 0 ]; then
|
|
1775
|
+
return_flow_unknown=1
|
|
1776
|
+
fi
|
|
1777
|
+
if { [ "$function_call_depth" -gt 0 ] || [ "$invocation_kind" = source ]; } && [ "$function_flow_unknown" -eq 0 ]; then
|
|
1778
|
+
# Ordinary braces share the shell but have distinct list frames.
|
|
1779
|
+
# A surrounding background brace list ends only its child; do not
|
|
1780
|
+
# cross a literal subshell boundary while finding that parent list.
|
|
1781
|
+
for ((list_index = list_depth; list_index >= 0; list_index--)); do
|
|
1782
|
+
[ "${list_scope_depths[list_index]}" -eq "$scope_depth" ] || break
|
|
1783
|
+
if [ "${list_asyncs[list_index]}" -ne 0 ]; then
|
|
1784
|
+
return_list_depth="$list_index"
|
|
1785
|
+
break
|
|
1786
|
+
fi
|
|
1787
|
+
done
|
|
1788
|
+
if [ "$return_list_depth" -ge 0 ]; then
|
|
1789
|
+
:
|
|
1790
|
+
elif [ "$scope_depth" -eq 0 ]; then
|
|
1791
|
+
return 0
|
|
1792
|
+
else
|
|
1793
|
+
return_scope_depth="$scope_depth"
|
|
1794
|
+
fi
|
|
1795
|
+
fi
|
|
1796
|
+
cmd_pos=0; i=$((i + 1)); continue
|
|
1797
|
+
;;
|
|
1798
|
+
if | while | until)
|
|
1799
|
+
function_flow_unknown=1
|
|
1800
|
+
i=$((i + 1)); continue
|
|
1801
|
+
;;
|
|
1802
|
+
for | select)
|
|
1803
|
+
function_flow_unknown=1
|
|
1804
|
+
cmd_pos=0; i=$((i + 1)); continue
|
|
1805
|
+
;;
|
|
1806
|
+
# Prefixes that keep the command position open: assignments, wrappers,
|
|
1807
|
+
# and the shell keywords a statement can start with. Without them
|
|
1808
|
+
# `if …; then bash <file>` and `env bash <file>` walk past the
|
|
1809
|
+
# invocation.
|
|
1810
|
+
"" | [A-Za-z_]*=* | --*) i=$((i + 1)); continue ;;
|
|
1811
|
+
-*)
|
|
1812
|
+
# A SHORT option inside a wrapper prefix can take a separate VALUE,
|
|
1813
|
+
# and that value is not a command. `nice -n 5 bash <file>` and
|
|
1814
|
+
# `sudo -u nobody bash <file>` put `5` and `nobody` where the walk
|
|
1815
|
+
# expected the program, so the invocation was never reached and the
|
|
1816
|
+
# file was never scanned — the same fail-open this whole block exists
|
|
1817
|
+
# to close, one layer along. Step the value over.
|
|
1818
|
+
#
|
|
1819
|
+
# But YIELD whenever the next token could itself be the invocation,
|
|
1820
|
+
# so `env -i bash <file>` still reads `bash` as the program: `-i`
|
|
1821
|
+
# takes no value, and consuming one would lose the interpreter.
|
|
1822
|
+
#
|
|
1823
|
+
# Long options are deliberately left alone. Their value is spelled
|
|
1824
|
+
# with `=`, and a `--foo <path> bash <file>` is rare enough that
|
|
1825
|
+
# consuming the operand would cost more than it buys.
|
|
1826
|
+
i=$((i + 1))
|
|
1827
|
+
if [ "$i" -lt "$n" ]; then
|
|
1828
|
+
follow_unwrap "${toks[i]}"
|
|
1829
|
+
follow_may_invoke "$follow_unwrapped" || i=$((i + 1))
|
|
1830
|
+
fi
|
|
1831
|
+
continue
|
|
1832
|
+
;;
|
|
1833
|
+
timeout | */timeout)
|
|
1834
|
+
# `timeout` takes a mandatory DURATION before the program. A bare
|
|
1835
|
+
# number is not an option, so the short-option arm above cannot see
|
|
1836
|
+
# it. Mark the operand pending and let the fall-through consume it,
|
|
1837
|
+
# which keeps working when options sit in between — `timeout -s KILL
|
|
1838
|
+
# 5 bash <file>` reaches `bash` just as `timeout 5 bash <file>` does.
|
|
1839
|
+
pending_operand=1
|
|
1840
|
+
function_lookup_allowed=0
|
|
1841
|
+
i=$((i + 1))
|
|
1842
|
+
continue
|
|
1843
|
+
;;
|
|
1844
|
+
sudo | env | command | exec | nohup | nice | time | builtin \
|
|
1845
|
+
| do | then | else | '!' | '{' \
|
|
1846
|
+
| */sudo | */env | */nohup | */nice | */time)
|
|
1847
|
+
case "$t" in
|
|
1848
|
+
sudo | env | command | exec | nohup | nice | builtin | */sudo | */env | */nohup | */nice) function_lookup_allowed=0 ;;
|
|
1849
|
+
do | then | else) function_flow_unknown=1 ;;
|
|
1850
|
+
esac
|
|
1851
|
+
i=$((i + 1))
|
|
1852
|
+
continue
|
|
1853
|
+
;;
|
|
1854
|
+
eval)
|
|
1855
|
+
# `eval` executes its ARGUMENTS as shell, so a file whose contents are
|
|
1856
|
+
# interpolated into them is executed text.
|
|
1857
|
+
eval_mode=1
|
|
1858
|
+
i=$((i + 1))
|
|
1859
|
+
continue
|
|
1860
|
+
;;
|
|
1861
|
+
find | */find)
|
|
1862
|
+
# `find` only builds a command after `-exec`/`-execdir`. Every other
|
|
1863
|
+
# operand is a path, a predicate, or a pattern.
|
|
1864
|
+
arg_exec=1
|
|
1865
|
+
find_mode=1
|
|
1866
|
+
cmd_pos=0
|
|
1867
|
+
i=$((i + 1))
|
|
1868
|
+
continue
|
|
1869
|
+
;;
|
|
1870
|
+
xargs | */xargs)
|
|
1871
|
+
# `xargs` takes its command as the first non-option operand; anything
|
|
1872
|
+
# after that is an argument TO that command, not another command.
|
|
1873
|
+
arg_exec=1
|
|
1874
|
+
dispatch_pending=1
|
|
1875
|
+
cmd_pos=0
|
|
1876
|
+
i=$((i + 1))
|
|
1877
|
+
continue
|
|
1878
|
+
;;
|
|
1879
|
+
cd)
|
|
1880
|
+
if [ $((i + 1)) -lt "$n" ]; then
|
|
1881
|
+
follow_unwrap "${toks[i + 1]}"
|
|
1882
|
+
follow_cd "$follow_unwrapped"
|
|
1883
|
+
else
|
|
1884
|
+
# A bare `cd` is `cd "$HOME"`, and leaving the previous directory in
|
|
1885
|
+
# place would resolve later tokens from a directory the shell left.
|
|
1886
|
+
follow_cd "${HOME:-}"
|
|
1887
|
+
fi
|
|
1888
|
+
if [ "$function_flow_unknown" -ne 0 ]; then
|
|
1889
|
+
follow_cwd=""; follow_cwd_unknown=1
|
|
1890
|
+
fi
|
|
1891
|
+
cmd_pos=0
|
|
1892
|
+
i=$((i + 1))
|
|
1893
|
+
continue
|
|
1894
|
+
;;
|
|
1895
|
+
source | .)
|
|
1896
|
+
follow_next_operand "$i" "$n"
|
|
1897
|
+
j="$follow_operand_index"
|
|
1898
|
+
if [ "$j" -lt "$n" ]; then
|
|
1899
|
+
follow_unwrap "${toks[j]}"
|
|
1900
|
+
local source_cwd="$follow_cwd" source_unknown="$follow_cwd_unknown"
|
|
1901
|
+
follow_source_target "$follow_unwrapped" "$depth" "$((function_flow_unknown || statement_conditional))"
|
|
1902
|
+
if { [ "$function_flow_unknown" -ne 0 ] || [ "$statement_conditional" -ne 0 ]; } && { [ "$follow_cwd" != "$source_cwd" ] || [ "$follow_cwd_unknown" != "$source_unknown" ]; }; then
|
|
1903
|
+
follow_cwd=""; follow_cwd_unknown=1
|
|
1904
|
+
fi
|
|
1905
|
+
fi
|
|
1906
|
+
cmd_pos=0
|
|
1907
|
+
i=$((j + 1))
|
|
1908
|
+
continue
|
|
1909
|
+
;;
|
|
1910
|
+
cat | '<')
|
|
1911
|
+
follow_next_operand "$i" "$n"
|
|
1912
|
+
j="$follow_operand_index"
|
|
1913
|
+
follow_unwrapped=""
|
|
1914
|
+
if [ "$j" -lt "$n" ]; then follow_unwrap "${toks[j]}"; fi
|
|
1915
|
+
if [ "$t" = cat ] && { [ "$j" -ge "$n" ] || [ "$follow_unwrapped" = - ]; }; then
|
|
1916
|
+
# Bare cat and cat - forward the group's named stdin unchanged.
|
|
1917
|
+
prev_cat="$pipe_cat"; prev_cwd="$pipe_cwd"; prev_unknown="$pipe_unknown"
|
|
1918
|
+
if [ "$eval_mode" -eq 1 ] && [ -n "$prev_cat" ]; then
|
|
1919
|
+
follow_pipe_target "$prev_cat" "$depth" "$prev_cwd" "$prev_unknown"
|
|
1920
|
+
fi
|
|
1921
|
+
elif [ "$j" -lt "$n" ]; then
|
|
1922
|
+
if [ "$eval_mode" -eq 1 ]; then
|
|
1923
|
+
follow_target "$follow_unwrapped" "$depth"
|
|
1924
|
+
else
|
|
1925
|
+
# Not executed here — the pipe consumer decides whether the
|
|
1926
|
+
# producer's file content becomes an executable shell input.
|
|
1927
|
+
prev_cat="$follow_unwrapped"
|
|
1928
|
+
prev_cwd="$follow_cwd"; prev_unknown="$follow_cwd_unknown"
|
|
1929
|
+
fi
|
|
1930
|
+
fi
|
|
1931
|
+
if [ -n "$prev_cat" ] && [ "$eval_mode" -eq 0 ]; then
|
|
1932
|
+
if [[ "$stmt" == *'>'* ]] || [ "$follow_descriptor_map" != '{"0":{"kind":"input"},"1":{"kind":"pipe"},"2":{"kind":"stderr"}}' ]; then
|
|
1933
|
+
follow_redirect_io "$stmt" cat
|
|
1934
|
+
case "$follow_redirect_output" in
|
|
1935
|
+
pipe) ;;
|
|
1936
|
+
file | closed | stderr | input) prev_cat="" ;;
|
|
1937
|
+
*) follow_refuse "cat redirection" "its output destination could not be determined" ;;
|
|
1938
|
+
esac
|
|
1939
|
+
fi
|
|
1940
|
+
fi
|
|
1941
|
+
cmd_pos=0
|
|
1942
|
+
i=$((j + 1))
|
|
1943
|
+
continue
|
|
1944
|
+
;;
|
|
1945
|
+
esac
|
|
1946
|
+
if follow_is_interpreter "$t"; then
|
|
1947
|
+
inline=0
|
|
1948
|
+
redirected=""
|
|
1949
|
+
heredoc=0
|
|
1950
|
+
stdin_mode=0
|
|
1951
|
+
noexec=0
|
|
1952
|
+
j=$((i + 1))
|
|
1953
|
+
while [ "$j" -lt "$n" ]; do
|
|
1954
|
+
follow_unwrap "${toks[j]}"
|
|
1955
|
+
tj="$follow_unwrapped"
|
|
1956
|
+
case "$tj" in
|
|
1957
|
+
--) j=$((j + 1)); break ;;
|
|
1958
|
+
# A heredoc or here-string payload is IN the command text, which the
|
|
1959
|
+
# guards already read. It is not a file, and treating it as one
|
|
1960
|
+
# refused `bash <<EOF` outright.
|
|
1961
|
+
'<<'*) heredoc=1; break ;;
|
|
1962
|
+
# `bash < <file>` executes the file just as `bash <file>` does.
|
|
1963
|
+
'<')
|
|
1964
|
+
j=$((j + 1))
|
|
1965
|
+
if [ "$j" -lt "$n" ]; then
|
|
1966
|
+
follow_unwrap "${toks[j]}"
|
|
1967
|
+
redirected="$follow_unwrapped"
|
|
1968
|
+
fi
|
|
1969
|
+
break
|
|
1970
|
+
;;
|
|
1971
|
+
'<'*) redirected="${tj#<}"; break ;;
|
|
1972
|
+
# An output redirection names a DESTINATION, never the script. Step
|
|
1973
|
+
# over it (and its target when the operator stands alone) so the
|
|
1974
|
+
# operand scan does not stop on `>` and read `/dev/null` as a
|
|
1975
|
+
# script it cannot resolve.
|
|
1976
|
+
*'>'*)
|
|
1977
|
+
case "$tj" in
|
|
1978
|
+
*'>') j=$((j + 2)) ;;
|
|
1979
|
+
*) j=$((j + 1)) ;;
|
|
1980
|
+
esac
|
|
1981
|
+
;;
|
|
1982
|
+
# `-n` is NOEXEC: the shell reads and parses its operand, then exits
|
|
1983
|
+
# without running a line of it. So a syntax check is the one shape
|
|
1984
|
+
# that puts a path at a command position while provably executing
|
|
1985
|
+
# nothing, and following it reads a file the command never runs.
|
|
1986
|
+
#
|
|
1987
|
+
# This guard hit that on ITSELF: its pattern list is destructive
|
|
1988
|
+
# command literals living inside the guard, so `bash -n` on its own
|
|
1989
|
+
# source was refused by its own patterns — and the same held for any
|
|
1990
|
+
# file documenting destructive commands. See CodySwannGT/lisa#3803.
|
|
1991
|
+
#
|
|
1992
|
+
# `bash <file>` still executes and is still followed. Noexec is the
|
|
1993
|
+
# distinction, not the program: this is not a read-only exemption.
|
|
1994
|
+
--noexec) noexec=1; break ;;
|
|
1995
|
+
--*) j=$((j + 1)) ;;
|
|
1996
|
+
-*c*) inline=1; j=$((j + 1)) ;;
|
|
1997
|
+
-*n*) noexec=1; break ;;
|
|
1998
|
+
-s | -[a-zA-Z]*s[a-zA-Z]*) stdin_mode=1; j=$((j + 1)) ;;
|
|
1999
|
+
-*) j=$((j + 1)) ;;
|
|
2000
|
+
*) break ;;
|
|
2001
|
+
esac
|
|
2002
|
+
done
|
|
2003
|
+
if [ "$noexec" -eq 1 ]; then
|
|
2004
|
+
# Nothing runs, so there is no script to follow and nothing to fail
|
|
2005
|
+
# closed about. Checked before the heredoc and dispatcher arms: those
|
|
2006
|
+
# decide WHICH text to read, and here there is no execution at all.
|
|
2007
|
+
cmd_pos=0
|
|
2008
|
+
i=$((j + 1))
|
|
2009
|
+
continue
|
|
2010
|
+
fi
|
|
2011
|
+
if [ "$heredoc" -eq 1 ] && [ "$inline" -eq 0 ]; then
|
|
2012
|
+
cmd_pos=0
|
|
2013
|
+
i=$((j + 1))
|
|
2014
|
+
continue
|
|
2015
|
+
fi
|
|
2016
|
+
if [ -n "$redirected" ]; then
|
|
2017
|
+
follow_target "$redirected" "$depth"
|
|
2018
|
+
cmd_pos=0
|
|
2019
|
+
i=$((j + 1))
|
|
2020
|
+
continue
|
|
2021
|
+
fi
|
|
2022
|
+
if [ "$inline" -eq 1 ]; then
|
|
2023
|
+
# The command string runs in a child shell. It gets its own function
|
|
2024
|
+
# map and cwd effects; positional arguments are data, not commands.
|
|
2025
|
+
follow_inline "$stmt" "$j" "$depth"
|
|
2026
|
+
cmd_pos=0
|
|
2027
|
+
i="$n"
|
|
2028
|
+
continue
|
|
2029
|
+
fi
|
|
2030
|
+
if [ "$stdin_mode" -eq 1 ]; then
|
|
2031
|
+
# `sh -s -- -y` reads its program from stdin; tokens after `--` are
|
|
2032
|
+
# positional arguments to that program, never a script path.
|
|
2033
|
+
[ -z "$pipe_cat" ] || follow_pipe_target "$pipe_cat" "$depth" "$pipe_cwd" "$pipe_unknown"
|
|
2034
|
+
cmd_pos=0
|
|
2035
|
+
i="$n"
|
|
2036
|
+
continue
|
|
2037
|
+
fi
|
|
2038
|
+
if [ "$j" -ge "$n" ]; then
|
|
2039
|
+
# A dispatcher supplies the script from ANOTHER command's output, so
|
|
2040
|
+
# the operand exists but is unreadable here. That is the fail-closed
|
|
2041
|
+
# case, not the interactive one.
|
|
2042
|
+
if [ "$arg_exec" -eq 1 ]; then
|
|
2043
|
+
follow_refuse "$t" "a dispatcher supplies its script from another command's output"
|
|
2044
|
+
fi
|
|
2045
|
+
# No operand: an interactive or stdin-fed interpreter. Only the
|
|
2046
|
+
# `cat <file> | bash` shape names a FILE, and only that is followed.
|
|
2047
|
+
[ -z "$pipe_cat" ] || follow_pipe_target "$pipe_cat" "$depth" "$pipe_cwd" "$pipe_unknown"
|
|
2048
|
+
cmd_pos=0
|
|
2049
|
+
i="$j"
|
|
2050
|
+
continue
|
|
2051
|
+
fi
|
|
2052
|
+
follow_unwrap "${toks[j]}"
|
|
2053
|
+
tj="$follow_unwrapped"
|
|
2054
|
+
if ! follow_supervisor "$tj" "$depth" "$stmt"; then
|
|
2055
|
+
follow_target "$tj" "$depth"
|
|
2056
|
+
fi
|
|
2057
|
+
cmd_pos=0
|
|
2058
|
+
i=$((j + 1))
|
|
2059
|
+
continue
|
|
2060
|
+
fi
|
|
2061
|
+
if [ "$pending_operand" -eq 1 ]; then
|
|
2062
|
+
# A wrapper's own mandatory operand (a `timeout` duration), not the
|
|
2063
|
+
# program it wraps. Consume it and leave the command position OPEN so
|
|
2064
|
+
# the walk carries on to the invocation. Yields on anything that could
|
|
2065
|
+
# itself be the program, for the reason follow_may_invoke documents.
|
|
2066
|
+
pending_operand=0
|
|
2067
|
+
if ! follow_may_invoke "$t"; then
|
|
2068
|
+
i=$((i + 1))
|
|
2069
|
+
continue
|
|
2070
|
+
fi
|
|
2071
|
+
fi
|
|
2072
|
+
# The tilde arm is LITERAL text, not a path this line expands:
|
|
2073
|
+
# follow_locate turns it into one.
|
|
2074
|
+
# shellcheck disable=SC2088
|
|
2075
|
+
case "$t" in
|
|
2076
|
+
/* | ./* | ../* | '~/'* | */*) follow_direct "$t" "$depth" ;;
|
|
2077
|
+
esac
|
|
2078
|
+
cmd_pos=0
|
|
2079
|
+
i=$((i + 1))
|
|
2080
|
+
done
|
|
2081
|
+
# Keep successful-branch provenance separate from the continuation join.
|
|
2082
|
+
# OR can skip its RHS and still reach a following &&, so it cannot prove
|
|
2083
|
+
# that RHS's cwd. A semicolon observes every possible preceding outcome.
|
|
2084
|
+
and_cwd="$follow_cwd"; and_unknown="$follow_cwd_unknown"
|
|
2085
|
+
if [ "$sep" != '?' ] && [ "$function_flow_unknown" -eq 0 ]; then and_ready=1; fi
|
|
2086
|
+
if [ "$statement_conditional" -ne 0 ] && { [ "$join_unknown" -ne 0 ] || [ "$follow_cwd_unknown" -ne 0 ] || [ "$follow_cwd" != "$join_cwd" ]; }; then
|
|
2087
|
+
follow_cwd=""; follow_cwd_unknown=1
|
|
2088
|
+
fi
|
|
2089
|
+
done <<<"$split_out"
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
follow_scan "$command_for_guards" 0
|
|
2093
|
+
rm_command_for_guards="$command_for_guards"$'\n'"$followed_rm_text"
|
|
2094
|
+
if [ -n "$follow_names" ]; then
|
|
2095
|
+
command_for_guards="$command_for_guards"$'\n'"$followed_text"
|
|
2096
|
+
# A refusal must say WHERE the match is. Without this the operator reads
|
|
2097
|
+
# advice about rewording a quoted string for a line they never typed.
|
|
2098
|
+
DESTRUCTIVE_GUIDANCE="$DESTRUCTIVE_GUIDANCE
|
|
2099
|
+
|
|
2100
|
+
The match may be inside a file this command EXECUTES rather than in the command
|
|
2101
|
+
you typed. These scripts were read and scanned as part of it:$follow_names
|
|
2102
|
+
Edit the file, or ask the user to confirm and run it manually outside the agent."
|
|
2103
|
+
fi
|
|
2104
|
+
|
|
2105
|
+
# Normalize bash line-continuations (a trailing backslash + newline → space)
|
|
2106
|
+
# before segmenting the command. Without this, "git push --force origin
|
|
2107
|
+
# \<newline>main" splits into a segment matching --force but not `main`, letting a
|
|
2108
|
+
# protected force-push slip past. Uses awk (POSIX) instead of a GNU-only
|
|
2109
|
+
# `sed ':a;N;$!ba;…'`, which errors on BSD sed (macOS) and there silently no-ops.
|
|
2110
|
+
normalized_command_str="$(printf '%s' "$command_for_guards" \
|
|
2111
|
+
| awk '{ if (sub(/\\$/, "")) printf "%s ", $0; else print }')"
|
|
2112
|
+
rm_normalized_command_str="$(printf '%s' "$rm_command_for_guards" \
|
|
2113
|
+
| awk '{ if (sub(/\\$/, "")) printf "%s ", $0; else print }')"
|
|
2114
|
+
|
|
2115
|
+
# matches / matches_cs run an ERE against the guarded command text. matches is
|
|
2116
|
+
# case-insensitive (the default for these guards); matches_cs is case-SENSITIVE
|
|
2117
|
+
# for guards where flag case is meaningful (`git branch -d` vs `-D`).
|
|
2118
|
+
#
|
|
2119
|
+
# Both read the NORMALIZED text. They used to read $command_for_guards, so only
|
|
2120
|
+
# the two segment-walking guards (rm, git push) were protected against line
|
|
2121
|
+
# continuations and every guard expressed through these helpers could be evaded
|
|
2122
|
+
# by breaking the command across lines:
|
|
2123
|
+
#
|
|
2124
|
+
# git reset --hard \
|
|
2125
|
+
# origin/main
|
|
2126
|
+
#
|
|
2127
|
+
# The patterns match on intermediate whitespace, and an embedded "\<newline>" is
|
|
2128
|
+
# not whitespace to grep -E, so the anchor failed to span the break. Defined
|
|
2129
|
+
# after the normalization above so the value exists when these are first called.
|
|
2130
|
+
matches() {
|
|
2131
|
+
scan -Ei "$normalized_command_str" "$1"
|
|
2132
|
+
}
|
|
2133
|
+
matches_cs() {
|
|
2134
|
+
scan -E "$normalized_command_str" "$1"
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
# Shared ERE fragments. GIT_TOKENS walks over intermediate argv tokens without
|
|
2138
|
+
# crossing a statement separator, so a flag in a LATER statement can never be
|
|
2139
|
+
# attributed to an earlier git subcommand.
|
|
2140
|
+
readonly GIT_TOKENS='([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+'
|
|
2141
|
+
# Git GLOBAL options (`-C <path>`, `-c <k>=<v>`, `--git-dir[=…]`, `--no-pager`,
|
|
2142
|
+
# …) legally sit between `git` and its subcommand, so every subcommand guard
|
|
2143
|
+
# consumes them — otherwise `git -C /path checkout -- f` dodges the anchor
|
|
2144
|
+
# (issue #1960 security review F1). Shape: zero or more dash-led tokens, each
|
|
2145
|
+
# optionally followed by ONE non-dash value token, which covers both the
|
|
2146
|
+
# `--git-dir=/x` and `--git-dir /x` spellings without naming every option.
|
|
2147
|
+
readonly GIT_GLOBAL_OPTS='(-[^;&|[:space:]]+([[:space:]]+[^-;&|[:space:]][^;&|[:space:]]*)?[[:space:]]+)*'
|
|
2148
|
+
# Anchor for every git subcommand guard: word-bounded `git`, whitespace, then
|
|
2149
|
+
# any run of global options. Callers append the subcommand name directly.
|
|
2150
|
+
readonly GIT_CMD='(^|[^[:alnum:]_-])git[[:space:]]+'"$GIT_GLOBAL_OPTS"
|
|
2151
|
+
# An rm invocation: bare `rm` or a path whose basename is exactly `rm`
|
|
2152
|
+
# (`/bin/rm`, `./rm`) — issue #1960 security review F2. The optional prefix must
|
|
2153
|
+
# END IN `/` immediately before `rm`, so charm/confirm/informant/rmdir never
|
|
2154
|
+
# match. The preceding-char class still excludes `.`, `/`, and `-` to keep
|
|
2155
|
+
# `--rm`-style flags and `foo.rm` names out.
|
|
2156
|
+
readonly RM_CMD='(^|[^[:alnum:]_./-])([[:alnum:]_./-]*/)?rm'
|
|
2157
|
+
# Where a recursive-forced flag cluster is allowed to END.
|
|
2158
|
+
#
|
|
2159
|
+
# It used to be `([[:space:]]|$)` — whitespace or end of segment — and a closing
|
|
2160
|
+
# parenthesis is neither, so a SUBSHELL-wrapped pipeline fell out of every guard
|
|
2161
|
+
# built on these two patterns while its brace-wrapped twin was caught: `{ find
|
|
2162
|
+
# .git -type d | parallel rm -rf; }` matched because the `;` supplied the
|
|
2163
|
+
# boundary, and `(find .git -type d | parallel rm -rf)` did not (issue #3494).
|
|
2164
|
+
#
|
|
2165
|
+
# So the two STATEMENT-GROUPING terminators join whitespace here, and nothing
|
|
2166
|
+
# else. The general spelling — "any character that cannot be part of a flag",
|
|
2167
|
+
# `([^[:alnum:]-]|$)` — is the more principled statement of the intent, and is
|
|
2168
|
+
# deliberately NOT used: these patterns feed the rm TARGET WALK, the guard with
|
|
2169
|
+
# this file's longest false-positive history, and it would newly match ordinary
|
|
2170
|
+
# prose punctuation (`echo "rm -rf, then …"` on the comma). Paren and semicolon
|
|
2171
|
+
# buy the brace/paren parity the defect is about and buy nothing else.
|
|
2172
|
+
#
|
|
2173
|
+
# The cluster must still END at the boundary, so `-rfoo` is not read as `-rf`.
|
|
2174
|
+
readonly RM_RF_END='([[:space:];)]|$)'
|
|
2175
|
+
# rm invoked with BOTH a recursive and a force flag — clustered (-rf/-fr, any
|
|
2176
|
+
# extra letters) or split, in either order. The split gate pairs ANY recursive
|
|
2177
|
+
# form (short cluster containing r, or --recursive) with ANY force form (short
|
|
2178
|
+
# cluster containing f, or --force), so mixed spellings like `rm -r --force /`
|
|
2179
|
+
# and `rm --recursive -f /` cannot slip between the short/short and long/long
|
|
2180
|
+
# alternations (PR #1976 review).
|
|
2181
|
+
#
|
|
2182
|
+
# The FLAG TAILS are named separately from the `rm`-anchored patterns because
|
|
2183
|
+
# the find/xargs family below needs the identical flag vocabulary against a
|
|
2184
|
+
# different verb anchor. Before #3494 it carried its own short-cluster-only copy
|
|
2185
|
+
# and therefore missed `rm --recursive --force` — a divergence that could only
|
|
2186
|
+
# happen because the spelling set was written twice.
|
|
2187
|
+
readonly RM_RF_TAIL_CLUSTER='([[:space:]]+-[[:alnum:]-]+)*[[:space:]]+(-[[:alnum:]]*r[[:alnum:]]*f|-[[:alnum:]]*f[[:alnum:]]*r)'"$RM_RF_END"
|
|
2188
|
+
readonly RM_RF_TAIL_SPLIT='(([[:space:]][^;&|]*)?[[:space:]](-[[:alnum:]]*r[[:alnum:]]*|--recursive)([[:space:]][^;&|]*)?[[:space:]](-[[:alnum:]]*f[[:alnum:]]*|--force)'"$RM_RF_END"'|([[:space:]][^;&|]*)?[[:space:]](-[[:alnum:]]*f[[:alnum:]]*|--force)([[:space:]][^;&|]*)?[[:space:]](-[[:alnum:]]*r[[:alnum:]]*|--recursive)'"$RM_RF_END"')'
|
|
2189
|
+
readonly RM_RF_CLUSTER="$RM_CMD$RM_RF_TAIL_CLUSTER"
|
|
2190
|
+
readonly RM_RF_SPLIT="$RM_CMD$RM_RF_TAIL_SPLIT"
|
|
2191
|
+
|
|
2192
|
+
# 1. Recursive forced delete (`rm -rf`) of a filesystem root, home, or top-level
|
|
2193
|
+
# wildcard. Two gates ANDed: the statement must invoke `rm` with BOTH a
|
|
2194
|
+
# recursive and a force flag, AND name a catastrophic target. Splitting the
|
|
2195
|
+
# flag check from the target check keeps each regex legible and testable.
|
|
2196
|
+
# The target boundary classes include quote characters (issue #1960): without
|
|
2197
|
+
# them, `bash -c "rm -rf /"` and interpreter one-liners like
|
|
2198
|
+
# `python -c "… os.system('rm -rf /')"` slip through because the target is
|
|
2199
|
+
# quote-adjacent instead of space-bounded.
|
|
2200
|
+
# Both gates run PER STATEMENT inside the shared rm loop below (quality
|
|
2201
|
+
# review S1): a harmless `/` in a LATER statement (`rm -rf build && cd /`)
|
|
2202
|
+
# is never attributed to the rm in an earlier one.
|
|
2203
|
+
qc="'\""
|
|
2204
|
+
# Path/home targets also accept the command-substitution closers `)` and backtick
|
|
2205
|
+
# as a trailing boundary (issue #1982): in `echo "$(rm -rf /)"` the target `/` is
|
|
2206
|
+
# followed by `)`, not a space or quote, so without them an executable
|
|
2207
|
+
# substitution-wrapped `rm -rf <root>` slips past this gate. Leading boundary is
|
|
2208
|
+
# unchanged — the target is always space-separated from the recursive+force flag.
|
|
2209
|
+
tc="'\"\`)"
|
|
2210
|
+
# The bare-`*` wildcard deliberately KEEPS the narrow (space/quote/end) boundary:
|
|
2211
|
+
# admitting `)` there would misread a shell `case … in *)` default arm as a
|
|
2212
|
+
# catastrophic `rm -rf *` (T4b F1). A genuinely substitution-wrapped bare `*`
|
|
2213
|
+
# (`echo "$(rm -rf *)"`) is instead caught by the token-walk (guard 1b) below.
|
|
2214
|
+
readonly RM_CATASTROPHIC_TARGET='([[:space:]='"$qc"'])((/|/\*|/\.\*?|~|~/\*?|\$HOME\b|\$\{HOME\})([[:space:]'"$tc"']|/?\*?['"$tc"']?$)|\*([[:space:]'"$qc"']|/?\*?['"$qc"']?$))'
|
|
2215
|
+
|
|
2216
|
+
# 1b. rm target hardening (issue #1960). For every statement that invokes rm
|
|
2217
|
+
# with recursive+force flags, additionally block when:
|
|
2218
|
+
# - cwd is $HOME (any target is one argument away from wiping home), or
|
|
2219
|
+
# - the target is the cwd itself (`.` / `./`), or
|
|
2220
|
+
# - the target traverses out via `..`, or
|
|
2221
|
+
# - the target is home-anchored (`~/…` — the shell expands it to $HOME at
|
|
2222
|
+
# execution time, so it is always outside the project), or
|
|
2223
|
+
# - the target is an absolute path outside the project — with an allowance
|
|
2224
|
+
# for /tmp, /var/tmp, and $TMPDIR — or
|
|
2225
|
+
# - the target is a `$VAR` expansion other than the sanctioned $TMPDIR.
|
|
2226
|
+
# Globbing is disabled around the token walk so a literal `*` in the command
|
|
2227
|
+
# is never expanded against the hook's own cwd.
|
|
2228
|
+
project_dir="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
2229
|
+
tmp_dir_allow="${TMPDIR:-/tmp}"
|
|
2230
|
+
tmp_dir_allow="${tmp_dir_allow%/}"
|
|
2231
|
+
[ -n "$tmp_dir_allow" ] || tmp_dir_allow="/tmp"
|
|
2232
|
+
# Physical (symlink-resolved) spelling of an allowed root — issue #3106 arm B.
|
|
2233
|
+
# The allowance below is a set of LITERAL string prefixes, so on macOS, where
|
|
2234
|
+
# /tmp is a symlink to /private/tmp, one directory had two spellings and only
|
|
2235
|
+
# one of them was allowed: `rm -rf /tmp/x` passed while `rm -rf /private/tmp/x`
|
|
2236
|
+
# — the SAME directory, and the spelling an agent's own scratchpad path is
|
|
2237
|
+
# handed to it in — was refused as "outside the project". Two opposite verdicts
|
|
2238
|
+
# for one location is a defect regardless of which verdict is right.
|
|
2239
|
+
#
|
|
2240
|
+
# This widens no LOCATION. It adds the second name of a location the guard has
|
|
2241
|
+
# always allowed, computed from the filesystem rather than hardcoded, so a
|
|
2242
|
+
# platform where /tmp is not a symlink gains nothing. Falls back to the logical
|
|
2243
|
+
# path when the directory does not exist or resolves to `/`, so the allowance
|
|
2244
|
+
# can never become an empty or root-anchored prefix (an empty prefix would make
|
|
2245
|
+
# `""/*` match every absolute path).
|
|
2246
|
+
phys_dir() {
|
|
2247
|
+
local logical="$1" real=""
|
|
2248
|
+
real="$(cd -- "$logical" 2>/dev/null && pwd -P)" || real=""
|
|
2249
|
+
case "$real" in
|
|
2250
|
+
"" | /) printf '%s' "$logical" ;;
|
|
2251
|
+
*) printf '%s' "$real" ;;
|
|
2252
|
+
esac
|
|
2253
|
+
}
|
|
2254
|
+
project_dir_phys="$(phys_dir "$project_dir")"
|
|
2255
|
+
tmp_phys="$(phys_dir /tmp)"
|
|
2256
|
+
var_tmp_phys="$(phys_dir /var/tmp)"
|
|
2257
|
+
tmp_dir_allow_phys="$(phys_dir "$tmp_dir_allow")"
|
|
2258
|
+
[ -n "$project_dir_phys" ] || project_dir_phys="$project_dir"
|
|
2259
|
+
[ -n "$tmp_phys" ] || tmp_phys="/tmp"
|
|
2260
|
+
[ -n "$var_tmp_phys" ] || var_tmp_phys="/var/tmp"
|
|
2261
|
+
[ -n "$tmp_dir_allow_phys" ] || tmp_dir_allow_phys="$tmp_dir_allow"
|
|
2262
|
+
# Peel command-substitution wrappers off a token so an rm nested in `$(…)`,
|
|
2263
|
+
# backticks, `<(…)`/`>(…)`, a `"…"`/`'…'` quote, or a leading `\` alias-bypass
|
|
2264
|
+
# (`\rm`) is recognized and its target classified as if the token were unwrapped
|
|
2265
|
+
# (issue #1982) — substitution-wrapping is verdict-neutral for the rm guard.
|
|
2266
|
+
# Leading wrappers (\ $( ` ( <( >( and quotes) and trailing closers () ` and
|
|
2267
|
+
# quotes) are stripped in runs, so `"$(\rm` → `rm` and `/etc)"` → `/etc`. `$((`
|
|
2268
|
+
# is ARITHMETIC, never command substitution: the LEADING peel stops at it (its
|
|
2269
|
+
# case is ordered first), so the inner expression is never exposed as a command
|
|
2270
|
+
# or a target. The trailing loop may still trim a closing `))`, but that is
|
|
2271
|
+
# harmless — an arithmetic token like `$((10/2))` still keeps its `$` for the
|
|
2272
|
+
# variable-target guard and can never be mis-normalized into an rm or a `/`-target.
|
|
2273
|
+
strip_subst_wrappers() {
|
|
2274
|
+
local t="$1" prev=""
|
|
2275
|
+
while [ "$t" != "$prev" ]; do
|
|
2276
|
+
prev="$t"
|
|
2277
|
+
case "$t" in
|
|
2278
|
+
'$(('*) break ;;
|
|
2279
|
+
'\'*) t="${t#\\}" ;;
|
|
2280
|
+
'$('*) t="${t#'$('}" ;;
|
|
2281
|
+
'<('*) t="${t#'<('}" ;;
|
|
2282
|
+
'>('*) t="${t#'>('}" ;;
|
|
2283
|
+
'"'*) t="${t#'"'}" ;;
|
|
2284
|
+
"'"*) t="${t#\'}" ;;
|
|
2285
|
+
'`'*) t="${t#'`'}" ;;
|
|
2286
|
+
'('*) t="${t#(}" ;;
|
|
2287
|
+
esac
|
|
2288
|
+
done
|
|
2289
|
+
prev=""
|
|
2290
|
+
while [ "$t" != "$prev" ]; do
|
|
2291
|
+
prev="$t"
|
|
2292
|
+
case "$t" in
|
|
2293
|
+
*')') t="${t%)}" ;;
|
|
2294
|
+
*'`') t="${t%'`'}" ;;
|
|
2295
|
+
*'"') t="${t%'"'}" ;;
|
|
2296
|
+
*"'") t="${t%\'}" ;;
|
|
2297
|
+
esac
|
|
2298
|
+
done
|
|
2299
|
+
printf '%s' "$t"
|
|
2300
|
+
}
|
|
2301
|
+
# Decide which text of an rm statement the TARGET WALK may read — issue #3106
|
|
2302
|
+
# arm A. The statement selector matches the RAW command text, so a recursive
|
|
2303
|
+
# delete quoted as PROSE inside a string argument selects the line, and every
|
|
2304
|
+
# remaining token on it is then classified as a deletion target. Three
|
|
2305
|
+
# independent sightings in one day, all of them commands that delete nothing:
|
|
2306
|
+
# a `printf` reduction, a `gh issue create --title …` filing the report about
|
|
2307
|
+
# this guard, and a commit message describing it. Each was refused for a
|
|
2308
|
+
# variable in a LATER, unrelated argument.
|
|
2309
|
+
#
|
|
2310
|
+
# The rule is structural, with no allowlist of "display commands":
|
|
2311
|
+
# - an rm invocation OUTSIDE any quote is real; the walk reads the whole
|
|
2312
|
+
# statement, exactly as before (`rm -rf "$HOME"` keeps its target),
|
|
2313
|
+
# - an rm at the START of its quoted run is an invocation the quote merely
|
|
2314
|
+
# wraps (`bash -c "rm -rf /"`, `echo "$(rm -rf /)"`); the walk reads the
|
|
2315
|
+
# whole statement, exactly as before,
|
|
2316
|
+
# - an rm preceded by other words INSIDE its quoted run is prose; the walk is
|
|
2317
|
+
# scoped to that run, so the rm's own quoted arguments are still classified
|
|
2318
|
+
# in full while a variable in a later argument is no longer read as a
|
|
2319
|
+
# deletion target.
|
|
2320
|
+
#
|
|
2321
|
+
# Failure directions all point at blocking: an unterminated quote, an
|
|
2322
|
+
# over-long statement, or any run the scan cannot place falls back to the whole
|
|
2323
|
+
# statement. Residual (accepted, documented): `eval "prose rm -rf" "$X"` would
|
|
2324
|
+
# concatenate its words at run time and is scoped to the first run here.
|
|
2325
|
+
rm_scan_scope() {
|
|
2326
|
+
local stmt="$1"
|
|
2327
|
+
local n=${#stmt}
|
|
2328
|
+
# Long statements fall back to the full walk rather than pay a char scan.
|
|
2329
|
+
if [ "$n" -gt 4000 ]; then
|
|
2330
|
+
printf '%s' "$stmt"
|
|
2331
|
+
return
|
|
2332
|
+
fi
|
|
2333
|
+
local i=0 ch q="" esc=0 outside="" cur_run="" rm_runs="" found=0
|
|
2334
|
+
local run_leading=0
|
|
2335
|
+
while [ "$i" -lt "$n" ]; do
|
|
2336
|
+
ch="${stmt:i:1}"
|
|
2337
|
+
i=$((i + 1))
|
|
2338
|
+
if [ "$esc" -eq 1 ]; then
|
|
2339
|
+
esc=0
|
|
2340
|
+
if [ -n "$q" ]; then cur_run="$cur_run$ch"; else outside="$outside$ch"; fi
|
|
2341
|
+
continue
|
|
2342
|
+
fi
|
|
2343
|
+
if [ "$ch" = '\' ] && [ "$q" != "'" ]; then
|
|
2344
|
+
esc=1
|
|
2345
|
+
continue
|
|
2346
|
+
fi
|
|
2347
|
+
if [ -n "$q" ]; then
|
|
2348
|
+
if [ "$ch" = "$q" ]; then
|
|
2349
|
+
case "$cur_run" in
|
|
2350
|
+
*[Rr][Mm]*)
|
|
2351
|
+
if scan -Ei "$cur_run" "$RM_CMD"'([[:space:]]|$)'; then
|
|
2352
|
+
found=$((found + 1))
|
|
2353
|
+
if [ -n "$rm_runs" ]; then rm_runs="$rm_runs"$'\n'; fi
|
|
2354
|
+
rm_runs="$rm_runs$cur_run"
|
|
2355
|
+
if scan -Ei "$cur_run" \
|
|
2356
|
+
'^[[:space:]]*(\\|\$\(|`|\(|<\(|>\()*[[:space:]]*([[:alnum:]_./-]*/)?rm([[:space:]]|$)'; then
|
|
2357
|
+
run_leading=1
|
|
2358
|
+
fi
|
|
2359
|
+
fi
|
|
2360
|
+
;;
|
|
2361
|
+
esac
|
|
2362
|
+
q=""
|
|
2363
|
+
cur_run=""
|
|
2364
|
+
else
|
|
2365
|
+
cur_run="$cur_run$ch"
|
|
2366
|
+
fi
|
|
2367
|
+
continue
|
|
2368
|
+
fi
|
|
2369
|
+
case "$ch" in
|
|
2370
|
+
'"' | "'")
|
|
2371
|
+
q="$ch"
|
|
2372
|
+
cur_run=""
|
|
2373
|
+
;;
|
|
2374
|
+
*) outside="$outside$ch" ;;
|
|
2375
|
+
esac
|
|
2376
|
+
done
|
|
2377
|
+
# An unterminated quote is text the scan cannot place: treat it as unquoted so
|
|
2378
|
+
# the fallback below hands back the whole statement.
|
|
2379
|
+
[ -z "$q" ] || outside="$outside$cur_run"
|
|
2380
|
+
if scan -Ei "$outside" "$RM_CMD"'([[:space:]]|$)'; then
|
|
2381
|
+
printf '%s' "$stmt"
|
|
2382
|
+
return
|
|
2383
|
+
fi
|
|
2384
|
+
if [ "$found" -eq 0 ]; then
|
|
2385
|
+
printf '%s' "$stmt"
|
|
2386
|
+
return
|
|
2387
|
+
fi
|
|
2388
|
+
# `rm` first in ANY run (modulo whitespace and substitution/alias openers) is
|
|
2389
|
+
# an invocation, not prose. Inspect every run before deciding: returning on
|
|
2390
|
+
# the first prose run used to hide a real delete in a later quoted run.
|
|
2391
|
+
if [ "$run_leading" -eq 1 ]; then
|
|
2392
|
+
printf '%s' "$stmt"
|
|
2393
|
+
return
|
|
2394
|
+
fi
|
|
2395
|
+
printf '%s' "$rm_runs"
|
|
2396
|
+
}
|
|
2397
|
+
# Look up the ONE unambiguous assignment of NAME inside the command being
|
|
2398
|
+
# classified — issue #3106 arm B, the variable half.
|
|
2399
|
+
#
|
|
2400
|
+
# Prints the recorded right-hand side, or fails when the name is not assigned
|
|
2401
|
+
# before the first rm token or is assigned more than one distinct value there.
|
|
2402
|
+
# A later write cannot affect an earlier delete, and a quoted assignment-like
|
|
2403
|
+
# argument can be prose, so neither is considered. Only assignments literally
|
|
2404
|
+
# present in this command are ever trusted; nothing is expanded from the hook's
|
|
2405
|
+
# own environment.
|
|
2406
|
+
rm_assignment_stmt=""
|
|
2407
|
+
rm_assignment_value() {
|
|
2408
|
+
local name="$1"
|
|
2409
|
+
case "$name" in
|
|
2410
|
+
"" | *[!A-Za-z0-9_]*) return 1 ;;
|
|
2411
|
+
esac
|
|
2412
|
+
# Only assignments that occur before this statement's rm token can influence
|
|
2413
|
+
# its delete safely. A later assignment is too late at runtime, and an
|
|
2414
|
+
# assignment whose NAME begins inside a quoted argument can be prose. The
|
|
2415
|
+
# small lexer below preserves quoted right-hand sides such as V="/tmp/x"
|
|
2416
|
+
# while excluding an argument such as echo "V=/tmp/x". Any ambiguity fails
|
|
2417
|
+
# closed.
|
|
2418
|
+
RM_ASSIGNMENT_NAME="$name" RM_ASSIGNMENT_COMMAND="$rm_normalized_command_str" \
|
|
2419
|
+
RM_ASSIGNMENT_STATEMENT="$rm_assignment_stmt" \
|
|
2420
|
+
python3 - <<'PY'
|
|
2421
|
+
import os
|
|
2422
|
+
import re
|
|
2423
|
+
import sys
|
|
2424
|
+
|
|
2425
|
+
name = os.environ.get("RM_ASSIGNMENT_NAME", "")
|
|
2426
|
+
command = os.environ.get("RM_ASSIGNMENT_COMMAND", "")
|
|
2427
|
+
statement = os.environ.get("RM_ASSIGNMENT_STATEMENT", "")
|
|
2428
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
|
|
2429
|
+
sys.exit(1)
|
|
2430
|
+
|
|
2431
|
+
rm_pattern = re.compile(
|
|
2432
|
+
r"(?i)(?<![A-Za-z0-9_./-])(?:[A-Za-z0-9_./-]*/)?rm(?=$|[\s;&|()'\"`])",
|
|
2433
|
+
)
|
|
2434
|
+
rm_token = rm_pattern.search(statement)
|
|
2435
|
+
if rm_token is None or not statement or command.count(statement) != 1:
|
|
2436
|
+
sys.exit(1)
|
|
2437
|
+
statement_start = command.find(statement)
|
|
2438
|
+
if statement_start < 0:
|
|
2439
|
+
sys.exit(1)
|
|
2440
|
+
prefix = command[: statement_start + rm_token.start()]
|
|
2441
|
+
|
|
2442
|
+
tokens = []
|
|
2443
|
+
buffer = []
|
|
2444
|
+
quote = None
|
|
2445
|
+
escaped = False
|
|
2446
|
+
started = False
|
|
2447
|
+
started_unquoted = False
|
|
2448
|
+
|
|
2449
|
+
def flush():
|
|
2450
|
+
global buffer, started, started_unquoted
|
|
2451
|
+
if started:
|
|
2452
|
+
tokens.append(("".join(buffer), started_unquoted))
|
|
2453
|
+
buffer = []
|
|
2454
|
+
started = False
|
|
2455
|
+
started_unquoted = False
|
|
2456
|
+
|
|
2457
|
+
for char in prefix:
|
|
2458
|
+
if escaped:
|
|
2459
|
+
buffer.append(char)
|
|
2460
|
+
escaped = False
|
|
2461
|
+
continue
|
|
2462
|
+
if quote is not None:
|
|
2463
|
+
if char == quote:
|
|
2464
|
+
quote = None
|
|
2465
|
+
elif char == "\\" and quote == '"':
|
|
2466
|
+
escaped = True
|
|
2467
|
+
else:
|
|
2468
|
+
buffer.append(char)
|
|
2469
|
+
continue
|
|
2470
|
+
if char in "'\"":
|
|
2471
|
+
if not started:
|
|
2472
|
+
started = True
|
|
2473
|
+
started_unquoted = False
|
|
2474
|
+
quote = char
|
|
2475
|
+
elif char == "\\":
|
|
2476
|
+
if not started:
|
|
2477
|
+
started = True
|
|
2478
|
+
started_unquoted = True
|
|
2479
|
+
escaped = True
|
|
2480
|
+
elif char.isspace() or char in ";&|()":
|
|
2481
|
+
flush()
|
|
2482
|
+
else:
|
|
2483
|
+
if not started:
|
|
2484
|
+
started = True
|
|
2485
|
+
started_unquoted = True
|
|
2486
|
+
buffer.append(char)
|
|
2487
|
+
flush()
|
|
2488
|
+
|
|
2489
|
+
needle = f"{name}="
|
|
2490
|
+
hits = [token[len(needle):] for token, outside in tokens
|
|
2491
|
+
if outside and token.startswith(needle)]
|
|
2492
|
+
distinct = set(hits)
|
|
2493
|
+
if len(distinct) != 1:
|
|
2494
|
+
sys.exit(1)
|
|
2495
|
+
value = hits[0]
|
|
2496
|
+
if not value:
|
|
2497
|
+
sys.exit(1)
|
|
2498
|
+
sys.stdout.write(value)
|
|
2499
|
+
PY
|
|
2500
|
+
}
|
|
2501
|
+
# Rewrite a leading `$NAME` / `${NAME}` in a deletion target to the value the
|
|
2502
|
+
# command itself assigns it, so the target is classified as if the agent had
|
|
2503
|
+
# written that value literally — issue #3106 arm B.
|
|
2504
|
+
#
|
|
2505
|
+
# The guard's standing reason for refusing variable targets is that "unset or
|
|
2506
|
+
# mistyped variables can point anywhere", and that reason survives intact: a
|
|
2507
|
+
# name with no in-command assignment, an ambiguous one, or a value that is
|
|
2508
|
+
# itself a command substitution never resolves and stays refused. What changes
|
|
2509
|
+
# is only that a target the agent could have spelled literally is no longer
|
|
2510
|
+
# refused for being spelled through a variable.
|
|
2511
|
+
resolve_var_prefix() {
|
|
2512
|
+
local token="$1" name="" rest="" value=""
|
|
2513
|
+
case "$token" in
|
|
2514
|
+
'${'*'}'*)
|
|
2515
|
+
name="${token#'${'}"
|
|
2516
|
+
name="${name%%\}*}"
|
|
2517
|
+
rest="${token#'${'"$name"'}'}"
|
|
2518
|
+
;;
|
|
2519
|
+
'$'*)
|
|
2520
|
+
rest="${token#'$'}"
|
|
2521
|
+
name="${rest%%[!A-Za-z0-9_]*}"
|
|
2522
|
+
rest="${rest#"$name"}"
|
|
2523
|
+
;;
|
|
2524
|
+
*) return 1 ;;
|
|
2525
|
+
esac
|
|
2526
|
+
[ -n "$name" ] || return 1
|
|
2527
|
+
value="$(rm_assignment_value "$name")" || return 1
|
|
2528
|
+
# A value that is itself a command substitution names no location the guard
|
|
2529
|
+
# can read, so it never resolves — the refusal stands. `$(…)` already fails
|
|
2530
|
+
# below (its leading `$` yields no variable name); backticks are named here so
|
|
2531
|
+
# both spellings of substitution behave the same way.
|
|
2532
|
+
case "$value" in
|
|
2533
|
+
*'`'*) return 1 ;;
|
|
2534
|
+
esac
|
|
2535
|
+
case "$value" in
|
|
2536
|
+
'"'*'"')
|
|
2537
|
+
value="${value#\"}"
|
|
2538
|
+
value="${value%\"}"
|
|
2539
|
+
;;
|
|
2540
|
+
"'"*"'")
|
|
2541
|
+
value="${value#\'}"
|
|
2542
|
+
value="${value%\'}"
|
|
2543
|
+
;;
|
|
2544
|
+
esac
|
|
2545
|
+
[ -n "$value" ] || return 1
|
|
2546
|
+
printf '%s%s' "$value" "$rest"
|
|
2547
|
+
}
|
|
2548
|
+
# Classify ONE deletion target. Extracted from the token walk so a variable
|
|
2549
|
+
# target can be re-classified through every arm after resolution (#3106),
|
|
2550
|
+
# instead of the variable arm being a terminal verdict of its own.
|
|
2551
|
+
classify_rm_target() {
|
|
2552
|
+
local token="$1" depth="${2:-0}" resolved=""
|
|
2553
|
+
case "$token" in
|
|
2554
|
+
-*) return 0 ;;
|
|
2555
|
+
. | ./)
|
|
2556
|
+
set +f
|
|
2557
|
+
block "recursive forced delete of the current directory (rm -rf .)"
|
|
2558
|
+
;;
|
|
2559
|
+
.git | .git/* | */.git | */.git/*)
|
|
2560
|
+
set +f
|
|
2561
|
+
block "recursive forced delete of the git control plane (.git holds every commit, branch and stash not already pushed; nothing in the working tree can rebuild it)"
|
|
2562
|
+
;;
|
|
2563
|
+
.. | ../* | */.. | */../*)
|
|
2564
|
+
set +f
|
|
2565
|
+
block "recursive forced delete of a path outside the project (.. traversal)"
|
|
2566
|
+
;;
|
|
2567
|
+
'~' | '~'/*)
|
|
2568
|
+
set +f
|
|
2569
|
+
block "recursive forced delete of a home-anchored path (~/…) outside the project"
|
|
2570
|
+
;;
|
|
2571
|
+
/*)
|
|
2572
|
+
case "$token" in
|
|
2573
|
+
"$project_dir" | "$project_dir"/* | "$project_dir_phys" | "$project_dir_phys"/* \
|
|
2574
|
+
| /tmp | /tmp/* | "$tmp_phys" | "$tmp_phys"/* \
|
|
2575
|
+
| /var/tmp | /var/tmp/* | "$var_tmp_phys" | "$var_tmp_phys"/* \
|
|
2576
|
+
| "$tmp_dir_allow" | "$tmp_dir_allow"/* | "$tmp_dir_allow_phys" | "$tmp_dir_allow_phys"/*) : ;;
|
|
2577
|
+
*)
|
|
2578
|
+
set +f
|
|
2579
|
+
block "recursive forced delete of an absolute path outside the project (only the project, /tmp, /var/tmp, and \$TMPDIR are allowed)"
|
|
2580
|
+
;;
|
|
2581
|
+
esac
|
|
2582
|
+
;;
|
|
2583
|
+
'*')
|
|
2584
|
+
# Bare top-level wildcard expands to every entry in the cwd. Guard 1a no
|
|
2585
|
+
# longer bounds `*)` (T4b F1), so a substitution-wrapped `rm -rf *` is
|
|
2586
|
+
# caught here after the trailing-closer strip peels `*)"` down to `*`.
|
|
2587
|
+
set +f
|
|
2588
|
+
block "recursive forced delete of a top-level wildcard (rm -rf *)"
|
|
2589
|
+
;;
|
|
2590
|
+
*'$'*)
|
|
2591
|
+
case "$token" in
|
|
2592
|
+
'$TMPDIR' | '$TMPDIR'/* | '${TMPDIR}' | '${TMPDIR}'/*) : ;;
|
|
2593
|
+
*)
|
|
2594
|
+
if [ "$depth" -lt 4 ] \
|
|
2595
|
+
&& resolved="$(resolve_var_prefix "$token")" \
|
|
2596
|
+
&& [ -n "$resolved" ] \
|
|
2597
|
+
&& [ "$resolved" != "$token" ]; then
|
|
2598
|
+
classify_rm_target "$resolved" $((depth + 1))
|
|
2599
|
+
else
|
|
2600
|
+
set +f
|
|
2601
|
+
block "recursive forced delete of a variable-expanded target (unset or mistyped variables can point anywhere; \$TMPDIR is the only sanctioned dynamic target)"
|
|
2602
|
+
fi
|
|
2603
|
+
;;
|
|
2604
|
+
esac
|
|
2605
|
+
;;
|
|
2606
|
+
esac
|
|
2607
|
+
return 0
|
|
2608
|
+
}
|
|
2609
|
+
# Segmented in the PARENT shell rather than in a process substitution, and the
|
|
2610
|
+
# `|| true` that used to sit on the feeder is gone. Both together were the same
|
|
2611
|
+
# three-way read as a bare `grep -q` condition (see scan() above), just wearing
|
|
2612
|
+
# a loop: a grep that could not run produced no lines, the body never executed,
|
|
2613
|
+
# and the guard was silently skipped with the command ALLOWED. `exit` inside a
|
|
2614
|
+
# process substitution cannot deny anything either — it ends that subshell, not
|
|
2615
|
+
# this hook.
|
|
2616
|
+
rm_segments_status=0
|
|
2617
|
+
rm_segments="$(printf '%s' "$rm_normalized_command_str" | tr '&|;' '\n' \
|
|
2618
|
+
| grep -Ei -- "$RM_CMD"'([[:space:]]|$)')" || rm_segments_status=$?
|
|
2619
|
+
case "$rm_segments_status" in
|
|
2620
|
+
0 | 1) ;;
|
|
2621
|
+
*) scan_failed "$rm_segments_status" ;;
|
|
2622
|
+
esac
|
|
2623
|
+
while IFS= read -r rm_stmt; do
|
|
2624
|
+
# A here-string over empty input still yields one empty line.
|
|
2625
|
+
[ -n "$rm_stmt" ] || continue
|
|
2626
|
+
if ! scan -Ei "$rm_stmt" "$RM_RF_CLUSTER" \
|
|
2627
|
+
&& ! scan -Ei "$rm_stmt" "$RM_RF_SPLIT"; then
|
|
2628
|
+
continue
|
|
2629
|
+
fi
|
|
2630
|
+
# Guard 1: catastrophic target named in the SAME statement as the rm -rf.
|
|
2631
|
+
if scan -E "$rm_stmt" "$RM_CATASTROPHIC_TARGET"; then
|
|
2632
|
+
block "recursive forced delete of a root, home, or wildcard path (rm -rf)"
|
|
2633
|
+
fi
|
|
2634
|
+
# Physical-path comparison (pwd -P): on macOS $HOME or the cwd may arrive via
|
|
2635
|
+
# a symlink (/var → /private/var), and a string compare would miss the match.
|
|
2636
|
+
if [ -n "${HOME:-}" ] \
|
|
2637
|
+
&& [ "$(pwd -P)" = "$(cd -- "$HOME" 2>/dev/null && pwd -P)" ]; then
|
|
2638
|
+
block "recursive forced delete while the working directory is \$HOME (cd into a project first)"
|
|
2639
|
+
fi
|
|
2640
|
+
# Scope the walk to the quoting region that actually contains the rm (#3106
|
|
2641
|
+
# arm A). Unquoted and run-leading invocations get the whole statement back,
|
|
2642
|
+
# so this is a no-op for every real delete.
|
|
2643
|
+
rm_assignment_stmt="$rm_stmt"
|
|
2644
|
+
rm_walk_status=0
|
|
2645
|
+
rm_walk_text="$(rm_scan_scope "$rm_stmt")" || rm_walk_status=$?
|
|
2646
|
+
if [ "$rm_walk_status" -ne 0 ]; then
|
|
2647
|
+
# The substitution above runs in a subshell. A denial inside it cannot stop
|
|
2648
|
+
# this parent hook, so carry the failure out explicitly and deny here.
|
|
2649
|
+
scan_failed "$rm_walk_status"
|
|
2650
|
+
fi
|
|
2651
|
+
while IFS= read -r rm_scope; do
|
|
2652
|
+
set -f
|
|
2653
|
+
seen_rm=0
|
|
2654
|
+
for raw_token in $rm_scope; do
|
|
2655
|
+
token="$(strip_subst_wrappers "$raw_token")"
|
|
2656
|
+
if [ "$seen_rm" -eq 0 ]; then
|
|
2657
|
+
# Path-prefixed spellings (`/bin/rm`, `./rm`) are still rm (F2).
|
|
2658
|
+
case "$token" in
|
|
2659
|
+
rm | */rm) seen_rm=1 ;;
|
|
2660
|
+
esac
|
|
2661
|
+
continue
|
|
2662
|
+
fi
|
|
2663
|
+
classify_rm_target "$token"
|
|
2664
|
+
done
|
|
2665
|
+
set +f
|
|
2666
|
+
done <<< "$rm_walk_text"
|
|
2667
|
+
done <<<"$rm_segments"
|
|
2668
|
+
|
|
2669
|
+
# 2. Force-pushing a protected branch. `--force-with-lease` is the safe,
|
|
2670
|
+
# non-clobbering alternative and is intentionally NOT blocked. Deliberate
|
|
2671
|
+
# divergence from upstream (which blocks force-push on ANY branch):
|
|
2672
|
+
# feature-branch force-push is a sanctioned rebase-and-push agent workflow.
|
|
2673
|
+
#
|
|
2674
|
+
# The force flag AND the protected-branch name must appear in the SAME
|
|
2675
|
+
# `git push` statement. Checking them independently over the whole command is
|
|
2676
|
+
# a false-positive magnet: an unrelated `-f` (a `[ -f file ]` test, `rm -f`,
|
|
2677
|
+
# `grep -f`, `tail -f`) plus an unrelated protected name (`--base main`,
|
|
2678
|
+
# `origin/main`, `git fetch origin main`) alongside any feature-branch
|
|
2679
|
+
# `git push` would wrongly block. So split the command into statements
|
|
2680
|
+
# (`;`, `&&`, `||`, `|`, newlines), keep only the `git push` segments, and
|
|
2681
|
+
# inspect each in isolation — a real `git push --force origin main` still
|
|
2682
|
+
# matches, while a feature-branch push next to `[ -f ]`/`--base main` passes.
|
|
2683
|
+
# Same reason as the rm segmentation above: a feeder that swallows grep's error
|
|
2684
|
+
# skips the guard entirely and allows the command.
|
|
2685
|
+
push_segments_status=0
|
|
2686
|
+
push_segments="$(printf '%s' "$normalized_command_str" | tr '&|;' '\n' \
|
|
2687
|
+
| grep -Ei -- "${GIT_CMD}push\b")" || push_segments_status=$?
|
|
2688
|
+
case "$push_segments_status" in
|
|
2689
|
+
0 | 1) ;;
|
|
2690
|
+
*) scan_failed "$push_segments_status" ;;
|
|
2691
|
+
esac
|
|
2692
|
+
while IFS= read -r push_stmt; do
|
|
2693
|
+
[ -n "$push_stmt" ] || continue
|
|
2694
|
+
if scan -Ei "$push_stmt" \
|
|
2695
|
+
'(--force([[:space:]]|=|$)|[[:space:]]-f([[:space:]]|$))' \
|
|
2696
|
+
&& ! scan -Ei "$push_stmt" '--force-with-lease' \
|
|
2697
|
+
&& scan -Ei "$push_stmt" \
|
|
2698
|
+
'(^|[^[:alnum:]_/-])(main|master|production|prod|release)([^[:alnum:]_/-]|$)'; then
|
|
2699
|
+
block "force-pushing a protected branch (use --force-with-lease, or push a feature branch)"
|
|
2700
|
+
fi
|
|
2701
|
+
done <<<"$push_segments"
|
|
2702
|
+
|
|
2703
|
+
# 3. `git reset --hard` / `git reset --merge` while the working tree has
|
|
2704
|
+
# uncommitted changes — both silently discard them. Only blocks when the tree
|
|
2705
|
+
# is actually dirty, so a clean-tree reset (a legitimate workflow) passes.
|
|
2706
|
+
# Deliberate divergence from upstream's unconditional block; the accepted
|
|
2707
|
+
# residual risk (dirty check runs at hook time in the hook's cwd) is
|
|
2708
|
+
# documented in the header.
|
|
2709
|
+
if matches "${GIT_CMD}"'reset\b.*--(hard|merge)\b'; then
|
|
2710
|
+
if git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|
|
2711
|
+
&& [ -n "$(git status --porcelain 2>/dev/null)" ]; then
|
|
2712
|
+
block "git reset --hard/--merge on a dirty working tree would discard uncommitted changes (commit first, or preserve as below)" "$PRESERVE_GUIDANCE
|
|
2713
|
+
|
|
2714
|
+
$DESTRUCTIVE_GUIDANCE"
|
|
2715
|
+
fi
|
|
2716
|
+
fi
|
|
2717
|
+
|
|
2718
|
+
# 3a. `git read-tree` with BOTH an update flag and `--reset`, on the same dirty
|
|
2719
|
+
# tree — the plumbing spelling of guard 3 (issue #3978). It resets the index
|
|
2720
|
+
# and writes the result into the working tree, discarding tracked
|
|
2721
|
+
# modifications: the identical destruction, reached through a command that
|
|
2722
|
+
# carries no `--hard` and no `reset` subcommand, so every pattern in
|
|
2723
|
+
# guard 3 misses it. A guard an agent can walk around is a guard that stops
|
|
2724
|
+
# the honest caller and not the one looking for a way through, and this
|
|
2725
|
+
# spelling is strictly worse than the blocked one — it discards WITHOUT
|
|
2726
|
+
# preserving, which is exactly the property guard 3's remedy supplies. Both
|
|
2727
|
+
# refusals therefore print the same $PRESERVE_GUIDANCE, so what they teach
|
|
2728
|
+
# cannot drift apart.
|
|
2729
|
+
#
|
|
2730
|
+
# `--reset` is the discriminator, not `-u`, and the pairing is deliberate:
|
|
2731
|
+
#
|
|
2732
|
+
# - `read-tree` with no update flag touches the INDEX only and destroys
|
|
2733
|
+
# nothing in the working tree, so a blanket `read-tree` block would be
|
|
2734
|
+
# over-broad.
|
|
2735
|
+
# - `read-tree -m -u` (a merge without `--reset`) is safe by git's own
|
|
2736
|
+
# construction: it REFUSES rather than overwrite a locally modified file
|
|
2737
|
+
# ("entry would be overwritten by merge"). `--reset` is precisely the flag
|
|
2738
|
+
# that switches that refusal off.
|
|
2739
|
+
#
|
|
2740
|
+
# So the destructive combination is an update flag AND `--reset` together,
|
|
2741
|
+
# in either order. The update flag is matched in the checkout guard's idiom
|
|
2742
|
+
# so a bundled short option (`-iu`) cannot dodge the anchor, with leading
|
|
2743
|
+
# whitespace required so a `--index-output=/tmp/out-u` style value cannot
|
|
2744
|
+
# read as the flag. Same conditional dirty-tree shape and same accepted
|
|
2745
|
+
# residual risk as guard 3: the probe runs in the hook's cwd at hook time.
|
|
2746
|
+
readonly GIT_READ_TREE="${GIT_CMD}read-tree"
|
|
2747
|
+
if matches "${GIT_READ_TREE}"'[^;&|]*[[:space:]]--reset([[:space:]]|$)' \
|
|
2748
|
+
&& matches "${GIT_READ_TREE}"'[^;&|]*[[:space:]](-[[:alnum:]]*u[[:alnum:]]*|--update)([[:space:]]|=|$)'; then
|
|
2749
|
+
if git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|
|
2750
|
+
&& [ -n "$(git status --porcelain 2>/dev/null)" ]; then
|
|
2751
|
+
block "git read-tree -u --reset on a dirty working tree would discard uncommitted changes (commit first, or preserve as below)" "$PRESERVE_GUIDANCE
|
|
2752
|
+
|
|
2753
|
+
$DESTRUCTIVE_GUIDANCE"
|
|
2754
|
+
fi
|
|
2755
|
+
fi
|
|
2756
|
+
|
|
2757
|
+
# 3b. `git rebase --abort` / `--quit` ONLY while human/agent conflict
|
|
2758
|
+
# resolutions exist (issue #1956). `--abort` restores the pre-rebase branch
|
|
2759
|
+
# and discards every resolution; `--quit` deletes the rebase bookkeeping
|
|
2760
|
+
# (head-name, todo) while stranding a detached HEAD, making recovery
|
|
2761
|
+
# ambiguous — both are treated the same, conditionally. A rebase state with
|
|
2762
|
+
# nothing human-made in it (a clean-pick wedge, or a conflict stop nobody
|
|
2763
|
+
# has touched) is agent-recoverable, so aborting it stays ALLOWED.
|
|
2764
|
+
# Discriminator (empirical, git 2.53): diff the worktree/index against the
|
|
2765
|
+
# AUTO_MERGE ref (the merge-ort recorded conflicted tree). Abort-safe ⇔
|
|
2766
|
+
# worktree diff is quiet AND (the cached diff is quiet OR unmerged index
|
|
2767
|
+
# entries exist — an untouched conflict stop has unmerged entries that make
|
|
2768
|
+
# the cached diff non-quiet without any human edit). Fail CLOSED on the
|
|
2769
|
+
# rebase-apply backend (no AUTO_MERGE contract) and on a missing AUTO_MERGE
|
|
2770
|
+
# ref while rebase-merge state exists. Same accepted residual risk as
|
|
2771
|
+
# guard 3: the probes run in the hook's cwd at hook time.
|
|
2772
|
+
if matches "${GIT_CMD}"'rebase'"${GIT_TOKENS}"'--(abort|quit)([[:space:]]|$)'; then
|
|
2773
|
+
rebase_apply_dir="$(git rev-parse --git-path rebase-apply 2>/dev/null || true)"
|
|
2774
|
+
rebase_merge_dir="$(git rev-parse --git-path rebase-merge 2>/dev/null || true)"
|
|
2775
|
+
if [ -n "$rebase_apply_dir" ] && [ -d "$rebase_apply_dir" ]; then
|
|
2776
|
+
block "git rebase --abort/--quit on an apply-backend rebase cannot prove no conflict resolutions would be lost (fail closed; finish or continue the rebase instead)"
|
|
2777
|
+
fi
|
|
2778
|
+
if [ -n "$rebase_merge_dir" ] && [ -d "$rebase_merge_dir" ]; then
|
|
2779
|
+
if ! git rev-parse -q --verify AUTO_MERGE >/dev/null 2>&1; then
|
|
2780
|
+
block "git rebase --abort/--quit with an unresolvable AUTO_MERGE ref cannot prove no conflict resolutions would be lost (fail closed)"
|
|
2781
|
+
fi
|
|
2782
|
+
if ! git diff --quiet AUTO_MERGE 2>/dev/null; then
|
|
2783
|
+
block "git rebase --abort/--quit would discard conflict resolutions in the working tree (finish resolving and run git rebase --continue instead)"
|
|
2784
|
+
fi
|
|
2785
|
+
unmerged_entries="$(git ls-files -u 2>/dev/null || true)"
|
|
2786
|
+
if [ -z "$unmerged_entries" ] \
|
|
2787
|
+
&& ! git diff --cached --quiet AUTO_MERGE 2>/dev/null; then
|
|
2788
|
+
block "git rebase --abort/--quit would discard staged conflict resolutions (finish resolving and run git rebase --continue instead)"
|
|
2789
|
+
fi
|
|
2790
|
+
fi
|
|
2791
|
+
fi
|
|
2792
|
+
|
|
2793
|
+
# 4. `git checkout` worktree discards: the `--` pathspec form (with or without a
|
|
2794
|
+
# ref), `-f/--force`, `--pathspec-from-file`, and bare `git checkout .`.
|
|
2795
|
+
# Blocking bare `.` exceeds upstream (which allows any single positional) —
|
|
2796
|
+
# issue #1960 names it explicitly: it discards the entire tree. Branch
|
|
2797
|
+
# switches and creation (`-b`/`-B`) stay allowed.
|
|
2798
|
+
readonly GIT_CHECKOUT="${GIT_CMD}checkout"
|
|
2799
|
+
if matches "${GIT_CHECKOUT}${GIT_TOKENS}"'--([[:space:]]|$)' \
|
|
2800
|
+
|| matches "${GIT_CHECKOUT}${GIT_TOKENS}"'(-[[:alnum:]]*f[[:alnum:]]*|--force)([[:space:]]|=|$)' \
|
|
2801
|
+
|| matches "${GIT_CHECKOUT}"'[[:space:]][^;&|]*--pathspec-from-file' \
|
|
2802
|
+
|| matches "${GIT_CHECKOUT}"'[[:space:]]+\.(/)?([[:space:]]|$)'; then
|
|
2803
|
+
block "git checkout discarding local changes (--, -f/--force, --pathspec-from-file, or bare .)" "$PRESERVE_GUIDANCE
|
|
2804
|
+
|
|
2805
|
+
$DESTRUCTIVE_GUIDANCE"
|
|
2806
|
+
fi
|
|
2807
|
+
|
|
2808
|
+
# 5. `git switch` discards: `--discard-changes` and its `-f/--force` aliases.
|
|
2809
|
+
# Carries the same guidance as guards 3 and 4 (issue #3696). It discards the
|
|
2810
|
+
# same uncommitted work by the same mechanism, so a refusal that named no
|
|
2811
|
+
# remedy left the reader to invent one — and the one they invent is the
|
|
2812
|
+
# stash, which is the single thing $PRESERVE_GUIDANCE exists to steer off.
|
|
2813
|
+
if matches "${GIT_CMD}switch${GIT_TOKENS}"'(--discard-changes|--force|-f)([[:space:]]|=|$)'; then
|
|
2814
|
+
block "git switch discarding local changes (--discard-changes/-f/--force)" "$PRESERVE_GUIDANCE
|
|
2815
|
+
|
|
2816
|
+
$DESTRUCTIVE_GUIDANCE"
|
|
2817
|
+
fi
|
|
2818
|
+
|
|
2819
|
+
# 6. `git restore` defaults to overwriting the WORKTREE — a silent discard. Only
|
|
2820
|
+
# the pure staging-area form is safe: `--staged` present and `--worktree`
|
|
2821
|
+
# absent. Two conditions, because an ERE has no lookahead: `--staged
|
|
2822
|
+
# --worktree` still discards, so `--worktree` blocks regardless of `--staged`.
|
|
2823
|
+
# Carries the same guidance as guards 3 and 4 (issue #3696). The permitted
|
|
2824
|
+
# alternative this reason names — `git restore --staged <path>` — is real,
|
|
2825
|
+
# but it only unstages; it leaves the worktree exactly as dirty as it found
|
|
2826
|
+
# it. So the operator who reached for `git restore` to CLEAN a tree is told
|
|
2827
|
+
# of an operation that does not do what they came for, and was told nothing
|
|
2828
|
+
# about what does. This is the refusal #3696 was filed from.
|
|
2829
|
+
if matches "${GIT_CMD}"'restore([[:space:]]|$)'; then
|
|
2830
|
+
if matches "${GIT_CMD}"'restore[^;&|]*--worktree' \
|
|
2831
|
+
|| ! matches "${GIT_CMD}"'restore[^;&|]*--staged'; then
|
|
2832
|
+
block "git restore overwriting worktree files (only 'git restore --staged <path>' without --worktree is allowed)" "$PRESERVE_GUIDANCE
|
|
2833
|
+
|
|
2834
|
+
$DESTRUCTIVE_GUIDANCE"
|
|
2835
|
+
fi
|
|
2836
|
+
fi
|
|
2837
|
+
|
|
2838
|
+
# 7. `git stash drop` / `git stash clear` destroy stashed work. push/pop/list/
|
|
2839
|
+
# apply stay ALLOWED — this guard moves no verdict on them — but they are no
|
|
2840
|
+
# longer RECOMMENDED anywhere in this file, and this comment used to say the
|
|
2841
|
+
# opposite (issue #3692). It called them "the safe alternatives the reset
|
|
2842
|
+
# guard recommends", which stopped being true when #3722 rewrote guards 3
|
|
2843
|
+
# and 4 to point at $PRESERVE_GUIDANCE instead. A stale comment endorsing
|
|
2844
|
+
# the stash is the same hazard as a stale message doing it: the next reader
|
|
2845
|
+
# takes the file's word for what the file does. See $PRESERVE_GUIDANCE for
|
|
2846
|
+
# why one stack shared by every worktree of a clone makes push/pop unsafe
|
|
2847
|
+
# under this project's concurrency.
|
|
2848
|
+
if matches "${GIT_CMD}"'stash[[:space:]]+(drop|clear)([[:space:]]|$)'; then
|
|
2849
|
+
block "git stash drop/clear destroys stashed work"
|
|
2850
|
+
fi
|
|
2851
|
+
|
|
2852
|
+
# 8. `git clean` with a force flag wipes untracked files. A dry-run flag
|
|
2853
|
+
# (`-n`/`--dry-run`) ANYWHERE wins — git itself performs no deletion under
|
|
2854
|
+
# dry-run, so `git clean -fdn` is a safe preview.
|
|
2855
|
+
readonly GIT_CLEAN="${GIT_CMD}clean"
|
|
2856
|
+
if matches "${GIT_CLEAN}${GIT_TOKENS}"'(-[[:alnum:]]*f[[:alnum:]]*|--force)([[:space:]]|=|$)' \
|
|
2857
|
+
&& ! matches "${GIT_CLEAN}${GIT_TOKENS}"'(-[[:alnum:]]*n[[:alnum:]]*|--dry-run)([[:space:]]|=|$)'; then
|
|
2858
|
+
block "git clean --force deletes untracked files (preview with git clean -n first)"
|
|
2859
|
+
fi
|
|
2860
|
+
|
|
2861
|
+
# 9. `git branch` force-delete loses unmerged commits: `-D`, `-d` combined with
|
|
2862
|
+
# `-f` (clustered or split), or `--delete` + `--force`. Case-SENSITIVE so the
|
|
2863
|
+
# safe `-d` (which refuses unmerged work) and rename `-m` stay allowed.
|
|
2864
|
+
readonly GIT_BRANCH="${GIT_CMD}branch"
|
|
2865
|
+
if matches_cs "${GIT_BRANCH}${GIT_TOKENS}"'-[[:alnum:]]*D[[:alnum:]]*([[:space:]]|$)' \
|
|
2866
|
+
|| { matches_cs "${GIT_BRANCH}${GIT_TOKENS}"'-[[:alnum:]]*d[[:alnum:]]*([[:space:]]|$)' \
|
|
2867
|
+
&& matches_cs "${GIT_BRANCH}${GIT_TOKENS}"'-[[:alnum:]]*f[[:alnum:]]*([[:space:]]|$)'; } \
|
|
2868
|
+
|| { matches "${GIT_BRANCH}"'[^;&|]*--delete' && matches "${GIT_BRANCH}"'[^;&|]*--force'; }; then
|
|
2869
|
+
block "git branch force-delete (-D) loses unmerged commits (use -d, which refuses unmerged work)"
|
|
2870
|
+
fi
|
|
2871
|
+
|
|
2872
|
+
# 10. Cheap destructive-ref guards: `git tag -d` (tags are shared refs),
|
|
2873
|
+
# `git reflog delete` (erases recovery history), and
|
|
2874
|
+
# `git worktree remove --force` (discards a dirty worktree). Tag deletion is
|
|
2875
|
+
# case-SENSITIVE so annotated-tag `-a` and message `-m` flags never match.
|
|
2876
|
+
if matches_cs "${GIT_CMD}tag${GIT_TOKENS}"'(-[[:alnum:]]*d[[:alnum:]]*([[:space:]]|$)|--delete([[:space:]]|=|$))'; then
|
|
2877
|
+
block "git tag -d deletes a shared ref"
|
|
2878
|
+
fi
|
|
2879
|
+
if matches "${GIT_CMD}"'reflog[[:space:]]+delete([[:space:]]|$)'; then
|
|
2880
|
+
block "git reflog delete erases recovery history"
|
|
2881
|
+
fi
|
|
2882
|
+
if matches "${GIT_CMD}"'worktree[[:space:]]+remove[^;&|]*(--force([[:space:]]|=|$)|[[:space:]]-[[:alnum:]]*f[[:alnum:]]*([[:space:]]|$))'; then
|
|
2883
|
+
block "git worktree remove --force discards a dirty worktree (remove without --force, which refuses dirty trees)"
|
|
2884
|
+
fi
|
|
2885
|
+
|
|
2886
|
+
# 11. Deletion through find/xargs, where the target never appears as a literal
|
|
2887
|
+
# argument: `find … -delete`, `find … -exec rm -rf`, and `xargs … rm -rf`
|
|
2888
|
+
# (targets arrive from dynamic stdin — unauditable). Plain `rm` (no
|
|
2889
|
+
# recursive+force) on find/xargs output stays allowed for normal cleanups.
|
|
2890
|
+
if matches '(^|[^[:alnum:]_./-])find[[:space:]][^;&|]*[[:space:]]-delete([[:space:]]|$)'; then
|
|
2891
|
+
block "find -delete removes files tree-wide (use -print to preview, or an explicit rm on reviewed paths)"
|
|
2892
|
+
fi
|
|
2893
|
+
if matches '(^|[^[:alnum:]_./-])find[[:space:]][^;&|]*-exec[[:space:]]+rm[[:space:]]+(-[[:alnum:]]*r[[:alnum:]]*f|-[[:alnum:]]*f[[:alnum:]]*r)([[:space:]]|$)'; then
|
|
2894
|
+
block "find -exec rm -rf performs a recursive forced delete on unreviewed paths"
|
|
2895
|
+
fi
|
|
2896
|
+
# A command that reads its operands from STDIN and runs a program on them. The
|
|
2897
|
+
# targets are therefore whatever the upstream stage emitted, which is exactly
|
|
2898
|
+
# what makes a recursive forced delete behind one unauditable.
|
|
2899
|
+
#
|
|
2900
|
+
# Three fail-opens closed here (issue #3494), all of them the same mistake —
|
|
2901
|
+
# this rule was written standalone instead of inheriting vocabulary the file
|
|
2902
|
+
# already had:
|
|
2903
|
+
#
|
|
2904
|
+
# 1. The old prefix was `(^|[^[:alnum:]_./-])xargs`, whose excluded class
|
|
2905
|
+
# contains `/`, so the character before `xargs` in `/usr/bin/xargs` was
|
|
2906
|
+
# itself disqualifying and a PATH-SPELLED invocation never matched.
|
|
2907
|
+
# `gxargs` — the GNU findutils spelling shipped by macOS package managers —
|
|
2908
|
+
# failed the same way on the alphanumeric side. RM_CMD above solved this
|
|
2909
|
+
# exact problem for `rm` by putting the boundary before the WHOLE path and
|
|
2910
|
+
# making the directory part optional; that shape is reused verbatim.
|
|
2911
|
+
# 2. `parallel` was absent. It is a drop-in `xargs` substitute reaching the
|
|
2912
|
+
# same delete on the same unaudited stdin, and guard 14 below already had
|
|
2913
|
+
# to name it in its own evidence — it was missing only here.
|
|
2914
|
+
# 3. The flag set was a short-cluster-only copy, so `rm --recursive --force`
|
|
2915
|
+
# was allowed where `rm -rf` was refused. It now uses the SAME tails as the
|
|
2916
|
+
# rm walk, which is what makes that class of divergence unrepeatable.
|
|
2917
|
+
readonly STDIN_DELETER='(^|[^[:alnum:]_./-])([[:alnum:]_./-]*/)?(g?xargs|parallel)[[:space:]]([^;&|]*[[:space:]])?rm'
|
|
2918
|
+
if matches "$STDIN_DELETER$RM_RF_TAIL_CLUSTER" \
|
|
2919
|
+
|| matches "$STDIN_DELETER$RM_RF_TAIL_SPLIT"; then
|
|
2920
|
+
block "xargs/parallel rm -rf performs a recursive forced delete on dynamic stdin input"
|
|
2921
|
+
fi
|
|
2922
|
+
|
|
2923
|
+
# 12. Disk destroyers, always on (exceeds upstream, which only scans these
|
|
2924
|
+
# inside interpreter one-liners): writing to a raw /dev node via dd,
|
|
2925
|
+
# formatting a device with mkfs, and shred's unrecoverable overwrite.
|
|
2926
|
+
# dd/mkfs against regular files (imaging) stays allowed.
|
|
2927
|
+
if matches '(^|[^[:alnum:]_./-])dd[[:space:]]+([^;&|]*[[:space:]])?of=/dev/'; then
|
|
2928
|
+
block "dd writing to a raw device (of=/dev/…) destroys it"
|
|
2929
|
+
fi
|
|
2930
|
+
if matches '(^|[^[:alnum:]_./-])mkfs(\.[[:alnum:]]+)?[[:space:]][^;&|]*/dev/'; then
|
|
2931
|
+
block "mkfs formatting a device erases it"
|
|
2932
|
+
fi
|
|
2933
|
+
if matches '(^|[^[:alnum:]_./-])shred([[:space:]]|$)'; then
|
|
2934
|
+
block "shred overwrites files unrecoverably"
|
|
2935
|
+
fi
|
|
2936
|
+
|
|
2937
|
+
# 13. Dropping or truncating a database / schema / table (Lisa-only guard —
|
|
2938
|
+
# upstream has no SQL protection; keep).
|
|
2939
|
+
#
|
|
2940
|
+
# The two arms used to be asymmetric, and only the lopsided one produced false
|
|
2941
|
+
# positives (issue #3530). The `drop` arm requires a following keyword —
|
|
2942
|
+
# `drop database|schema|table` — so `drop the ball` never matched it. The
|
|
2943
|
+
# `truncate` arm made that keyword OPTIONAL:
|
|
2944
|
+
#
|
|
2945
|
+
# truncate[[:space:]]+(table[[:space:]]+)?[[:alnum:]_."`]+
|
|
2946
|
+
#
|
|
2947
|
+
# which reduces to "`truncate`, whitespace, any identifier-ish word", so
|
|
2948
|
+
# `truncate the board`, `truncate results` and `truncate output` all matched.
|
|
2949
|
+
# A session was refused for a bug-report TITLE describing a connection that
|
|
2950
|
+
# truncates results — and a title has no escape hatch, because the remedy this
|
|
2951
|
+
# hook prints at the wall (write the text to a file, pass it BY PATH) covers a
|
|
2952
|
+
# body, while the title flag of an issue-filing CLI is necessarily inline. The
|
|
2953
|
+
# only exit left was to reword, which is what trains people to route around a
|
|
2954
|
+
# guard.
|
|
2955
|
+
#
|
|
2956
|
+
# The fix keeps the coverage and drops the English, in three arms.
|
|
2957
|
+
#
|
|
2958
|
+
# ARM 1 — `drop`, byte-identical to before. It was already correct, and a
|
|
2959
|
+
# rewrite that unified the arms could only make it permissive.
|
|
2960
|
+
readonly SQL_DROP='\bdrop[[:space:]]+(database|schema|table)\b'
|
|
2961
|
+
# ARM 2 — `truncate` carrying its own evidence that it is a statement rather
|
|
2962
|
+
# than a verb: the TABLE keyword (what the drop arm demands), a
|
|
2963
|
+
# schema-qualified name, a quoted identifier, a trailing `;`, or one of the
|
|
2964
|
+
# tail keywords that only ever follow a TRUNCATE. English has none of these.
|
|
2965
|
+
readonly SQL_TRUNCATE_TAIL='(cascade|restrict|restart[[:space:]]+identity|continue[[:space:]]+identity)([^[:alnum:]_]|$)'
|
|
2966
|
+
readonly SQL_TRUNCATE_EVIDENCE='\btruncate[[:space:]]+(table[[:space:]]+[[:alnum:]_."`]+|(only[[:space:]]+)?([[:alnum:]_]+\.[[:alnum:]_."`]+|["`][[:alnum:]_. ]+["`]|[[:alnum:]_]+[[:space:]]*(;|'"$SQL_TRUNCATE_TAIL"')))'
|
|
2967
|
+
# ARM 3 — the permissive-dialect `TRUNCATE <name>` with none of that evidence.
|
|
2968
|
+
# Deleting this shape is what a lazy fix does: it passes every prose scenario
|
|
2969
|
+
# while silently removing the coverage a regression fixture already asserts
|
|
2970
|
+
# (`mysql -e 'TRUNCATE sessions'`). So it is recognized by CONTEXT instead —
|
|
2971
|
+
# the same command must also invoke a command-line database client. Prose in a
|
|
2972
|
+
# tracker filing or a commit message invokes none, which is exactly the
|
|
2973
|
+
# difference the text scan could not see on its own.
|
|
2974
|
+
readonly SQL_CLIENT_TRUNCATE='(^|[;&|()][[:space:]]*)([[:alnum:]_./-]*/)?(psql|pgcli|mysql|mysqladmin|mysqlsh|mariadb|mycli|sqlite3|sqlcmd|cqlsh|clickhouse-client|cockroach|mongosh|duckdb|usql)([[:space:]]+[^;&|()]*)?[[:space:]]+(-e|-c|--command|--eval)(=|[[:space:]]+)[^;&|()]*\btruncate[[:space:]]+(only[[:space:]]+)?[[:alnum:]_."`]'
|
|
2975
|
+
# Residual, accepted and stated: a bare `TRUNCATE <name>` with no terminator,
|
|
2976
|
+
# no qualification and no recognized client in the same command goes unmatched
|
|
2977
|
+
# — the same trade the `drop` arm has always made for `drop the ball`. No DROP
|
|
2978
|
+
# shape is weakened, and the guard still fails in the safe direction.
|
|
2979
|
+
if matches "$SQL_DROP" \
|
|
2980
|
+
|| matches "$SQL_TRUNCATE_EVIDENCE" \
|
|
2981
|
+
|| matches "$SQL_CLIENT_TRUNCATE"; then
|
|
2982
|
+
block "destructive SQL (DROP/TRUNCATE) detected"
|
|
2983
|
+
fi
|
|
2984
|
+
|
|
2985
|
+
# 14. The git control plane. A recursive forced delete of `.git` destroys every
|
|
2986
|
+
# commit, branch and stash that is not already pushed — the one directory whose
|
|
2987
|
+
# loss is not recoverable from the working tree.
|
|
2988
|
+
#
|
|
2989
|
+
# This is not a new principle, it is a hole in one the guard already holds. Rule
|
|
2990
|
+
# 4 above already refuses `rm -rf "$SOMEDIR"` and paths outside the project;
|
|
2991
|
+
# `rm -rf .git` was permitted only because nothing named it. Measured before
|
|
2992
|
+
# this rule existed: `rm -rf .git`, `rm -rf ./.git` and `rm -rf .git/objects`
|
|
2993
|
+
# were all ALLOWED while the variable-expanded case was correctly blocked.
|
|
2994
|
+
#
|
|
2995
|
+
# `.git` must be a whole path component. `.gitignore`, `.gitattributes`,
|
|
2996
|
+
# `.github/` and a `.git-old` backup are ordinary files a project deletes on
|
|
2997
|
+
# purpose, and blocking those would be the collision that gets a guard switched
|
|
2998
|
+
# off. The trailing `([/[:space:]'"$qc"']|$)` is what draws that line.
|
|
2999
|
+
readonly GIT_CONTROL_PLANE='(^|[[:space:]='"$qc"'./])\.git([/[:space:]'"$qc"']|$)'
|
|
3000
|
+
# This guard is the one segment walk that pairs TWO facts found in different
|
|
3001
|
+
# places: the protected target (`.git`) and the destructive verb (a recursive
|
|
3002
|
+
# forced delete). That makes its segmentation load-bearing in a way the rm and
|
|
3003
|
+
# push walks' is not — those two read a verb and its own operands, which are
|
|
3004
|
+
# always in one pipeline stage, so splitting on `|` costs them nothing.
|
|
3005
|
+
#
|
|
3006
|
+
# Here it cost everything. The old feeder was `tr '&|;' '\n'`, which cut a
|
|
3007
|
+
# PIPELINE into pieces before the pairing question was asked, and a pipeline is
|
|
3008
|
+
# exactly how a delete reaches a target named upstream of it:
|
|
3009
|
+
# `find .git -type f -print0 | xargs -0 rm -rf` became `find .git -type f
|
|
3010
|
+
# -print0` (a target, no verb) and `xargs -0 rm -rf` (a verb, no target), and
|
|
3011
|
+
# neither half could answer. Measured before this fix, with the blanket
|
|
3012
|
+
# `xargs rm -rf` rule masking the reported shape: `find .git -type d | parallel
|
|
3013
|
+
# rm -rf`, `… | /usr/bin/xargs rm -rf`, `… | xargs -0 -- rm --recursive
|
|
3014
|
+
# --force`, `… 2>&1 | parallel rm -rf`, `… |& parallel rm -rf` and
|
|
3015
|
+
# `cat .git/config | grep url | rm -rf refs` were ALL allowed (CodySwannGT/lisa#3320).
|
|
3016
|
+
#
|
|
3017
|
+
# So segment on STATEMENT boundaries and nothing else. A pipe joins stages of
|
|
3018
|
+
# one statement; `;`, `&`, `&&` and `||` end one. Three spellings have to be
|
|
3019
|
+
# normalized first or a naive split gets them wrong in both directions:
|
|
3020
|
+
#
|
|
3021
|
+
# `|&` bash's pipe-with-stderr. Splitting its `&` would halve a pipeline —
|
|
3022
|
+
# the same fail-open, wearing a different operator. Becomes `|`.
|
|
3023
|
+
# `2>&1` a redirection, not a boundary (also `>&2`, `<&3`, `&>file`). These
|
|
3024
|
+
# are where `&` appears WITHOUT ending a statement, and they are
|
|
3025
|
+
# common enough that ignoring them would reopen the hole on any
|
|
3026
|
+
# pipeline that redirects stderr. The `&` is folded away.
|
|
3027
|
+
# `||` a real boundary that happens to be built from pipe characters, so it
|
|
3028
|
+
# must be recognized BEFORE a single `|` is spared. Becomes `;`.
|
|
3029
|
+
#
|
|
3030
|
+
# Every one of those rewrites only ever REMOVES a boundary or leaves it intact,
|
|
3031
|
+
# so each can merge two segments but never split one — the fail-closed
|
|
3032
|
+
# direction. `&` and `;` still separate, so unrelated commands are not joined.
|
|
3033
|
+
#
|
|
3034
|
+
# The leading awk is the same idea one level up. A line ending in `|` is bash's
|
|
3035
|
+
# OTHER implicit continuation — the pipeline resumes on the next line — and the
|
|
3036
|
+
# per-line read below would otherwise part the stages exactly as `tr` did, so
|
|
3037
|
+
# `find .git -type d |`↵` parallel rm -rf` fails open through the newline
|
|
3038
|
+
# instead of through the pipe. This mirrors the backslash-continuation join that
|
|
3039
|
+
# `normalized_command_str` already performs for the same reason. It is done here
|
|
3040
|
+
# rather than in that shared normalization deliberately: this guard is the one
|
|
3041
|
+
# that pairs facts across stages, so it is the one that needs the stages kept
|
|
3042
|
+
# together, and widening the shared normalization would change every other
|
|
3043
|
+
# guard's segmentation for no defect anyone has measured.
|
|
3044
|
+
#
|
|
3045
|
+
# The status check is the second half of the fix and closes a quieter hole. This
|
|
3046
|
+
# feeder had none, while the rm and push walks above both have one: a segmenter
|
|
3047
|
+
# that could not run produced no lines, the loop body never executed, and the
|
|
3048
|
+
# guard was skipped with the command ALLOWED. A segmentation that cannot answer
|
|
3049
|
+
# must refuse, exactly like a scan that cannot answer.
|
|
3050
|
+
# 14b. Linked worktrees, which are the same principle one level out. `.git` is
|
|
3051
|
+
# protected because nothing in the working tree can rebuild it; a linked
|
|
3052
|
+
# worktree is protected because nothing OUTSIDE it can rebuild what it holds.
|
|
3053
|
+
# The asymmetry is exact and it is invisible from the prompt: `.git/refs` and
|
|
3054
|
+
# the object store are shared by every worktree of a repository, while
|
|
3055
|
+
# `.git/index` and the working files are per-worktree. So `git commit` moves
|
|
3056
|
+
# work somewhere a deletion cannot reach, and `git add` does not — a staged file
|
|
3057
|
+
# shows green in `git status`, which reads as saved, and dies with the
|
|
3058
|
+
# directory.
|
|
3059
|
+
#
|
|
3060
|
+
# Measured twice in one evening (issue #3863): one worktree lost a 284-line
|
|
3061
|
+
# staged test file, and a second lost nothing from the identical event purely
|
|
3062
|
+
# because a gate had forced its work into a commit first. Neither deletion
|
|
3063
|
+
# announced itself. That is the second reason this is a block rather than a
|
|
3064
|
+
# warning — a worktree deletion destroys the only evidence its own case could
|
|
3065
|
+
# leave, so there is no after-the-fact detection to fall back on.
|
|
3066
|
+
#
|
|
3067
|
+
# `git worktree remove` (guard 12) already refuses a dirty tree, and
|
|
3068
|
+
# `--force` is already blocked. This closes the spelling that walks around both:
|
|
3069
|
+
# `rm -rf` on the directory, which git never sees.
|
|
3070
|
+
#
|
|
3071
|
+
# Matching is by REGISTERED path — `git worktree list` names them — so an
|
|
3072
|
+
# ordinary directory that merely lives near one is untouched. A repository that
|
|
3073
|
+
# cannot be read answers "no": this guard adds protection to a shape that was
|
|
3074
|
+
# fully allowed a moment ago, and failing it closed would deny every recursive
|
|
3075
|
+
# delete on every path outside a repository.
|
|
3076
|
+
#
|
|
3077
|
+
# The comparison is on WHOLE TOKENS, which is the only spelling that gets both
|
|
3078
|
+
# error directions right. Substring containment would let a worktree named
|
|
3079
|
+
# `wt-1` claim `rm -rf wt-10`, an over-block on an unrelated directory; and
|
|
3080
|
+
# hand-written boundary classes leak the other way, because `rm -rf <wt>/`
|
|
3081
|
+
# with one trailing slash ends on a character every such class treats as
|
|
3082
|
+
# "inside a path". So every non-path character — quote, space, parenthesis,
|
|
3083
|
+
# redirection — is folded to a space first, which makes each argument a
|
|
3084
|
+
# space-delimited token regardless of how it was quoted, and the match is then
|
|
3085
|
+
# an equality test on that token. A SUBPATH (`rm -rf <wt>/node_modules`) is
|
|
3086
|
+
# deliberately not this guard's business: it is not a worktree deletion, and
|
|
3087
|
+
# blocking routine cleanup inside a worktree is how a guard gets switched off.
|
|
3088
|
+
names_linked_worktree() {
|
|
3089
|
+
local stmt="$1" probe root listed line primary wt rel
|
|
3090
|
+
root="$(git -C "${CLAUDE_PROJECT_DIR:-$PWD}" rev-parse --show-toplevel 2> /dev/null)" || return 1
|
|
3091
|
+
[ -n "$root" ] || return 1
|
|
3092
|
+
listed="$(git -C "$root" worktree list --porcelain 2> /dev/null)" || return 1
|
|
3093
|
+
probe=" $(printf '%s' "$stmt" | tr -c 'A-Za-z0-9_./-' ' ') "
|
|
3094
|
+
primary=""
|
|
3095
|
+
while IFS= read -r line; do
|
|
3096
|
+
case "$line" in
|
|
3097
|
+
worktree\ *) wt="${line#worktree }" ;;
|
|
3098
|
+
*) continue ;;
|
|
3099
|
+
esac
|
|
3100
|
+
# First entry is the primary checkout, which is not a linked worktree.
|
|
3101
|
+
if [ -z "$primary" ]; then
|
|
3102
|
+
primary="$wt"
|
|
3103
|
+
continue
|
|
3104
|
+
fi
|
|
3105
|
+
case "$probe" in
|
|
3106
|
+
*" $wt "* | *" $wt/ "*) return 0 ;;
|
|
3107
|
+
esac
|
|
3108
|
+
rel="${wt#"$primary"/}"
|
|
3109
|
+
[ "$rel" != "$wt" ] || continue
|
|
3110
|
+
# `./` is the same path spelled the way an agent in the project root types
|
|
3111
|
+
# it, and a token comparison is exact, so it has to be spelled here too.
|
|
3112
|
+
case "$probe" in
|
|
3113
|
+
*" $rel "* | *" $rel/ "* | *" ./$rel "* | *" ./$rel/ "*) return 0 ;;
|
|
3114
|
+
esac
|
|
3115
|
+
done <<EOF
|
|
3116
|
+
$listed
|
|
3117
|
+
EOF
|
|
3118
|
+
return 1
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
git_segments_status=0
|
|
3122
|
+
git_segments="$(printf '%s' "$normalized_command_str" \
|
|
3123
|
+
| awk '{ if (/\|[ \t]*$/) printf "%s ", $0; else print }' \
|
|
3124
|
+
| sed -e 's/|&/|/g' \
|
|
3125
|
+
-e 's/&>/>/g' \
|
|
3126
|
+
-e 's/[0-9]*>&[0-9-]*/>/g' \
|
|
3127
|
+
-e 's/[0-9]*<&[0-9-]*/</g' \
|
|
3128
|
+
-e 's/||/;/g' \
|
|
3129
|
+
| tr '&;' '\n' \
|
|
3130
|
+
| grep -E -- "$RM_RF_CLUSTER|$RM_RF_SPLIT")" || git_segments_status=$?
|
|
3131
|
+
case "$git_segments_status" in
|
|
3132
|
+
0 | 1) ;;
|
|
3133
|
+
*) scan_failed "$git_segments_status" ;;
|
|
3134
|
+
esac
|
|
3135
|
+
while IFS= read -r git_stmt; do
|
|
3136
|
+
[ -n "$git_stmt" ] || continue
|
|
3137
|
+
if ! scan -E "$git_stmt" "$RM_RF_CLUSTER" \
|
|
3138
|
+
&& ! scan -E "$git_stmt" "$RM_RF_SPLIT"; then
|
|
3139
|
+
continue
|
|
3140
|
+
fi
|
|
3141
|
+
if scan -E "$git_stmt" "$GIT_CONTROL_PLANE"; then
|
|
3142
|
+
block "recursive forced delete of the git control plane (.git holds every commit, branch and stash not already pushed; nothing in the working tree can rebuild it). Delete a specific ignored artifact instead. Re-cloning is NOT a like-for-like replacement: every linked worktree stores a pointer into this .git, so discarding it discards each of them and any uncommitted work they hold — check \`git worktree list\` before treating the checkout as disposable."
|
|
3143
|
+
fi
|
|
3144
|
+
if names_linked_worktree "$git_stmt"; then
|
|
3145
|
+
block "recursive forced delete of a linked git worktree. A worktree's index and working files are private to it — only commits are shared — so this destroys any staged or unwritten-to-a-commit work another agent left there, and destroys the evidence that it did (CodySwannGT/lisa#3863). Use 'node scripts/lisa-worktree-guard.mjs remove <path>' instead: it deletes clean and already-committed trees without ceremony, names the files when they exist nowhere else, and records an explicit --force override."
|
|
3146
|
+
fi
|
|
3147
|
+
done <<<"$git_segments"
|
|
3148
|
+
|
|
3149
|
+
# 15. Credential stores. Reading one into an agent transcript is disclosure even
|
|
3150
|
+
# when nothing is copied anywhere: the value lands in a log, a context window,
|
|
3151
|
+
# and whatever retains either.
|
|
3152
|
+
#
|
|
3153
|
+
# Scoped deliberately rather than by the obvious pattern. Each clause below is a
|
|
3154
|
+
# separate judgement about a family where an agent has no legitimate read, and
|
|
3155
|
+
# each carries the exclusions that keep ordinary work moving — public keys,
|
|
3156
|
+
# known_hosts, ssh config, and `.env.example` are all still readable, because a
|
|
3157
|
+
# guard that blocks routine setup is a guard somebody disables.
|
|
3158
|
+
|
|
3159
|
+
# The verbs that disclose: read it, duplicate it, or send it. Writing is NOT
|
|
3160
|
+
# here — `echo FOO=1 > .env` during setup creates a secret rather than leaking
|
|
3161
|
+
# one, and refusing it would block the very workflow that produces the file.
|
|
3162
|
+
readonly SECRET_READ_VERB='(^|[^[:alnum:]_./-])([[:alnum:]_./-]*/)?(cat|bat|less|more|head|tail|strings|xxd|od|base64|cp|mv|rsync|scp|sftp|tar|zip|curl|wget|http|nc)([[:space:]]|$)'
|
|
3163
|
+
|
|
3164
|
+
# 15a. SSH PRIVATE keys. `id_rsa.pub` and friends are public by construction and
|
|
3165
|
+
# stay readable; so do `known_hosts`, `config` and `authorized_keys`, which
|
|
3166
|
+
# agents legitimately inspect when diagnosing a remote.
|
|
3167
|
+
readonly SSH_PRIVATE_KEY='\.ssh/(id_[[:alnum:]_-]+|[[:alnum:]_.-]*_(rsa|dsa|ecdsa|ed25519))([[:space:]'"$qc"']|$)'
|
|
3168
|
+
# 15b. Cloud provider credential files.
|
|
3169
|
+
readonly CLOUD_CREDENTIAL='(\.aws/(credentials|config)|\.config/gcloud/[[:alnum:]_./-]*credentials[[:alnum:]_.-]*|\.azure/(accessTokens|msal_token_cache)[[:alnum:]_.-]*|\.kube/config)([[:space:]'"$qc"']|$)'
|
|
3170
|
+
# 15c. Coding-agent credential stores — the tokens that impersonate the operator
|
|
3171
|
+
# to every service their agent can reach.
|
|
3172
|
+
readonly AGENT_CREDENTIAL='(\.(claude|codex|cursor|copilot|gemini|antigravity)/[[:alnum:]_./-]*(credentials|auth|token)[[:alnum:]_.-]*|\.netrc|\.npmrc|\.pypirc|\.docker/config\.json)([[:space:]'"$qc"']|$)'
|
|
3173
|
+
# 15d. Dotenv files. `.env.example`, `.sample`, `.template`, `.dist` and
|
|
3174
|
+
# `.schema` are checked-in documentation of which keys exist, never the values,
|
|
3175
|
+
# so they are excluded — that collision is the one this rule most had to avoid.
|
|
3176
|
+
# Decide dotenv privacy per PATH, never by subtracting one command-wide match
|
|
3177
|
+
# from another. A command that names `.env.example` and `.env` contains both a
|
|
3178
|
+
# public path and a private one; seeing the former cannot authorize the latter.
|
|
3179
|
+
#
|
|
3180
|
+
# The one write-only shape this guard has always allowed remains explicit:
|
|
3181
|
+
# seeding a private dotenv destination from one public template. This is safe
|
|
3182
|
+
# because the private path is the destination, not a value being read back.
|
|
3183
|
+
has_private_dotenv_path() {
|
|
3184
|
+
dotenv_scan_status=0
|
|
3185
|
+
dotenv_candidates="$(printf '%s' "$normalized_command_str" \
|
|
3186
|
+
| grep -Eio -- '(^|[[:space:]='"$qc"'./])\.env(\.[[:alnum:]_-]+)?')" \
|
|
3187
|
+
|| dotenv_scan_status=$?
|
|
3188
|
+
if [ "$dotenv_scan_status" -gt 1 ]; then
|
|
3189
|
+
scan_failed "$dotenv_scan_status"
|
|
3190
|
+
fi
|
|
3191
|
+
|
|
3192
|
+
while IFS= read -r candidate; do
|
|
3193
|
+
[ -n "$candidate" ] || continue
|
|
3194
|
+
dotenv_normalize_status=0
|
|
3195
|
+
candidate="$(printf '%s' "$candidate" \
|
|
3196
|
+
| tr '[:upper:]' '[:lower:]' \
|
|
3197
|
+
| grep -Eo -- '\.env(\.[[:alnum:]_-]+)?')" \
|
|
3198
|
+
|| dotenv_normalize_status=$?
|
|
3199
|
+
if [ "$dotenv_normalize_status" -gt 1 ] || [ -z "$candidate" ]; then
|
|
3200
|
+
scan_failed "$dotenv_normalize_status"
|
|
3201
|
+
fi
|
|
3202
|
+
case "$candidate" in
|
|
3203
|
+
.env.example | .env.sample | .env.template | .env.dist | .env.schema | .env.defaults) ;;
|
|
3204
|
+
*) return 0 ;;
|
|
3205
|
+
esac
|
|
3206
|
+
done <<EOF
|
|
3207
|
+
$dotenv_candidates
|
|
3208
|
+
EOF
|
|
3209
|
+
return 1
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
readonly SAFE_DOTENV_SEED='^[[:space:]]*(cp|mv)[[:space:]]+\.env\.(example|sample|template|dist|schema|defaults)[[:space:]]+\.env(\.[[:alnum:]_-]+)?[[:space:]]*$'
|
|
3213
|
+
|
|
3214
|
+
if matches "$SECRET_READ_VERB"; then
|
|
3215
|
+
if matches "$SSH_PRIVATE_KEY"; then
|
|
3216
|
+
block "reading, copying or transmitting an SSH PRIVATE key. Public keys (.pub), known_hosts and ssh config are unaffected; if you need the fingerprint use \`ssh-keygen -lf <key>.pub\`."
|
|
3217
|
+
fi
|
|
3218
|
+
if matches "$CLOUD_CREDENTIAL"; then
|
|
3219
|
+
block "reading, copying or transmitting a cloud provider credential file. Use the provider CLI's own identity command (\`aws sts get-caller-identity\`, \`gcloud auth list\`) instead of reading the file."
|
|
3220
|
+
fi
|
|
3221
|
+
if matches "$AGENT_CREDENTIAL"; then
|
|
3222
|
+
block "reading, copying or transmitting a coding-agent or package-registry credential store. These tokens impersonate the operator to every service the agent can reach."
|
|
3223
|
+
fi
|
|
3224
|
+
# A public path elsewhere in the command never exempts a private path. The
|
|
3225
|
+
# narrow public-template -> private-destination seed is write-only and remains
|
|
3226
|
+
# allowed; every other command containing a private dotenv path is refused.
|
|
3227
|
+
if has_private_dotenv_path && ! matches "$SAFE_DOTENV_SEED"; then
|
|
3228
|
+
block "reading, copying or transmitting a dotenv file holding real values. \`.env.example\`, \`.env.sample\`, \`.env.template\` and \`.env.dist\` are readable, and WRITING a .env is unaffected — only reading one back is refused."
|
|
3229
|
+
fi
|
|
3230
|
+
# Fail-closed, narrowly. A read whose DIRECTORY is a known credential store
|
|
3231
|
+
# but whose leaf is variable-expanded cannot be classified, and #2980's lesson
|
|
3232
|
+
# is that a guard which cannot classify must refuse rather than permit. Scoped
|
|
3233
|
+
# to credential directories on purpose: refusing every \`cat "\$FILE"\` would be
|
|
3234
|
+
# the over-reach that gets the whole hook switched off.
|
|
3235
|
+
if matches_cs '(\.ssh|\.aws|\.config/gcloud|\.azure|\.kube)/[^[:space:]'"$qc"']*\$'; then
|
|
3236
|
+
block "reading a path inside a credential directory whose filename is variable-expanded, so the guard cannot tell which file it is. Name the file explicitly if it is genuinely not a secret."
|
|
3237
|
+
fi
|
|
3238
|
+
fi
|
|
3239
|
+
|
|
3240
|
+
# 16. Project-local custom rules. Each non-comment line is an ERE; a match blocks.
|
|
3241
|
+
rules_file="${SAFETY_NET_RULES_FILE:-${CLAUDE_PROJECT_DIR:-$PWD}/.claude/safety-net-rules.txt}"
|
|
3242
|
+
if [ -f "$rules_file" ]; then
|
|
3243
|
+
# Custom rules can intentionally govern text itself, so preserve their input.
|
|
3244
|
+
custom_command_str="$(printf '%s\n%s' "$custom_command_for_guards" "$followed_text" \
|
|
3245
|
+
| awk '{ if (sub(/\\$/, "")) printf "%s ", $0; else print }')"
|
|
3246
|
+
while IFS= read -r rule || [ -n "$rule" ]; do
|
|
3247
|
+
case "$rule" in
|
|
3248
|
+
'' | '#'*) continue ;;
|
|
3249
|
+
esac
|
|
3250
|
+
# Normalized text, for the same reason every built-in guard uses it: a
|
|
3251
|
+
# project rule must not be evadable by breaking the command across lines.
|
|
3252
|
+
#
|
|
3253
|
+
# `|| rule_status=$?` rather than a bare pipeline: this script runs under
|
|
3254
|
+
# `set -e`, and a no-match grep exits 1, which would end the hook right
|
|
3255
|
+
# here — before the remaining rules ran, and with an exit status that is
|
|
3256
|
+
# not a refusal. The `||` puts the pipeline in a condition context, where
|
|
3257
|
+
# a non-zero status is expected rather than fatal.
|
|
3258
|
+
rule_status=0
|
|
3259
|
+
printf '%s' "$custom_command_str" | grep -Eiq -- "$rule" || rule_status=$?
|
|
3260
|
+
case "$rule_status" in
|
|
3261
|
+
0) block "matched a project custom safety rule (${rules_file##*/}): $rule" ;;
|
|
3262
|
+
1) ;; # no match
|
|
3263
|
+
# grep exits 2 on a malformed ERE. Treated as no-match before, so a typo
|
|
3264
|
+
# in a project's rules file silently disabled that rule — a guard the
|
|
3265
|
+
# project believed it had. Say so instead; still non-fatal, because one
|
|
3266
|
+
# bad line must not take the other rules down with it.
|
|
3267
|
+
*)
|
|
3268
|
+
printf 'parity-safety-net: invalid regex in %s, rule NOT enforced: %s\n' \
|
|
3269
|
+
"$rules_file" "$rule" >&2
|
|
3270
|
+
;;
|
|
3271
|
+
esac
|
|
3272
|
+
done <"$rules_file"
|
|
3273
|
+
fi
|
|
3274
|
+
|
|
3275
|
+
exit 0
|