@codyswann/lisa 4.74.8 → 4.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +60 -0
- package/all/copy-overwrite/scripts/lib/npm-update-cancel-origin.mjs +4 -2
- package/all/copy-overwrite/scripts/lib/npm-update-checkpoint-codec.mjs +141 -0
- package/all/copy-overwrite/scripts/lib/npm-update-checkpoint.mjs +162 -116
- package/all/copy-overwrite/scripts/lib/npm-update-helper-inventory.mjs +1 -0
- package/all/copy-overwrite/scripts/lib/npm-update-recovery.mjs +9 -60
- package/all/copy-overwrite/scripts/npm-updater-helper-graph.json +18 -6
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +20 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/lisa-skill-sources.d.ts +3 -3
- package/dist/core/lisa-skill-sources.d.ts.map +1 -1
- package/dist/core/lisa-skill-sources.js +3 -2
- package/dist/core/lisa-skill-sources.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +739 -11
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-devin/.claude-plugin/plugin.json +11 -0
- package/plugins/lisa-cdk-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-cdk-devin/skills/setup-aws-accounts/SKILL.md +292 -0
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-devin/.claude-plugin/plugin.json +8 -0
- package/plugins/lisa-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-devin/.mcp.json +8 -0
- package/plugins/lisa-devin/agents/architecture-specialist.md +32 -0
- package/plugins/lisa-devin/agents/bug-fixer.md +43 -0
- package/plugins/lisa-devin/agents/builder.md +44 -0
- package/plugins/lisa-devin/agents/confluence-prd-intake.md +65 -0
- package/plugins/lisa-devin/agents/debug-specialist.md +34 -0
- package/plugins/lisa-devin/agents/eval-specialist.md +33 -0
- package/plugins/lisa-devin/agents/git-history-analyzer.md +183 -0
- package/plugins/lisa-devin/agents/github-agent.md +155 -0
- package/plugins/lisa-devin/agents/github-build-intake.md +64 -0
- package/plugins/lisa-devin/agents/github-prd-intake.md +66 -0
- package/plugins/lisa-devin/agents/jira-agent.md +135 -0
- package/plugins/lisa-devin/agents/jira-build-intake.md +64 -0
- package/plugins/lisa-devin/agents/learner.md +85 -0
- package/plugins/lisa-devin/agents/learning-judge.md +119 -0
- package/plugins/lisa-devin/agents/learnings-synthesizer.md +155 -0
- package/plugins/lisa-devin/agents/linear-agent.md +138 -0
- package/plugins/lisa-devin/agents/linear-build-intake.md +64 -0
- package/plugins/lisa-devin/agents/linear-prd-intake.md +66 -0
- package/plugins/lisa-devin/agents/notion-prd-intake.md +63 -0
- package/plugins/lisa-devin/agents/performance-specialist.md +26 -0
- package/plugins/lisa-devin/agents/pr-mining-specialist.md +85 -0
- package/plugins/lisa-devin/agents/product-specialist.md +30 -0
- package/plugins/lisa-devin/agents/quality-specialist.md +30 -0
- package/plugins/lisa-devin/agents/security-specialist.md +43 -0
- package/plugins/lisa-devin/agents/skill-evaluator.md +205 -0
- package/plugins/lisa-devin/agents/spec-conformance-specialist.md +56 -0
- package/plugins/lisa-devin/agents/test-specialist.md +32 -0
- package/plugins/lisa-devin/agents/tracker-mining-specialist.md +85 -0
- package/plugins/lisa-devin/agents/verification-specialist.md +145 -0
- package/plugins/lisa-devin/hooks/auto-update.mjs +824 -0
- package/plugins/lisa-devin/hooks/auto-update.sh +31 -0
- package/plugins/lisa-devin/hooks/block-blind-automerge.sh +1242 -0
- package/plugins/lisa-devin/hooks/block-direct-issue-create.sh +2603 -0
- package/plugins/lisa-devin/hooks/block-host-name-leak.mjs +412 -0
- package/plugins/lisa-devin/hooks/block-host-name-leak.sh +61 -0
- package/plugins/lisa-devin/hooks/block-instruction-file-edits.sh +207 -0
- package/plugins/lisa-devin/hooks/block-managed-file-edits.sh +1175 -0
- package/plugins/lisa-devin/hooks/block-no-verify.sh +2288 -0
- package/plugins/lisa-devin/hooks/block-shell-json-parsing.sh +263 -0
- package/plugins/lisa-devin/hooks/cleanup-stale-worktrees.sh +122 -0
- package/plugins/lisa-devin/hooks/devin-tool-name-shim.sh +30 -0
- package/plugins/lisa-devin/hooks/discharge-work-item-gates.sh +90 -0
- package/plugins/lisa-devin/hooks/enforce-verification-gate.sh +506 -0
- package/plugins/lisa-devin/hooks/enforcement-vintage-npm.mjs +320 -0
- package/plugins/lisa-devin/hooks/enforcement-vintage.mjs +472 -0
- package/plugins/lisa-devin/hooks/enforcement-vintage.sh +48 -0
- package/plugins/lisa-devin/hooks/failure-signature-index.mjs +717 -0
- package/plugins/lisa-devin/hooks/failure-signature-index.sh +26 -0
- package/plugins/lisa-devin/hooks/guard-dedupe.bash +374 -0
- package/plugins/lisa-devin/hooks/inject-resolved-config.mjs +903 -0
- package/plugins/lisa-devin/hooks/inject-resolved-config.sh +107 -0
- package/plugins/lisa-devin/hooks/inject-rules.sh +49 -0
- package/plugins/lisa-devin/hooks/install-pkgs.sh +84 -0
- package/plugins/lisa-devin/hooks/operational-hazards.mjs +736 -0
- package/plugins/lisa-devin/hooks/operational-hazards.sh +28 -0
- package/plugins/lisa-devin/hooks/parity-safety-net-heredoc.py +1004 -0
- package/plugins/lisa-devin/hooks/parity-safety-net.sh +3275 -0
- package/plugins/lisa-devin/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa-devin/hooks/setup-jira-cli.sh +205 -0
- package/plugins/lisa-devin/hooks/shell-write-nudge.sh +86 -0
- package/plugins/lisa-devin/hooks/sonar-secrets.sh +205 -0
- package/plugins/lisa-devin/hooks/threshold-ratchet-compare.mjs +518 -0
- package/plugins/lisa-devin/hooks/threshold-ratchet-families.mjs +543 -0
- package/plugins/lisa-devin/hooks/threshold-ratchet.mjs +568 -0
- package/plugins/lisa-devin/hooks/threshold-ratchet.sh +22 -0
- package/plugins/lisa-devin/hooks/withdrawn-rulings.mjs +600 -0
- package/plugins/lisa-devin/hooks/withdrawn-rulings.sh +24 -0
- package/plugins/lisa-devin/hooks/worktree-binding-guard.mjs +1383 -0
- package/plugins/lisa-devin/hooks/worktree-binding-guard.sh +79 -0
- package/plugins/lisa-devin/hooks.json +247 -0
- package/plugins/lisa-devin/rules/eager/00-rule-index.md +82 -0
- package/plugins/lisa-devin/rules/eager/base-rules.md +85 -0
- package/plugins/lisa-devin/rules/eager/coding-philosophy.md +27 -0
- package/plugins/lisa-devin/rules/eager/config-resolution.md +37 -0
- package/plugins/lisa-devin/rules/eager/do-it-now.md +11 -0
- package/plugins/lisa-devin/rules/eager/empirical-inquiry.md +24 -0
- package/plugins/lisa-devin/rules/eager/falsifiable-checks.md +48 -0
- package/plugins/lisa-devin/rules/eager/history-audit.md +27 -0
- package/plugins/lisa-devin/rules/eager/integration-access-layer.md +19 -0
- package/plugins/lisa-devin/rules/eager/intent-routing.md +18 -0
- package/plugins/lisa-devin/rules/eager/learnings-ladder.md +18 -0
- package/plugins/lisa-devin/rules/eager/local-ci-first.md +15 -0
- package/plugins/lisa-devin/rules/eager/measurement-integrity.md +18 -0
- package/plugins/lisa-devin/rules/eager/never-name-a-downstream-project.md +9 -0
- package/plugins/lisa-devin/rules/eager/not-blocked-just-waiting.md +13 -0
- package/plugins/lisa-devin/rules/eager/operational-hazards.md +50 -0
- package/plugins/lisa-devin/rules/eager/report-actionability.md +26 -0
- package/plugins/lisa-devin/rules/eager/security-audit-handling.md +40 -0
- package/plugins/lisa-devin/rules/eager/session-status-updates.md +20 -0
- package/plugins/lisa-devin/rules/eager/settled-decisions.md +75 -0
- package/plugins/lisa-devin/rules/eager/stale-state-claims.md +26 -0
- package/plugins/lisa-devin/rules/eager/tool-access-gate.md +23 -0
- package/plugins/lisa-devin/rules/eager/tracked-work.md +9 -0
- package/plugins/lisa-devin/rules/eager/upstream-to-lisa.md +14 -0
- package/plugins/lisa-devin/rules/eager/verification.md +33 -0
- package/plugins/lisa-devin/rules/eager/wiki-knowledge-source.md +13 -0
- package/plugins/lisa-devin/rules/eager/withdrawn-rulings.md +102 -0
- package/plugins/lisa-devin/rules/reference/automation-runbook-contract.md +355 -0
- package/plugins/lisa-devin/rules/reference/base-rules.md +136 -0
- package/plugins/lisa-devin/rules/reference/bdd-e2e-coverage.md +301 -0
- package/plugins/lisa-devin/rules/reference/blocker-containment.md +165 -0
- package/plugins/lisa-devin/rules/reference/claim-archaeology.md +184 -0
- package/plugins/lisa-devin/rules/reference/claim-evidence-mapping.md +362 -0
- package/plugins/lisa-devin/rules/reference/claim-time-guards.md +135 -0
- package/plugins/lisa-devin/rules/reference/coding-philosophy.md +428 -0
- package/plugins/lisa-devin/rules/reference/config-resolution.md +1225 -0
- package/plugins/lisa-devin/rules/reference/control-reachability.md +166 -0
- package/plugins/lisa-devin/rules/reference/convergent-review.md +145 -0
- package/plugins/lisa-devin/rules/reference/credential-substrate-precedence.md +249 -0
- package/plugins/lisa-devin/rules/reference/dependency-decision-records.md +177 -0
- package/plugins/lisa-devin/rules/reference/dependency-internalization-kit.md +244 -0
- package/plugins/lisa-devin/rules/reference/dependency-trust-classes.md +291 -0
- package/plugins/lisa-devin/rules/reference/deployed-state-readback.md +142 -0
- package/plugins/lisa-devin/rules/reference/derived-branch-plan.md +214 -0
- package/plugins/lisa-devin/rules/reference/design-source-of-truth.md +260 -0
- package/plugins/lisa-devin/rules/reference/design-value-binding.md +311 -0
- package/plugins/lisa-devin/rules/reference/do-it-now.md +19 -0
- package/plugins/lisa-devin/rules/reference/documentation-source-paths.md +31 -0
- package/plugins/lisa-devin/rules/reference/empirical-inquiry.md +28 -0
- package/plugins/lisa-devin/rules/reference/factory-model.md +111 -0
- package/plugins/lisa-devin/rules/reference/falsifiable-checks.md +275 -0
- package/plugins/lisa-devin/rules/reference/history-audit.md +42 -0
- package/plugins/lisa-devin/rules/reference/integration-access-layer.md +89 -0
- package/plugins/lisa-devin/rules/reference/intent-routing.md +428 -0
- package/plugins/lisa-devin/rules/reference/leaf-only-lifecycle.md +227 -0
- package/plugins/lisa-devin/rules/reference/learnings-ladder.md +41 -0
- package/plugins/lisa-devin/rules/reference/local-ci-first.md +34 -0
- package/plugins/lisa-devin/rules/reference/measurement-integrity.md +119 -0
- package/plugins/lisa-devin/rules/reference/never-name-a-downstream-project.md +37 -0
- package/plugins/lisa-devin/rules/reference/not-blocked-just-waiting.md +41 -0
- package/plugins/lisa-devin/rules/reference/observability-audit.md +146 -0
- package/plugins/lisa-devin/rules/reference/operational-hazards.md +115 -0
- package/plugins/lisa-devin/rules/reference/prd-definition-of-ready.md +118 -0
- package/plugins/lisa-devin/rules/reference/prd-lifecycle-rollup.md +192 -0
- package/plugins/lisa-devin/rules/reference/pre-flight-autofill.md +250 -0
- package/plugins/lisa-devin/rules/reference/project-learnings.md +159 -0
- package/plugins/lisa-devin/rules/reference/promotion-contract.md +148 -0
- package/plugins/lisa-devin/rules/reference/readiness-rubric.md +242 -0
- package/plugins/lisa-devin/rules/reference/ready-role-filing.md +212 -0
- package/plugins/lisa-devin/rules/reference/rejection-detection.md +308 -0
- package/plugins/lisa-devin/rules/reference/repo-scope-split.md +119 -0
- package/plugins/lisa-devin/rules/reference/report-actionability.md +61 -0
- package/plugins/lisa-devin/rules/reference/reset-seed-coverage.md +286 -0
- package/plugins/lisa-devin/rules/reference/security-audit-handling.md +55 -0
- package/plugins/lisa-devin/rules/reference/session-status-updates.md +111 -0
- package/plugins/lisa-devin/rules/reference/settled-decisions.md +39 -0
- package/plugins/lisa-devin/rules/reference/stale-state-claims.md +88 -0
- package/plugins/lisa-devin/rules/reference/tool-access-gate.md +114 -0
- package/plugins/lisa-devin/rules/reference/tracked-work.md +49 -0
- package/plugins/lisa-devin/rules/reference/upstream-to-lisa.md +26 -0
- package/plugins/lisa-devin/rules/reference/usage-accounting.md +261 -0
- package/plugins/lisa-devin/rules/reference/verification.md +278 -0
- package/plugins/lisa-devin/rules/reference/wiki-knowledge-source.md +37 -0
- package/plugins/lisa-devin/rules/reference/withdrawn-rulings.md +148 -0
- package/plugins/lisa-devin/rules/reference/work-item-definition-of-ready.md +177 -0
- package/plugins/lisa-devin/rules/reference/work-item-trailer-definition.md +129 -0
- package/plugins/lisa-devin/scripts/automation-run-record.mjs +678 -0
- package/plugins/lisa-devin/scripts/automation-status-claude-adapter.mjs +804 -0
- package/plugins/lisa-devin/scripts/automation-status-codex-adapter.mjs +1081 -0
- package/plugins/lisa-devin/scripts/automation-status-contract-drift.mjs +392 -0
- package/plugins/lisa-devin/scripts/automation-status-expected-fleet.mjs +508 -0
- package/plugins/lisa-devin/scripts/automation-status-report.mjs +200 -0
- package/plugins/lisa-devin/scripts/automation-status-run-history.mjs +216 -0
- package/plugins/lisa-devin/scripts/automation-status-unrecorded-runs.mjs +127 -0
- package/plugins/lisa-devin/scripts/blocker-edge-resolution.mjs +150 -0
- package/plugins/lisa-devin/scripts/cross-pollinate.mjs +763 -0
- package/plugins/lisa-devin/scripts/design-bindings-probe.mjs +713 -0
- package/plugins/lisa-devin/scripts/design-intake-gate.mjs +470 -0
- package/plugins/lisa-devin/scripts/design-source-gate.mjs +610 -0
- package/plugins/lisa-devin/scripts/design-variable-ids.mjs +567 -0
- package/plugins/lisa-devin/scripts/doctor-report.mjs +635 -0
- package/plugins/lisa-devin/scripts/install-remote-agent-aws.mjs +443 -0
- package/plugins/lisa-devin/scripts/intake-blocker-reprobe.mjs +1065 -0
- package/plugins/lisa-devin/scripts/intake-prework-denominator.mjs +249 -0
- package/plugins/lisa-devin/scripts/lifecycle-label-trust.mjs +593 -0
- package/plugins/lisa-devin/scripts/linear-state-write-target.mjs +362 -0
- package/plugins/lisa-devin/scripts/plugin-sync-explain.mjs +654 -0
- package/plugins/lisa-devin/scripts/pr-arming-sweep.mjs +561 -0
- package/plugins/lisa-devin/scripts/project-ideation-idempotency-harness.mjs +338 -0
- package/plugins/lisa-devin/scripts/qa-signal-lifecycle.mjs +523 -0
- package/plugins/lisa-devin/scripts/queue-contract-resolution.mjs +583 -0
- package/plugins/lisa-devin/scripts/queue-health-classification.mjs +157 -0
- package/plugins/lisa-devin/scripts/queue-status-build-readers.mjs +561 -0
- package/plugins/lisa-devin/scripts/queue-status-prd-readers.mjs +452 -0
- package/plugins/lisa-devin/scripts/remote-agent-aws-setup.sh +677 -0
- package/plugins/lisa-devin/scripts/repo-scope-vocabulary-audit.mjs +458 -0
- package/plugins/lisa-devin/scripts/resolve-lifecycle-role.mjs +347 -0
- package/plugins/lisa-devin/scripts/rollup-blocker-classification.mjs +616 -0
- package/plugins/lisa-devin/scripts/scoping-label-audit.mjs +474 -0
- package/plugins/lisa-devin/skills/lisa-acceptance-criteria/SKILL.md +73 -0
- package/plugins/lisa-devin/skills/lisa-agent-design-best-practices/SKILL.md +242 -0
- package/plugins/lisa-devin/skills/lisa-agent-ready/SKILL.md +271 -0
- package/plugins/lisa-devin/skills/lisa-analyze-claude-remote/SKILL.md +434 -0
- package/plugins/lisa-devin/skills/lisa-atlassian-access/SKILL.md +469 -0
- package/plugins/lisa-devin/skills/lisa-atlassian-access/scripts/markdown-to-adf.mjs +236 -0
- package/plugins/lisa-devin/skills/lisa-attribute-failure/SKILL.md +142 -0
- package/plugins/lisa-devin/skills/lisa-automation-status/SKILL.md +149 -0
- package/plugins/lisa-devin/skills/lisa-bug-triage/SKILL.md +23 -0
- package/plugins/lisa-devin/skills/lisa-capability-drift/SKILL.md +71 -0
- package/plugins/lisa-devin/skills/lisa-codebase-research/SKILL.md +87 -0
- package/plugins/lisa-devin/skills/lisa-codify-verification/SKILL.md +213 -0
- package/plugins/lisa-devin/skills/lisa-confluence-prd-intake/SKILL.md +417 -0
- package/plugins/lisa-devin/skills/lisa-confluence-to-tracker/SKILL.md +469 -0
- package/plugins/lisa-devin/skills/lisa-confluence-write-prd/SKILL.md +111 -0
- package/plugins/lisa-devin/skills/lisa-cross-pollinate/SKILL.md +175 -0
- package/plugins/lisa-devin/skills/lisa-debrief/SKILL.md +96 -0
- package/plugins/lisa-devin/skills/lisa-debrief-apply/SKILL.md +106 -0
- package/plugins/lisa-devin/skills/lisa-delivery-effectiveness/SKILL.md +62 -0
- package/plugins/lisa-devin/skills/lisa-design-intake/SKILL.md +198 -0
- package/plugins/lisa-devin/skills/lisa-detect-tooling/SKILL.md +110 -0
- package/plugins/lisa-devin/skills/lisa-detect-tooling/scripts/commands.mjs +479 -0
- package/plugins/lisa-devin/skills/lisa-detect-tooling/scripts/detect-tooling.mjs +658 -0
- package/plugins/lisa-devin/skills/lisa-doctor/SKILL.md +743 -0
- package/plugins/lisa-devin/skills/lisa-drive-pr-to-merge/SKILL.md +1587 -0
- package/plugins/lisa-devin/skills/lisa-epic-triage/SKILL.md +28 -0
- package/plugins/lisa-devin/skills/lisa-evaluation-suite/SKILL.md +63 -0
- package/plugins/lisa-devin/skills/lisa-exploratory-qa/SKILL.md +185 -0
- package/plugins/lisa-devin/skills/lisa-fix-linter-error/SKILL.md +45 -0
- package/plugins/lisa-devin/skills/lisa-generate-claude-remote-build-script/SKILL.md +235 -0
- package/plugins/lisa-devin/skills/lisa-git-commit/SKILL.md +53 -0
- package/plugins/lisa-devin/skills/lisa-git-prune/SKILL.md +35 -0
- package/plugins/lisa-devin/skills/lisa-git-submit-pr/SKILL.md +170 -0
- package/plugins/lisa-devin/skills/lisa-github-add-journey/SKILL.md +118 -0
- package/plugins/lisa-devin/skills/lisa-github-build-intake/SKILL.md +745 -0
- package/plugins/lisa-devin/skills/lisa-github-claim/SKILL.md +27 -0
- package/plugins/lisa-devin/skills/lisa-github-create/SKILL.md +105 -0
- package/plugins/lisa-devin/skills/lisa-github-evidence/SKILL.md +120 -0
- package/plugins/lisa-devin/skills/lisa-github-journey/SKILL.md +134 -0
- package/plugins/lisa-devin/skills/lisa-github-prd-intake/SKILL.md +424 -0
- package/plugins/lisa-devin/skills/lisa-github-project-v2/SKILL.md +227 -0
- package/plugins/lisa-devin/skills/lisa-github-read-issue/SKILL.md +305 -0
- package/plugins/lisa-devin/skills/lisa-github-sync/SKILL.md +201 -0
- package/plugins/lisa-devin/skills/lisa-github-to-tracker/SKILL.md +456 -0
- package/plugins/lisa-devin/skills/lisa-github-validate-issue/SKILL.md +598 -0
- package/plugins/lisa-devin/skills/lisa-github-verify/SKILL.md +60 -0
- package/plugins/lisa-devin/skills/lisa-github-write-issue/SKILL.md +531 -0
- package/plugins/lisa-devin/skills/lisa-github-write-prd/SKILL.md +156 -0
- package/plugins/lisa-devin/skills/lisa-health/SKILL.md +142 -0
- package/plugins/lisa-devin/skills/lisa-health-drift-cron/SKILL.md +80 -0
- package/plugins/lisa-devin/skills/lisa-implement/SKILL.md +372 -0
- package/plugins/lisa-devin/skills/lisa-improve-code-complexity/SKILL.md +44 -0
- package/plugins/lisa-devin/skills/lisa-improve-harness/SKILL.md +487 -0
- package/plugins/lisa-devin/skills/lisa-improve-max-lines/SKILL.md +45 -0
- package/plugins/lisa-devin/skills/lisa-improve-max-lines-per-function/SKILL.md +46 -0
- package/plugins/lisa-devin/skills/lisa-improve-test-coverage/SKILL.md +47 -0
- package/plugins/lisa-devin/skills/lisa-improve-tests/SKILL.md +50 -0
- package/plugins/lisa-devin/skills/lisa-intake/SKILL.md +208 -0
- package/plugins/lisa-devin/skills/lisa-intake-explain/SKILL.md +279 -0
- package/plugins/lisa-devin/skills/lisa-jam-access/SKILL.md +128 -0
- package/plugins/lisa-devin/skills/lisa-jira-add-journey/SKILL.md +139 -0
- package/plugins/lisa-devin/skills/lisa-jira-build-intake/SKILL.md +556 -0
- package/plugins/lisa-devin/skills/lisa-jira-claim/SKILL.md +27 -0
- package/plugins/lisa-devin/skills/lisa-jira-create/SKILL.md +157 -0
- package/plugins/lisa-devin/skills/lisa-jira-evidence/SKILL.md +106 -0
- package/plugins/lisa-devin/skills/lisa-jira-evidence/scripts/post-evidence.sh +262 -0
- package/plugins/lisa-devin/skills/lisa-jira-journey/SKILL.md +168 -0
- package/plugins/lisa-devin/skills/lisa-jira-journey/scripts/generate-templates.py +239 -0
- package/plugins/lisa-devin/skills/lisa-jira-journey/scripts/parse-plan.py +570 -0
- package/plugins/lisa-devin/skills/lisa-jira-read-ticket/SKILL.md +199 -0
- package/plugins/lisa-devin/skills/lisa-jira-read-ticket/scripts/download-attachment.sh +211 -0
- package/plugins/lisa-devin/skills/lisa-jira-sync/SKILL.md +117 -0
- package/plugins/lisa-devin/skills/lisa-jira-validate-ticket/SKILL.md +525 -0
- package/plugins/lisa-devin/skills/lisa-jira-verify/SKILL.md +61 -0
- package/plugins/lisa-devin/skills/lisa-jira-write-ticket/SKILL.md +440 -0
- package/plugins/lisa-devin/skills/lisa-jsdoc-best-practices/SKILL.md +491 -0
- package/plugins/lisa-devin/skills/lisa-kane-browser/SKILL.md +80 -0
- package/plugins/lisa-devin/skills/lisa-learnings-audit/SKILL.md +490 -0
- package/plugins/lisa-devin/skills/lisa-linear-access/SKILL.md +483 -0
- package/plugins/lisa-devin/skills/lisa-linear-add-journey/SKILL.md +109 -0
- package/plugins/lisa-devin/skills/lisa-linear-build-intake/SKILL.md +548 -0
- package/plugins/lisa-devin/skills/lisa-linear-claim/SKILL.md +28 -0
- package/plugins/lisa-devin/skills/lisa-linear-create/SKILL.md +150 -0
- package/plugins/lisa-devin/skills/lisa-linear-evidence/SKILL.md +124 -0
- package/plugins/lisa-devin/skills/lisa-linear-journey/SKILL.md +147 -0
- package/plugins/lisa-devin/skills/lisa-linear-prd-intake/SKILL.md +411 -0
- package/plugins/lisa-devin/skills/lisa-linear-read-issue/SKILL.md +201 -0
- package/plugins/lisa-devin/skills/lisa-linear-sync/SKILL.md +164 -0
- package/plugins/lisa-devin/skills/lisa-linear-to-tracker/SKILL.md +454 -0
- package/plugins/lisa-devin/skills/lisa-linear-validate-issue/SKILL.md +504 -0
- package/plugins/lisa-devin/skills/lisa-linear-verify/SKILL.md +65 -0
- package/plugins/lisa-devin/skills/lisa-linear-write-issue/SKILL.md +448 -0
- package/plugins/lisa-devin/skills/lisa-linear-write-prd/SKILL.md +98 -0
- package/plugins/lisa-devin/skills/lisa-monitor/SKILL.md +224 -0
- package/plugins/lisa-devin/skills/lisa-nightly-add-test-coverage/SKILL.md +40 -0
- package/plugins/lisa-devin/skills/lisa-nightly-improve-tests/SKILL.md +29 -0
- package/plugins/lisa-devin/skills/lisa-nightly-lower-code-complexity/SKILL.md +42 -0
- package/plugins/lisa-devin/skills/lisa-notion-access/SKILL.md +301 -0
- package/plugins/lisa-devin/skills/lisa-notion-prd-intake/SKILL.md +360 -0
- package/plugins/lisa-devin/skills/lisa-notion-to-tracker/SKILL.md +467 -0
- package/plugins/lisa-devin/skills/lisa-notion-write-prd/SKILL.md +111 -0
- package/plugins/lisa-devin/skills/lisa-parity-code-review/SKILL.md +83 -0
- package/plugins/lisa-devin/skills/lisa-parity-code-simplifier/SKILL.md +76 -0
- package/plugins/lisa-devin/skills/lisa-parity-coderabbit/SKILL.md +87 -0
- package/plugins/lisa-devin/skills/lisa-parity-sentry-sdk-setup/SKILL.md +375 -0
- package/plugins/lisa-devin/skills/lisa-parity-sentry-seer/SKILL.md +168 -0
- package/plugins/lisa-devin/skills/lisa-parity-skill-creator/SKILL.md +155 -0
- package/plugins/lisa-devin/skills/lisa-performance-review/SKILL.md +94 -0
- package/plugins/lisa-devin/skills/lisa-persist-learning/SKILL.md +247 -0
- package/plugins/lisa-devin/skills/lisa-plan/SKILL.md +64 -0
- package/plugins/lisa-devin/skills/lisa-plugin-sync-explain/SKILL.md +53 -0
- package/plugins/lisa-devin/skills/lisa-posthog-access/SKILL.md +140 -0
- package/plugins/lisa-devin/skills/lisa-prd-backlink/SKILL.md +274 -0
- package/plugins/lisa-devin/skills/lisa-prd-source-write/SKILL.md +104 -0
- package/plugins/lisa-devin/skills/lisa-prd-ticket-coverage/SKILL.md +187 -0
- package/plugins/lisa-devin/skills/lisa-product-walkthrough/SKILL.md +106 -0
- package/plugins/lisa-devin/skills/lisa-project-ideation/SKILL.md +417 -0
- package/plugins/lisa-devin/skills/lisa-project-ideation/examples/evidence-card-format.md +21 -0
- package/plugins/lisa-devin/skills/lisa-project-ideation/examples/host-project-only.md +22 -0
- package/plugins/lisa-devin/skills/lisa-project-ideation/examples/idempotency-verification-harness.md +57 -0
- package/plugins/lisa-devin/skills/lisa-project-ideation/examples/public-external-inspiration.md +22 -0
- package/plugins/lisa-devin/skills/lisa-project-ideation/examples/unavailable-data-rejection.md +22 -0
- package/plugins/lisa-devin/skills/lisa-pull-request-review/SKILL.md +132 -0
- package/plugins/lisa-devin/skills/lisa-quality-review/SKILL.md +63 -0
- package/plugins/lisa-devin/skills/lisa-queue-status/SKILL.md +215 -0
- package/plugins/lisa-devin/skills/lisa-remote-dispatch/SKILL.md +109 -0
- package/plugins/lisa-devin/skills/lisa-remote-dispatch/scripts/dispatch.mjs +535 -0
- package/plugins/lisa-devin/skills/lisa-repair-intake/SKILL.md +1621 -0
- package/plugins/lisa-devin/skills/lisa-reproduce-bug/SKILL.md +75 -0
- package/plugins/lisa-devin/skills/lisa-research/SKILL.md +92 -0
- package/plugins/lisa-devin/skills/lisa-review-implementation/SKILL.md +209 -0
- package/plugins/lisa-devin/skills/lisa-review-local/SKILL.md +141 -0
- package/plugins/lisa-devin/skills/lisa-rework-triage/SKILL.md +206 -0
- package/plugins/lisa-devin/skills/lisa-root-cause-analysis/SKILL.md +135 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/SKILL.md +430 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/aws-bootstrap.mjs +433 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/bootstrap-store.mjs +288 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/doctor-secrets.mjs +273 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/envfile.mjs +80 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/inspect-vault.mjs +142 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/materialize-secrets.mjs +1085 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/note-format.mjs +179 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/owner.mjs +96 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +342 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/prompt-secret.mjs +79 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/providers.mjs +805 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/publish-aws-bootstrap.mjs +680 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/read-secret-note.mjs +105 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/resolve-expo-token.mjs +90 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/resolve-secret.mjs +285 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/rotate-secret.mjs +297 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/surfaces.mjs +480 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/sync-secret-to-ci.mjs +510 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/tools-from-notes.mjs +63 -0
- package/plugins/lisa-devin/skills/lisa-secrets-access/scripts/validate-config.mjs +550 -0
- package/plugins/lisa-devin/skills/lisa-security-review/SKILL.md +124 -0
- package/plugins/lisa-devin/skills/lisa-security-zap-scan/SKILL.md +53 -0
- package/plugins/lisa-devin/skills/lisa-sentry-access/SKILL.md +140 -0
- package/plugins/lisa-devin/skills/lisa-setup-atlassian/SKILL.md +407 -0
- package/plugins/lisa-devin/skills/lisa-setup-automations/SKILL.md +430 -0
- package/plugins/lisa-devin/skills/lisa-setup-automations/scripts/generate-workflow.mjs +288 -0
- package/plugins/lisa-devin/skills/lisa-setup-confluence/SKILL.md +254 -0
- package/plugins/lisa-devin/skills/lisa-setup-github/SKILL.md +319 -0
- package/plugins/lisa-devin/skills/lisa-setup-github-repo/SKILL.md +128 -0
- package/plugins/lisa-devin/skills/lisa-setup-jira/SKILL.md +198 -0
- package/plugins/lisa-devin/skills/lisa-setup-kane/SKILL.md +96 -0
- package/plugins/lisa-devin/skills/lisa-setup-linear/SKILL.md +375 -0
- package/plugins/lisa-devin/skills/lisa-setup-local-env/SKILL.md +95 -0
- package/plugins/lisa-devin/skills/lisa-setup-local-env/scripts/local-env.mjs +196 -0
- package/plugins/lisa-devin/skills/lisa-setup-notion/SKILL.md +376 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-aws/SKILL.md +149 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/SKILL.md +382 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/assets/materialized-env-authority.mjs +262 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/assets/session-start.sh +87 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/assets/setup.sh +216 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/environment.mjs +227 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +378 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/setup-remote-env.mjs +1632 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/toolchain.mjs +448 -0
- package/plugins/lisa-devin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +250 -0
- package/plugins/lisa-devin/skills/lisa-setup-sonar/SKILL.md +153 -0
- package/plugins/lisa-devin/skills/lisa-setup-workstation/SKILL.md +143 -0
- package/plugins/lisa-devin/skills/lisa-setup-workstation/scripts/bounded-child.mjs +168 -0
- package/plugins/lisa-devin/skills/lisa-setup-workstation/scripts/catalogue.mjs +293 -0
- package/plugins/lisa-devin/skills/lisa-setup-workstation/scripts/cli.mjs +333 -0
- package/plugins/lisa-devin/skills/lisa-setup-workstation/scripts/workstation.mjs +511 -0
- package/plugins/lisa-devin/skills/lisa-sonarcloud-access/SKILL.md +202 -0
- package/plugins/lisa-devin/skills/lisa-spec-conformance/SKILL.md +205 -0
- package/plugins/lisa-devin/skills/lisa-starter-sync/SKILL.md +35 -0
- package/plugins/lisa-devin/skills/lisa-sync-down/SKILL.md +209 -0
- package/plugins/lisa-devin/skills/lisa-task-decomposition/SKILL.md +195 -0
- package/plugins/lisa-devin/skills/lisa-task-triage/SKILL.md +23 -0
- package/plugins/lisa-devin/skills/lisa-tdd-implementation/SKILL.md +91 -0
- package/plugins/lisa-devin/skills/lisa-tear-down-automations/SKILL.md +85 -0
- package/plugins/lisa-devin/skills/lisa-test-strategy/SKILL.md +66 -0
- package/plugins/lisa-devin/skills/lisa-ticket-triage/SKILL.md +214 -0
- package/plugins/lisa-devin/skills/lisa-track/SKILL.md +129 -0
- package/plugins/lisa-devin/skills/lisa-tracker-add-journey/SKILL.md +28 -0
- package/plugins/lisa-devin/skills/lisa-tracker-build-intake/SKILL.md +134 -0
- package/plugins/lisa-devin/skills/lisa-tracker-claim/SKILL.md +35 -0
- package/plugins/lisa-devin/skills/lisa-tracker-create/SKILL.md +28 -0
- package/plugins/lisa-devin/skills/lisa-tracker-evidence/SKILL.md +63 -0
- package/plugins/lisa-devin/skills/lisa-tracker-journey/SKILL.md +27 -0
- package/plugins/lisa-devin/skills/lisa-tracker-read/SKILL.md +28 -0
- package/plugins/lisa-devin/skills/lisa-tracker-source-artifacts/SKILL.md +111 -0
- package/plugins/lisa-devin/skills/lisa-tracker-sync/SKILL.md +150 -0
- package/plugins/lisa-devin/skills/lisa-tracker-validate/SKILL.md +37 -0
- package/plugins/lisa-devin/skills/lisa-tracker-verify/SKILL.md +29 -0
- package/plugins/lisa-devin/skills/lisa-tracker-write/SKILL.md +55 -0
- package/plugins/lisa-devin/skills/lisa-usage-accounting/SKILL.md +205 -0
- package/plugins/lisa-devin/skills/lisa-usage-accounting/references/effectiveness.md +63 -0
- package/plugins/lisa-devin/skills/lisa-use-the-product/SKILL.md +86 -0
- package/plugins/lisa-devin/skills/lisa-validate-tracker-mapping/SKILL.md +467 -0
- package/plugins/lisa-devin/skills/lisa-verification-lifecycle/SKILL.md +384 -0
- package/plugins/lisa-devin/skills/lisa-verify/SKILL.md +55 -0
- package/plugins/lisa-devin/skills/lisa-verify-prd/SKILL.md +394 -0
- package/plugins/lisa-devin/skills/lisa-verify-workflow-change/SKILL.md +154 -0
- package/plugins/lisa-devin/skills/lisa-wiki-install/SKILL.md +101 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-devin/.claude-plugin/plugin.json +11 -0
- package/plugins/lisa-expo-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-expo-devin/.mcp.json +8 -0
- package/plugins/lisa-expo-devin/THIRD-PARTY-NOTICES.md +84 -0
- package/plugins/lisa-expo-devin/agents/ops-specialist.md +124 -0
- package/plugins/lisa-expo-devin/hooks/devin-tool-name-shim.sh +30 -0
- package/plugins/lisa-expo-devin/hooks/register-maestro-mcp.sh +84 -0
- package/plugins/lisa-expo-devin/hooks.json +14 -0
- package/plugins/lisa-expo-devin/skills/add-app-clip/SKILL.md +280 -0
- package/plugins/lisa-expo-devin/skills/add-app-clip/references/native-module.md +96 -0
- package/plugins/lisa-expo-devin/skills/apollo-client/SKILL.md +238 -0
- package/plugins/lisa-expo-devin/skills/apollo-client/references/mutation-patterns.md +360 -0
- package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/SKILL.md +369 -0
- package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/references/atomic-levels.md +417 -0
- package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/references/folder-structure.md +257 -0
- package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/references/gluestack-mapping.md +233 -0
- package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/scripts/test_validate_atomic_structure.py +240 -0
- package/plugins/lisa-expo-devin/skills/atomic-design-gluestack/scripts/validate_atomic_structure.py +438 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/SKILL.md +321 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/animations.md +220 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/controls.md +272 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/form-sheet.md +253 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/gradients.md +106 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/icons.md +213 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/media.md +198 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/route-structure.md +229 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/search.md +248 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/storage.md +121 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/tabs.md +433 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/toolbar-and-headers.md +284 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/visual-effects.md +197 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/webgpu-three.md +605 -0
- package/plugins/lisa-expo-devin/skills/building-native-ui/references/zoom-transitions.md +158 -0
- package/plugins/lisa-expo-devin/skills/container-view-pattern/SKILL.md +355 -0
- package/plugins/lisa-expo-devin/skills/container-view-pattern/references/examples.md +749 -0
- package/plugins/lisa-expo-devin/skills/container-view-pattern/references/patterns.md +318 -0
- package/plugins/lisa-expo-devin/skills/container-view-pattern/scripts/create_component.py +200 -0
- package/plugins/lisa-expo-devin/skills/container-view-pattern/scripts/validate_component.py +332 -0
- package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/SKILL.md +268 -0
- package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/references/common-issues.md +619 -0
- package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/references/file-extensions.md +340 -0
- package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/references/platform-api.md +276 -0
- package/plugins/lisa-expo-devin/skills/cross-platform-compatibility/scripts/validate_cross_platform.py +416 -0
- package/plugins/lisa-expo-devin/skills/directory-structure/SKILL.md +214 -0
- package/plugins/lisa-expo-devin/skills/directory-structure/scripts/validate_structure.py +454 -0
- package/plugins/lisa-expo-devin/skills/e2e-coverage-gaps/SKILL.md +117 -0
- package/plugins/lisa-expo-devin/skills/eas-update-insights/SKILL.md +228 -0
- package/plugins/lisa-expo-devin/skills/eas-update-insights/references/channel-insights-schema.md +47 -0
- package/plugins/lisa-expo-devin/skills/eas-update-insights/references/update-insights-schema.md +69 -0
- package/plugins/lisa-expo-devin/skills/expo-api-routes/SKILL.md +369 -0
- package/plugins/lisa-expo-devin/skills/expo-brownfield/SKILL.md +54 -0
- package/plugins/lisa-expo-devin/skills/expo-brownfield/references/brownfield-integrated.md +526 -0
- package/plugins/lisa-expo-devin/skills/expo-brownfield/references/brownfield-isolated.md +402 -0
- package/plugins/lisa-expo-devin/skills/expo-brownfield/references/comparison.md +63 -0
- package/plugins/lisa-expo-devin/skills/expo-brownfield/references/troubleshooting.md +88 -0
- package/plugins/lisa-expo-devin/skills/expo-cicd-workflows/SKILL.md +92 -0
- package/plugins/lisa-expo-devin/skills/expo-cicd-workflows/scripts/fetch.js +113 -0
- package/plugins/lisa-expo-devin/skills/expo-cicd-workflows/scripts/package.json +11 -0
- package/plugins/lisa-expo-devin/skills/expo-cicd-workflows/scripts/validate.js +85 -0
- package/plugins/lisa-expo-devin/skills/expo-deployment/SKILL.md +193 -0
- package/plugins/lisa-expo-devin/skills/expo-deployment/references/app-store-metadata.md +479 -0
- package/plugins/lisa-expo-devin/skills/expo-deployment/references/ios-app-store.md +355 -0
- package/plugins/lisa-expo-devin/skills/expo-deployment/references/play-store.md +264 -0
- package/plugins/lisa-expo-devin/skills/expo-deployment/references/testflight.md +58 -0
- package/plugins/lisa-expo-devin/skills/expo-deployment/references/workflows.md +200 -0
- package/plugins/lisa-expo-devin/skills/expo-dev-client/SKILL.md +164 -0
- package/plugins/lisa-expo-devin/skills/expo-env-config/SKILL.md +309 -0
- package/plugins/lisa-expo-devin/skills/expo-env-config/references/validation-patterns.md +417 -0
- package/plugins/lisa-expo-devin/skills/expo-module/SKILL.md +141 -0
- package/plugins/lisa-expo-devin/skills/expo-module/references/config-plugin.md +90 -0
- package/plugins/lisa-expo-devin/skills/expo-module/references/create-expo-module.md +206 -0
- package/plugins/lisa-expo-devin/skills/expo-module/references/lifecycle.md +127 -0
- package/plugins/lisa-expo-devin/skills/expo-module/references/module-config.md +48 -0
- package/plugins/lisa-expo-devin/skills/expo-module/references/native-module.md +286 -0
- package/plugins/lisa-expo-devin/skills/expo-module/references/native-view.md +171 -0
- package/plugins/lisa-expo-devin/skills/expo-router-best-practices/SKILL.md +432 -0
- package/plugins/lisa-expo-devin/skills/expo-router-best-practices/references/official-docs.md +290 -0
- package/plugins/lisa-expo-devin/skills/expo-router-best-practices/references/static-export-verification.md +54 -0
- package/plugins/lisa-expo-devin/skills/expo-router-best-practices/scripts/generate-route.py +171 -0
- package/plugins/lisa-expo-devin/skills/expo-tailwind-setup/SKILL.md +487 -0
- package/plugins/lisa-expo-devin/skills/expo-ui-jetpack-compose/SKILL.md +40 -0
- package/plugins/lisa-expo-devin/skills/expo-ui-swift-ui/SKILL.md +39 -0
- package/plugins/lisa-expo-devin/skills/gluestack-nativewind/SKILL.md +419 -0
- package/plugins/lisa-expo-devin/skills/gluestack-nativewind/references/color-tokens.md +343 -0
- package/plugins/lisa-expo-devin/skills/gluestack-nativewind/references/component-mapping.md +307 -0
- package/plugins/lisa-expo-devin/skills/gluestack-nativewind/references/spacing-scale.md +300 -0
- package/plugins/lisa-expo-devin/skills/gluestack-nativewind/scripts/test_validate_styling.py +240 -0
- package/plugins/lisa-expo-devin/skills/gluestack-nativewind/scripts/validate_styling.py +424 -0
- package/plugins/lisa-expo-devin/skills/jira-add-journey/SKILL.md +140 -0
- package/plugins/lisa-expo-devin/skills/jira-create/SKILL.md +161 -0
- package/plugins/lisa-expo-devin/skills/jira-evidence/SKILL.md +84 -0
- package/plugins/lisa-expo-devin/skills/jira-evidence/scripts/post-evidence.sh +262 -0
- package/plugins/lisa-expo-devin/skills/jira-journey/SKILL.md +196 -0
- package/plugins/lisa-expo-devin/skills/jira-journey/scripts/generate-templates.py +281 -0
- package/plugins/lisa-expo-devin/skills/jira-journey/scripts/parse-plan.py +400 -0
- package/plugins/lisa-expo-devin/skills/jira-verify/SKILL.md +27 -0
- package/plugins/lisa-expo-devin/skills/local-state/SKILL.md +362 -0
- package/plugins/lisa-expo-devin/skills/local-state/references/async-storage.md +505 -0
- package/plugins/lisa-expo-devin/skills/local-state/references/persistence-patterns.md +711 -0
- package/plugins/lisa-expo-devin/skills/local-state/references/reactive-variables.md +446 -0
- package/plugins/lisa-expo-devin/skills/maestro-mcp-setup/SKILL.md +144 -0
- package/plugins/lisa-expo-devin/skills/native-data-fetching/SKILL.md +507 -0
- package/plugins/lisa-expo-devin/skills/native-data-fetching/references/expo-router-loaders.md +344 -0
- package/plugins/lisa-expo-devin/skills/ops-browser-uat/SKILL.md +124 -0
- package/plugins/lisa-expo-devin/skills/ops-check-logs/SKILL.md +217 -0
- package/plugins/lisa-expo-devin/skills/ops-db-ops/SKILL.md +214 -0
- package/plugins/lisa-expo-devin/skills/ops-deploy/SKILL.md +123 -0
- package/plugins/lisa-expo-devin/skills/ops-monitor-errors/SKILL.md +99 -0
- package/plugins/lisa-expo-devin/skills/ops-performance/SKILL.md +165 -0
- package/plugins/lisa-expo-devin/skills/ops-run-local/SKILL.md +169 -0
- package/plugins/lisa-expo-devin/skills/ops-verify-health/SKILL.md +101 -0
- package/plugins/lisa-expo-devin/skills/owasp-zap/SKILL.md +61 -0
- package/plugins/lisa-expo-devin/skills/play-store-access/SKILL.md +226 -0
- package/plugins/lisa-expo-devin/skills/playwright-ci-debugging/SKILL.md +140 -0
- package/plugins/lisa-expo-devin/skills/playwright-selectors/SKILL.md +496 -0
- package/plugins/lisa-expo-devin/skills/reduce-complexity/SKILL.md +251 -0
- package/plugins/lisa-expo-devin/skills/reduce-complexity/references/extraction-strategies.md +456 -0
- package/plugins/lisa-expo-devin/skills/reduce-complexity/references/refactoring-patterns.md +557 -0
- package/plugins/lisa-expo-devin/skills/testing-library/SKILL.md +314 -0
- package/plugins/lisa-expo-devin/skills/testing-library/references/async-patterns.md +420 -0
- package/plugins/lisa-expo-devin/skills/testing-library/references/expo-router-testing.md +556 -0
- package/plugins/lisa-expo-devin/skills/testing-library/references/mocking-patterns.md +612 -0
- package/plugins/lisa-expo-devin/skills/testing-library/references/query-priority.md +291 -0
- package/plugins/lisa-expo-devin/skills/upgrading-expo/SKILL.md +134 -0
- package/plugins/lisa-expo-devin/skills/upgrading-expo/references/expo-av-to-audio.md +132 -0
- package/plugins/lisa-expo-devin/skills/upgrading-expo/references/expo-av-to-video.md +160 -0
- package/plugins/lisa-expo-devin/skills/upgrading-expo/references/native-tabs.md +124 -0
- package/plugins/lisa-expo-devin/skills/upgrading-expo/references/new-architecture.md +79 -0
- package/plugins/lisa-expo-devin/skills/upgrading-expo/references/react-19.md +79 -0
- package/plugins/lisa-expo-devin/skills/upgrading-expo/references/react-compiler.md +59 -0
- package/plugins/lisa-expo-devin/skills/upgrading-expo/references/react-navigation-to-expo-router.md +61 -0
- package/plugins/lisa-expo-devin/skills/use-dom/SKILL.md +417 -0
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-devin/.claude-plugin/plugin.json +11 -0
- package/plugins/lisa-harper-fabric-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-harper-fabric-devin/generated-artifact-globs.txt +5 -0
- package/plugins/lisa-harper-fabric-devin/hooks/block-generated-artifact-edits.sh +141 -0
- package/plugins/lisa-harper-fabric-devin/hooks/devin-tool-name-shim.sh +30 -0
- package/plugins/lisa-harper-fabric-devin/hooks/enforce-config-extensions.mjs +196 -0
- package/plugins/lisa-harper-fabric-devin/hooks/enforce-config-extensions.sh +46 -0
- package/plugins/lisa-harper-fabric-devin/hooks/inject-rules.sh +27 -0
- package/plugins/lisa-harper-fabric-devin/hooks.json +36 -0
- package/plugins/lisa-harper-fabric-devin/rules/eager/harper-fabric.md +31 -0
- package/plugins/lisa-harper-fabric-devin/rules/reference/harper-fabric.md +52 -0
- package/plugins/lisa-harper-fabric-devin/skills/e2e-coverage-gaps/SKILL.md +117 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-auth/SKILL.md +368 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-build-and-deploy/SKILL.md +198 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-caching/SKILL.md +265 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-component-model/SKILL.md +87 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-config-yaml/SKILL.md +283 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-operations/SKILL.md +239 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-realtime/SKILL.md +187 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-resources/SKILL.md +142 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-rest-queries/SKILL.md +403 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-schema-graphql/SKILL.md +123 -0
- package/plugins/lisa-harper-fabric-devin/skills/harper-testing/SKILL.md +254 -0
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-devin/.claude-plugin/plugin.json +11 -0
- package/plugins/lisa-nestjs-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-nestjs-devin/hooks/block-migration-edits.sh +125 -0
- package/plugins/lisa-nestjs-devin/hooks/devin-tool-name-shim.sh +30 -0
- package/plugins/lisa-nestjs-devin/hooks/lisa-edit-gate.sh +152 -0
- package/plugins/lisa-nestjs-devin/hooks.json +15 -0
- package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/SKILL.md +176 -0
- package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/advanced-features.md +527 -0
- package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/project-patterns.md +531 -0
- package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/quick-start.md +257 -0
- package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/resolvers-mutations.md +413 -0
- package/plugins/lisa-nestjs-devin/skills/nestjs-graphql/references/types-scalars.md +513 -0
- package/plugins/lisa-nestjs-devin/skills/nestjs-rules/SKILL.md +560 -0
- package/plugins/lisa-nestjs-devin/skills/typeorm-patterns/SKILL.md +275 -0
- package/plugins/lisa-nestjs-devin/skills/typeorm-patterns/references/configuration-patterns.md +487 -0
- package/plugins/lisa-nestjs-devin/skills/typeorm-patterns/references/entity-patterns.md +450 -0
- package/plugins/lisa-nestjs-devin/skills/typeorm-patterns/references/observability-patterns.md +536 -0
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-devin/.claude-plugin/plugin.json +8 -0
- package/plugins/lisa-openclaw-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-repo-topic/SKILL.md +163 -0
- package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-repo-topic/references/repo-topic-config.md +185 -0
- package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-staff/SKILL.md +175 -0
- package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-staff/references/platform-routing.md +123 -0
- package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-connect-staff/references/prompts.md +78 -0
- package/plugins/lisa-openclaw-devin/skills/lisa-openclaw-setup/SKILL.md +141 -0
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-devin/.claude-plugin/plugin.json +11 -0
- package/plugins/lisa-phaser-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-phaser-devin/agents/accessibility-advocate.md +55 -0
- package/plugins/lisa-phaser-devin/agents/art-director.md +90 -0
- package/plugins/lisa-phaser-devin/agents/audio-director.md +65 -0
- package/plugins/lisa-phaser-devin/agents/combat-designer.md +61 -0
- package/plugins/lisa-phaser-devin/agents/economy-designer.md +59 -0
- package/plugins/lisa-phaser-devin/agents/game-designer.md +62 -0
- package/plugins/lisa-phaser-devin/agents/game-feel-specialist.md +56 -0
- package/plugins/lisa-phaser-devin/agents/level-designer.md +59 -0
- package/plugins/lisa-phaser-devin/agents/localization-manager.md +61 -0
- package/plugins/lisa-phaser-devin/agents/marketing-strategist.md +59 -0
- package/plugins/lisa-phaser-devin/agents/monetization-designer.md +59 -0
- package/plugins/lisa-phaser-devin/agents/narrative-designer.md +56 -0
- package/plugins/lisa-phaser-devin/agents/onboarding-advocate.md +59 -0
- package/plugins/lisa-phaser-devin/agents/player-advocate.md +57 -0
- package/plugins/lisa-phaser-devin/agents/producer.md +61 -0
- package/plugins/lisa-phaser-devin/agents/product-analyst.md +62 -0
- package/plugins/lisa-phaser-devin/agents/publisher.md +59 -0
- package/plugins/lisa-phaser-devin/agents/qa-playtester.md +57 -0
- package/plugins/lisa-phaser-devin/agents/target-player.md +52 -0
- package/plugins/lisa-phaser-devin/agents/ux-ui-designer.md +60 -0
- package/plugins/lisa-phaser-devin/hooks/devin-tool-name-shim.sh +30 -0
- package/plugins/lisa-phaser-devin/hooks/inject-rules.sh +27 -0
- package/plugins/lisa-phaser-devin/hooks.json +14 -0
- package/plugins/lisa-phaser-devin/rules/eager/phaser.md +44 -0
- package/plugins/lisa-phaser-devin/rules/reference/phaser.md +184 -0
- package/plugins/lisa-phaser-devin/skills/phaser-accessibility/SKILL.md +135 -0
- package/plugins/lisa-phaser-devin/skills/phaser-asset-pipeline/SKILL.md +222 -0
- package/plugins/lisa-phaser-devin/skills/phaser-asset-sourcing/SKILL.md +151 -0
- package/plugins/lisa-phaser-devin/skills/phaser-build-deploy/SKILL.md +153 -0
- package/plugins/lisa-phaser-devin/skills/phaser-i18n/SKILL.md +122 -0
- package/plugins/lisa-phaser-devin/skills/phaser-project-structure/SKILL.md +89 -0
- package/plugins/lisa-phaser-devin/skills/phaser-services/SKILL.md +208 -0
- package/plugins/lisa-phaser-devin/skills/phaser-testing/SKILL.md +192 -0
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-devin/.claude-plugin/plugin.json +11 -0
- package/plugins/lisa-rails-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-rails-devin/agents/ops-specialist.md +226 -0
- package/plugins/lisa-rails-devin/hooks/devin-tool-name-shim.sh +30 -0
- package/plugins/lisa-rails-devin/hooks/inject-rules.sh +40 -0
- package/plugins/lisa-rails-devin/hooks/lisa-edit-gate.sh +152 -0
- package/plugins/lisa-rails-devin/hooks/rubocop-on-edit.sh +101 -0
- package/plugins/lisa-rails-devin/hooks/sg-scan-on-edit.sh +97 -0
- package/plugins/lisa-rails-devin/hooks.json +29 -0
- package/plugins/lisa-rails-devin/rules/eager/rails-conventions.md +23 -0
- package/plugins/lisa-rails-devin/rules/reference/rails-conventions.md +176 -0
- package/plugins/lisa-rails-devin/skills/action-controller-best-practices/SKILL.md +374 -0
- package/plugins/lisa-rails-devin/skills/action-view-best-practices/SKILL.md +335 -0
- package/plugins/lisa-rails-devin/skills/active-record-model-best-practices/SKILL.md +166 -0
- package/plugins/lisa-rails-devin/skills/e2e-coverage-gaps/SKILL.md +117 -0
- package/plugins/lisa-rails-devin/skills/fix-linter-error/SKILL.md +45 -0
- package/plugins/lisa-rails-devin/skills/improve-code-complexity/SKILL.md +51 -0
- package/plugins/lisa-rails-devin/skills/improve-max-lines/SKILL.md +46 -0
- package/plugins/lisa-rails-devin/skills/improve-max-lines-per-function/SKILL.md +46 -0
- package/plugins/lisa-rails-devin/skills/improve-test-coverage/SKILL.md +48 -0
- package/plugins/lisa-rails-devin/skills/jira-add-journey/SKILL.md +79 -0
- package/plugins/lisa-rails-devin/skills/jira-create/SKILL.md +100 -0
- package/plugins/lisa-rails-devin/skills/jira-evidence/SKILL.md +76 -0
- package/plugins/lisa-rails-devin/skills/jira-evidence/scripts/post-evidence.sh +262 -0
- package/plugins/lisa-rails-devin/skills/jira-journey/SKILL.md +65 -0
- package/plugins/lisa-rails-devin/skills/jira-verify/SKILL.md +26 -0
- package/plugins/lisa-rails-devin/skills/ops-check-logs/SKILL.md +191 -0
- package/plugins/lisa-rails-devin/skills/ops-deploy/SKILL.md +153 -0
- package/plugins/lisa-rails-devin/skills/ops-run-local/SKILL.md +169 -0
- package/plugins/lisa-rails-devin/skills/ops-verify-jobs/SKILL.md +157 -0
- package/plugins/lisa-rails-devin/skills/ops-verify-telemetry/SKILL.md +226 -0
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-devin/.claude-plugin/plugin.json +11 -0
- package/plugins/lisa-typescript-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-typescript-devin/hooks/block-suppress-directives.sh +141 -0
- package/plugins/lisa-typescript-devin/hooks/devin-tool-name-shim.sh +30 -0
- package/plugins/lisa-typescript-devin/hooks/format-on-edit.sh +102 -0
- package/plugins/lisa-typescript-devin/hooks/lint-on-edit.sh +117 -0
- package/plugins/lisa-typescript-devin/hooks/lisa-edit-gate.sh +152 -0
- package/plugins/lisa-typescript-devin/hooks/sg-scan-on-edit.sh +94 -0
- package/plugins/lisa-typescript-devin/hooks.json +34 -0
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-devin/.claude-plugin/plugin.json +8 -0
- package/plugins/lisa-wiki-devin/.devin-plugin/plugin.json +8 -0
- package/plugins/lisa-wiki-devin/ci/lisa-wiki-validate.yml +33 -0
- package/plugins/lisa-wiki-devin/schema/lisa-wiki-config.schema.json +162 -0
- package/plugins/lisa-wiki-devin/schema/wiki-structure.schema.json +51 -0
- package/plugins/lisa-wiki-devin/scripts/_wiki-lib.mjs +204 -0
- package/plugins/lisa-wiki-devin/scripts/diff-guard.mjs +117 -0
- package/plugins/lisa-wiki-devin/scripts/ensure-gitignore.mjs +131 -0
- package/plugins/lisa-wiki-devin/scripts/ensure-wiki.mjs +268 -0
- package/plugins/lisa-wiki-devin/scripts/ingest-git.mjs +198 -0
- package/plugins/lisa-wiki-devin/scripts/ingest-memory.mjs +135 -0
- package/plugins/lisa-wiki-devin/scripts/ingest-roles.mjs +95 -0
- package/plugins/lisa-wiki-devin/scripts/ingest_slack_channel.py +361 -0
- package/plugins/lisa-wiki-devin/scripts/lib/bounded-child.mjs +159 -0
- package/plugins/lisa-wiki-devin/scripts/lint-wiki.mjs +513 -0
- package/plugins/lisa-wiki-devin/scripts/mcp-doctor.mjs +72 -0
- package/plugins/lisa-wiki-devin/scripts/render-contract.mjs +107 -0
- package/plugins/lisa-wiki-devin/scripts/rewrite-refs.mjs +144 -0
- package/plugins/lisa-wiki-devin/scripts/slack_oauth_user.py +179 -0
- package/plugins/lisa-wiki-devin/scripts/validate-config.mjs +321 -0
- package/plugins/lisa-wiki-devin/scripts/verify-migration.mjs +203 -0
- package/plugins/lisa-wiki-devin/scripts/verify-wiki-safety.mjs +126 -0
- package/plugins/lisa-wiki-devin/scripts/wiki-safety.mjs +378 -0
- package/plugins/lisa-wiki-devin/scripts/wiki-status.mjs +345 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-add-ingest/SKILL.md +34 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-add-role/SKILL.md +35 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-confluence/SKILL.md +25 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-docs/SKILL.md +30 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-git/SKILL.md +25 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-jira/SKILL.md +28 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-memory/SKILL.md +28 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-notion/SKILL.md +25 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-roles/SKILL.md +22 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-slack/SKILL.md +30 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-connector-web/SKILL.md +23 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-doctor/SKILL.md +47 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-ingest/SKILL.md +106 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-lint/SKILL.md +32 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-migrate/SKILL.md +43 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-onboard-me/SKILL.md +33 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-query/SKILL.md +40 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-setup/SKILL.md +75 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-setup-automations/SKILL.md +83 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-status/SKILL.md +47 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-tear-down-automations/SKILL.md +34 -0
- package/plugins/lisa-wiki-devin/skills/lisa-wiki-usage/SKILL.md +50 -0
- package/plugins/lisa-wiki-devin/templates/agents/role-agent.claude.md +16 -0
- package/plugins/lisa-wiki-devin/templates/agents/role-agent.codex.toml +15 -0
- package/plugins/lisa-wiki-devin/templates/index.md +17 -0
- package/plugins/lisa-wiki-devin/templates/llm-wiki-contract.md +72 -0
- package/plugins/lisa-wiki-devin/templates/log.md +8 -0
- package/plugins/lisa-wiki-devin/templates/page-types/architecture.md +18 -0
- package/plugins/lisa-wiki-devin/templates/page-types/concept.md +18 -0
- package/plugins/lisa-wiki-devin/templates/page-types/decision.md +18 -0
- package/plugins/lisa-wiki-devin/templates/page-types/entity.md +19 -0
- package/plugins/lisa-wiki-devin/templates/page-types/open-question.md +18 -0
- package/plugins/lisa-wiki-devin/templates/page-types/playbook.md +18 -0
- package/plugins/lisa-wiki-devin/templates/page-types/project.md +19 -0
- package/plugins/lisa-wiki-devin/templates/page-types/requirement.md +19 -0
- package/plugins/lisa-wiki-devin/templates/page-types/staff.md +26 -0
- package/plugins/lisa-wiki-devin/templates/start-here.md +24 -0
- package/plugins/lisa-wiki-devin/templates/state-readme.md +20 -0
- package/plugins/lisa-wiki-devin/templates/wrapper-gitignore.txt +19 -0
- package/scripts/build-plugins.sh +2 -2
- package/scripts/deployed-guard-advice.mjs +1 -0
- package/scripts/generate-devin-plugin-artifacts.mjs +263 -0
- package/scripts/lib/hook-registration-audit.mjs +3 -1
- package/scripts/lib/per-agent-hook-filter.mjs +174 -5
- package/scripts/lib/plugin-rule-citations.mjs +2 -2
- package/scripts/two-channel-couplings.json +1 -1
|
@@ -0,0 +1,2603 @@
|
|
|
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: refuse a direct tracker-creation command that
|
|
6
|
+
# declares no readiness.
|
|
7
|
+
#
|
|
8
|
+
# WHY THIS IS A HOOK AND NOT A RULE
|
|
9
|
+
#
|
|
10
|
+
# The `ready-role-filing` rule says every filing declares either
|
|
11
|
+
# `build_ready: true` or a `human_gate:` reason, and that filings go through
|
|
12
|
+
# `lisa-track` / `lisa-tracker-write`. A conformance audit of the ~13 issues
|
|
13
|
+
# filed during one working session found 13/13 bypassed it, with zero
|
|
14
|
+
# `lisa-track` invocations — eight of them filed AFTER the rule merged, several
|
|
15
|
+
# by the agent that wrote the rule. Over the same window `Co-Authored-By`
|
|
16
|
+
# compliance was 50/50, because a husky `commit-msg` hook enforces it.
|
|
17
|
+
#
|
|
18
|
+
# Prose at the EAGER-RULE rung did not bind even its own author; the executable
|
|
19
|
+
# control was never once violated. Lisa's `learnings-ladder` rule says
|
|
20
|
+
# machine-checkable knowledge belongs at EXECUTABLE-CONTROL. This is that
|
|
21
|
+
# promotion.
|
|
22
|
+
#
|
|
23
|
+
# WHAT IT CHECKS, AND WHY THAT AND NOT "DID YOU USE THE SKILL"
|
|
24
|
+
#
|
|
25
|
+
# A Bash-level hook cannot observe call provenance. Any provenance signal an
|
|
26
|
+
# agent could carry — a flag, a marker, an env var — is settable by the very
|
|
27
|
+
# agent being governed, so a guard built on one is theatre. What the hook CAN
|
|
28
|
+
# observe is the artifact: whether the command about to run produces a
|
|
29
|
+
# correctly declared work item. So it enforces the checkable half.
|
|
30
|
+
#
|
|
31
|
+
# A creation command is refused unless it carries a readiness declaration:
|
|
32
|
+
# - the project's configured build-ready role (GitHub label, JIRA/Linear
|
|
33
|
+
# workflow state) resolved from `.lisa.config.json`, never hard-coded;
|
|
34
|
+
# - on a tracker whose ready role is a STATE rather than a label, the
|
|
35
|
+
# `lifecycle_role: ready` declaration the access layer resolves that state
|
|
36
|
+
# from — because no flag on the mandated client can carry a state, and a
|
|
37
|
+
# guard with no satisfiable declaration is a guard that teaches lying; or
|
|
38
|
+
# - an explicit `[lisa-human-gate]` marker, inline or in the `--body-file`
|
|
39
|
+
# the create is about to submit.
|
|
40
|
+
#
|
|
41
|
+
# WHERE IT LOOKS
|
|
42
|
+
#
|
|
43
|
+
# argv, the request payload (inline, in a `--data-binary @file`, or piped in
|
|
44
|
+
# over stdin from the same pipeline), and the contents of any file the command
|
|
45
|
+
# names. The last of those is what CodySwannGT/lisa#3484 was: the guard
|
|
46
|
+
# inspected argv and nothing else, so `bash /path/create.sh` showed it two
|
|
47
|
+
# tokens and the creation was one file away. Lisa's own `parity-safety-net.sh`
|
|
48
|
+
# tells agents to write payloads to a file and execute the file, so complying
|
|
49
|
+
# with Lisa's guidance produced the bypass.
|
|
50
|
+
#
|
|
51
|
+
# That is exactly the machine-checkable content of `ready-role-filing`, and it
|
|
52
|
+
# lets `lisa-github-write-issue` / `lisa-jira-write-ticket` /
|
|
53
|
+
# `lisa-linear-write-issue` through by construction, because those writers
|
|
54
|
+
# always stamp one. A blanket refusal would have blocked Lisa's own writers and
|
|
55
|
+
# left the factory unable to file anything.
|
|
56
|
+
#
|
|
57
|
+
# Creation signatures, per tracker CLI and the two ways around each:
|
|
58
|
+
# gh issue create · gh api POST to .../issues · gh api graphql createIssue
|
|
59
|
+
# linear issue create · jira issue create · acli … workitem/issue create
|
|
60
|
+
# curl/http POST to api.github.com/…/issues, api.linear.app/graphql with
|
|
61
|
+
# issueCreate, or …atlassian.net/rest/api/…/issue
|
|
62
|
+
# Reads never fire: `gh issue list`, `gh issue view`, `gh issue edit`,
|
|
63
|
+
# `gh pr create`, `gh label create`, a bare `gh api …/issues` GET, and a prose
|
|
64
|
+
# mention inside a quoted string are all allowed.
|
|
65
|
+
#
|
|
66
|
+
# STANDING DOWN
|
|
67
|
+
#
|
|
68
|
+
# - No tracker configured (`.lisa.config.json` absent, or carrying no
|
|
69
|
+
# `tracker`). There is no `lisa-tracker-write` to route through, so the
|
|
70
|
+
# guard has nothing to redirect to. This is the bootstrapping case, and it
|
|
71
|
+
# is DETECTED rather than asserted — the operator does not have to remember
|
|
72
|
+
# an env var to bring up a new repo.
|
|
73
|
+
# - `LISA_ALLOW_DIRECT_ISSUE_CREATE` non-empty in the hook's inherited
|
|
74
|
+
# environment. This is the human operator's override, mirroring
|
|
75
|
+
# `LISA_ALLOW_INSTRUCTION_FILE_WRITE`.
|
|
76
|
+
#
|
|
77
|
+
# The override is honored ONLY from the ambient environment, and is refused
|
|
78
|
+
# outright when it appears as an inline assignment in the intercepted command.
|
|
79
|
+
# That distinction is the whole point: a tool-call shell is fresh every time
|
|
80
|
+
# and its exports do not reach this hook's environment, so the ambient variable
|
|
81
|
+
# can only have been set by a human before the session started (shell profile,
|
|
82
|
+
# settings env block, CI config). An escape the governed agent reaches by
|
|
83
|
+
# typing one more token in front of the command it was just refused is not an
|
|
84
|
+
# escape hatch — it is the prose problem with extra steps.
|
|
85
|
+
#
|
|
86
|
+
# ## What this hook costs, and the number that would change the decision
|
|
87
|
+
#
|
|
88
|
+
# Registered `matcher: ""` since CodySwannGT/lisa#3753 — it runs on EVERY tool
|
|
89
|
+
# call, not only Bash. Measured on a developer machine at load ~8-26, 50
|
|
90
|
+
# invocations per row:
|
|
91
|
+
#
|
|
92
|
+
# 16.7 ms enforce-team-first.sh already `matcher: ""` today
|
|
93
|
+
# 17.5 ms enforce-verification-gate.sh already `matcher: ""` today
|
|
94
|
+
# 11.5 ms this guard, non-Bash payload what the broad matcher adds
|
|
95
|
+
# 112.3 ms this guard, Bash payload what Bash calls already paid
|
|
96
|
+
#
|
|
97
|
+
# So the broad registration was not novel — two hooks were already there, this
|
|
98
|
+
# one is cheaper than either, and it raises an existing ~34 ms per-call baseline
|
|
99
|
+
# by about a third.
|
|
100
|
+
#
|
|
101
|
+
# THIS IS A THRESHOLD, NOT A PRECEDENT. The argument is about the TOTAL cost of
|
|
102
|
+
# the broad set, so it does not transfer to the next guard: four more hooks
|
|
103
|
+
# moved to `matcher: ""` would put ~90 ms on every tool call and the answer
|
|
104
|
+
# flips. **Before widening any other hook's matcher, re-run the measurement and
|
|
105
|
+
# reconsider if the broad set totals more than ~100 ms per tool call, or if any
|
|
106
|
+
# single broad hook exceeds ~50 ms.** Do not cite these numbers for a fifth
|
|
107
|
+
# hook; measure again, because the number that matters is the sum.
|
|
108
|
+
#
|
|
109
|
+
# The cost is also irreducible rather than sloppy. A 9-line script containing
|
|
110
|
+
# only this file's preamble measured 11.7 ms, and padding it to this file's
|
|
111
|
+
# length changed nothing (11.5 ms) — it is process spawn plus one `jq`, not
|
|
112
|
+
# parsing. Moving the early exit further up the file buys nothing; the exit is
|
|
113
|
+
# already ahead of config resolution, which is what keeps the figure at 11.5
|
|
114
|
+
# rather than at the 112 ms the Bash path pays.
|
|
115
|
+
set -euo pipefail
|
|
116
|
+
|
|
117
|
+
input="$(cat)"
|
|
118
|
+
|
|
119
|
+
# Evaluate once per tool call when this guard is registered on both channels.
|
|
120
|
+
#
|
|
121
|
+
# Lisa reaches an agent through the repository dispatcher AND the plugin
|
|
122
|
+
# manifest, and where both are live the harness runs this guard twice for one
|
|
123
|
+
# tool call. `guard-dedupe.bash` short-circuits the second run ONLY when a
|
|
124
|
+
# byte-identical copy already ALLOWED this exact payload on this exact tool
|
|
125
|
+
# call; a differing vintage, a refusal, and a host with one channel all
|
|
126
|
+
# evaluate exactly as before. Nothing is de-registered by it
|
|
127
|
+
# (CodySwannGT/lisa#3814).
|
|
128
|
+
#
|
|
129
|
+
# Absent library means no dedupe, which is the pre-existing behaviour, so an
|
|
130
|
+
# older channel copy that predates it is unaffected.
|
|
131
|
+
lisa_guard_hook_dir="${BASH_SOURCE[0]%/*}"
|
|
132
|
+
lisa_guard_dedupe_lib="$lisa_guard_hook_dir/guard-dedupe.bash"
|
|
133
|
+
if [ -r "$lisa_guard_dedupe_lib" ]; then
|
|
134
|
+
# shellcheck source=guard-dedupe.bash
|
|
135
|
+
. "$lisa_guard_dedupe_lib"
|
|
136
|
+
trap 'lisa_guard_dedupe_record $?' EXIT
|
|
137
|
+
lisa_guard_dedupe block-direct-issue-create "$input"
|
|
138
|
+
fi
|
|
139
|
+
|
|
140
|
+
# Probe both interpreters before use and announce a missing one rather than
|
|
141
|
+
# swallowing it. Under `set -e` an absent jq aborts with 127, and Claude Code
|
|
142
|
+
# treats any non-2 exit as a NON-BLOCKING hook error — so the guard would
|
|
143
|
+
# silently permit exactly what it exists to stop. Degrading to "allow" is
|
|
144
|
+
# right (a hook that cannot parse its input cannot tell a filing from a read),
|
|
145
|
+
# but doing it quietly is not: a guard that is silently absent reads exactly
|
|
146
|
+
# like a guard that is passing. Same reasoning as block-no-verify.sh.
|
|
147
|
+
for required in jq python3; do
|
|
148
|
+
if ! command -v "$required" >/dev/null 2>&1; then
|
|
149
|
+
printf 'block-direct-issue-create: %s not found; ready-role filing enforcement is NOT active\n' \
|
|
150
|
+
"$required" >&2
|
|
151
|
+
exit 0
|
|
152
|
+
fi
|
|
153
|
+
done
|
|
154
|
+
|
|
155
|
+
tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null || true)"
|
|
156
|
+
|
|
157
|
+
# ── The two substrates ────────────────────────────────────────────────────
|
|
158
|
+
#
|
|
159
|
+
# A creation reaches a tracker two ways, and until CodySwannGT/lisa#3753 this
|
|
160
|
+
# guard saw only one of them. It was registered `matcher: "Bash"` AND gated
|
|
161
|
+
# here on `tool_name != "Bash"`, so an MCP tool call was refused entry twice
|
|
162
|
+
# over. MEASURED before the change, driving the guard with synthetic payloads:
|
|
163
|
+
#
|
|
164
|
+
# ALLOW mcp__linear-server__create_issue, undeclared <- the defect
|
|
165
|
+
# ALLOW mcp__github__create_issue, undeclared <- the defect
|
|
166
|
+
# REFUSE the same creation as `gh issue create` shell text <- control
|
|
167
|
+
# ALLOW an ordinary Read tool call <- control
|
|
168
|
+
#
|
|
169
|
+
# The two controls are what make those ALLOWs mean anything: the refusal proves
|
|
170
|
+
# the guard is live, the Read proves it is not simply refusing everything.
|
|
171
|
+
#
|
|
172
|
+
# **Widening the matcher alone would have changed nothing** — the call would
|
|
173
|
+
# arrive and this gate would still exit 0. Anyone who "fixed" #3753 by editing
|
|
174
|
+
# `plugin.json` would have seen no test fail and shipped a guard that still
|
|
175
|
+
# allows every MCP filing. The registration and this gate had to move together.
|
|
176
|
+
#
|
|
177
|
+
# The SHELL path below is untouched. A structured payload has no command line
|
|
178
|
+
# to tokenise, so it gets its own small classifier rather than being forced
|
|
179
|
+
# through a parser built for shell text.
|
|
180
|
+
structured_call=""
|
|
181
|
+
if [ "$tool_name" != "Bash" ]; then
|
|
182
|
+
# The cheap shape gate, deliberately BEFORE config resolution.
|
|
183
|
+
#
|
|
184
|
+
# This hook now runs on EVERY tool call, so the cost of the path that does
|
|
185
|
+
# nothing is the cost of the whole change. Config resolution spawns two jq
|
|
186
|
+
# processes per lookup and is what makes the Bash path ~112 ms; putting it
|
|
187
|
+
# ahead of this gate would charge that to every Read and Grep. So the
|
|
188
|
+
# not-a-creation exit happens here, on a shell `case` with no subprocess.
|
|
189
|
+
#
|
|
190
|
+
# Matched on SHAPE, not on an enumerated list of server tool names. Server
|
|
191
|
+
# names are supplied by whoever wrote the server and change when one is
|
|
192
|
+
# added or renamed, so a list would pass every row anyone thought of and
|
|
193
|
+
# miss the first one nobody did — the failure mode this repository's guard
|
|
194
|
+
# suites are written to defeat.
|
|
195
|
+
#
|
|
196
|
+
# RESIDUAL, stated rather than hidden: a server whose creation tool is named
|
|
197
|
+
# without a create-verb or without a tracker noun (`mcp__x__file_work`) is
|
|
198
|
+
# not recognised and is allowed. That is a fail-open, and it is the honest
|
|
199
|
+
# cost of refusing to enumerate. Add shapes here as they are measured.
|
|
200
|
+
case "$tool_name" in
|
|
201
|
+
Bash | Read | Write | Edit | MultiEdit | Glob | Grep | Task | TodoWrite | \
|
|
202
|
+
TaskCreate | TaskGet | TaskList | TaskOutput | TaskStop | TaskUpdate)
|
|
203
|
+
# The `Task*` family is Claude Code's in-session task list — LOCAL
|
|
204
|
+
# scratch, not a tracker write. `TaskCreate` carries `"type": "bug"`
|
|
205
|
+
# metadata the lisa-implement skill itself prescribes, so its name
|
|
206
|
+
# satisfies both shape gates below (create-verb + task noun) and was
|
|
207
|
+
# refused as "a tracker creation through TaskCreate"
|
|
208
|
+
# (CodySwannGT/lisa#4274). MCP creations are namespaced `mcp__*__*` and
|
|
209
|
+
# can never collide with a bare built-in name, so enumerating the
|
|
210
|
+
# built-ins here does not reopen the shape matcher for real filings.
|
|
211
|
+
exit 0
|
|
212
|
+
;;
|
|
213
|
+
esac
|
|
214
|
+
# These terminal actions edit an existing item, not create the item.
|
|
215
|
+
# Strip only the measured action shape, then still inspect any preceding
|
|
216
|
+
# compound operation: create_issue_and_add_issue_comment remains a filing.
|
|
217
|
+
creation_name="$tool_name"
|
|
218
|
+
case "$creation_name" in
|
|
219
|
+
*[Aa]dd_[Ii]ssue_[Cc]omment)
|
|
220
|
+
creation_name="${creation_name%[Aa]dd_[Ii]ssue_[Cc]omment}" ;;
|
|
221
|
+
*[Aa]dd[Cc]omment[Tt]o[Jj]ira[Ii]ssue)
|
|
222
|
+
creation_name="${creation_name%[Aa]dd[Cc]omment[Tt]o[Jj]ira[Ii]ssue}" ;;
|
|
223
|
+
*[Aa]dd_[Cc]omment_[Tt]o_[Ii]ssue)
|
|
224
|
+
creation_name="${creation_name%[Aa]dd_[Cc]omment_[Tt]o_[Ii]ssue}" ;;
|
|
225
|
+
*[Aa]dd_[Ii]ssue_[Ll]abels)
|
|
226
|
+
creation_name="${creation_name%[Aa]dd_[Ii]ssue_[Ll]abels}" ;;
|
|
227
|
+
*[Aa]dd_[Ll]abels_[Tt]o_[Ii]ssue)
|
|
228
|
+
creation_name="${creation_name%[Aa]dd_[Ll]abels_[Tt]o_[Ii]ssue}" ;;
|
|
229
|
+
*[Aa]dd_[Rr]eaction_[Tt]o_[Ii]ssue_[Cc]omment)
|
|
230
|
+
creation_name="${creation_name%[Aa]dd_[Rr]eaction_[Tt]o_[Ii]ssue_[Cc]omment}" ;;
|
|
231
|
+
esac
|
|
232
|
+
case "$creation_name" in
|
|
233
|
+
*[Cc]reate* | *[Nn]ew* | *[Aa]dd* | *[Ff]ile*) ;;
|
|
234
|
+
*) exit 0 ;;
|
|
235
|
+
esac
|
|
236
|
+
case "$creation_name" in
|
|
237
|
+
*[Ii]ssue* | *[Tt]icket* | *[Tt]ask* | *[Ss]tory* | *[Bb]ug* | *[Ee]pic* | *[Ww]ork*) ;;
|
|
238
|
+
*) exit 0 ;;
|
|
239
|
+
esac
|
|
240
|
+
structured_call="1"
|
|
241
|
+
fi
|
|
242
|
+
|
|
243
|
+
command_str=""
|
|
244
|
+
if [ -z "$structured_call" ]; then
|
|
245
|
+
command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
|
|
246
|
+
if [ -z "$command_str" ]; then
|
|
247
|
+
exit 0
|
|
248
|
+
fi
|
|
249
|
+
fi
|
|
250
|
+
|
|
251
|
+
project_dir="${CLAUDE_PROJECT_DIR:-}"
|
|
252
|
+
if [ -z "$project_dir" ]; then
|
|
253
|
+
project_dir="$PWD"
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
# Merged config, local overlay over base — the same precedence
|
|
257
|
+
# `lisa-tracker-read` / `lisa-tracker-write` resolve with, so the guard can
|
|
258
|
+
# never disagree with the writer about which tracker a project has.
|
|
259
|
+
read_config_value() {
|
|
260
|
+
local filter="$1"
|
|
261
|
+
local value=""
|
|
262
|
+
local file
|
|
263
|
+
for file in "$project_dir/.lisa.config.json" "$project_dir/.lisa.config.local.json"; do
|
|
264
|
+
[ -f "$file" ] || continue
|
|
265
|
+
local candidate
|
|
266
|
+
candidate="$(jq -r "$filter // empty" "$file" 2>/dev/null || true)"
|
|
267
|
+
[ -n "$candidate" ] && value="$candidate"
|
|
268
|
+
done
|
|
269
|
+
printf '%s' "$value"
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
tracker="$(read_config_value '.tracker')"
|
|
273
|
+
if [ -z "$tracker" ]; then
|
|
274
|
+
exit 0
|
|
275
|
+
fi
|
|
276
|
+
|
|
277
|
+
# The build-ready role is read from config, never hard-coded: a project that
|
|
278
|
+
# renamed its ready lane must still be able to satisfy the guard, and the
|
|
279
|
+
# refusal has to name the token that project actually uses.
|
|
280
|
+
default_ready_role="status:ready"
|
|
281
|
+
case "$tracker" in
|
|
282
|
+
github) ready_role="$(read_config_value '.github.labels.build.ready')" ;;
|
|
283
|
+
jira) ready_role="$(read_config_value '.jira.workflow.ready')" ;;
|
|
284
|
+
linear) ready_role="$(read_config_value '.linear.workflow.ready')" ;;
|
|
285
|
+
*) ready_role="" ;;
|
|
286
|
+
esac
|
|
287
|
+
if [ -z "$ready_role" ]; then
|
|
288
|
+
ready_role="$default_ready_role"
|
|
289
|
+
fi
|
|
290
|
+
|
|
291
|
+
# WHICH REPOSITORY'S VOCABULARY ANSWERS FOR THIS FILING
|
|
292
|
+
#
|
|
293
|
+
# The role above is the CALLING project's. For a same-repo filing that is the
|
|
294
|
+
# right question. For a filing addressed at a DIFFERENT repository — which
|
|
295
|
+
# Lisa ships a first-class, cron-driven path for in `lisa-persist-learning` —
|
|
296
|
+
# it is the wrong repository's vocabulary, and the guard demanded a token the
|
|
297
|
+
# target does not carry:
|
|
298
|
+
#
|
|
299
|
+
# - a JIRA or Linear caller's ready role is a workflow STATE. Demanded as a
|
|
300
|
+
# `gh --label` on another repo it is unsatisfiable, because that label does
|
|
301
|
+
# not exist there and `gh` rejects an unknown one. Obeying the guard made
|
|
302
|
+
# the command fail, which is not the same thing as being refused.
|
|
303
|
+
# - a GitHub caller that renamed its ready lane demanded its own token of a
|
|
304
|
+
# repository that never had it.
|
|
305
|
+
# - a GitHub caller on the stock lane worked only because both repositories
|
|
306
|
+
# happened to choose the same string. That is a coincidence, not routing.
|
|
307
|
+
#
|
|
308
|
+
# The one escape that IS satisfiable cross-repo, `[lisa-human-gate]`, is a lie
|
|
309
|
+
# about the item: it stamps a build-ready defect report as held for a human
|
|
310
|
+
# product call, and the target's build queue scans the ready role and nothing
|
|
311
|
+
# else. The report is filed and never picked up — precisely the incomplete
|
|
312
|
+
# handoff this guard exists to prevent, committed one repository over.
|
|
313
|
+
#
|
|
314
|
+
# So the target's own role answers, resolved from CONFIG rather than the
|
|
315
|
+
# network. A live `gh api repos/<o>/<r>/labels` lookup would be more general
|
|
316
|
+
# and is the wrong trade for a PreToolUse hook: a network round-trip on every
|
|
317
|
+
# intercepted command, and a new fail-open surface when it errors.
|
|
318
|
+
own_org="$(read_config_value '.github.org')"
|
|
319
|
+
own_name="$(read_config_value '.github.repo')"
|
|
320
|
+
own_repo=""
|
|
321
|
+
if [ -n "$own_org" ] && [ -n "$own_name" ]; then
|
|
322
|
+
own_repo="$own_org/$own_name"
|
|
323
|
+
fi
|
|
324
|
+
|
|
325
|
+
# `hardening.upstreamRepo` already names the upstream repository across Lisa's
|
|
326
|
+
# filing skills; `hardening.upstreamReadyRole` is its sibling, so the guard
|
|
327
|
+
# keeps its existing discipline — read the role from config, never hard-code it
|
|
328
|
+
# — while reading it from the RIGHT repository's config.
|
|
329
|
+
upstream_repo="$(read_config_value '.hardening.upstreamRepo')"
|
|
330
|
+
if [ -z "$upstream_repo" ]; then
|
|
331
|
+
upstream_repo="CodySwannGT/lisa"
|
|
332
|
+
fi
|
|
333
|
+
upstream_ready_role="$(read_config_value '.hardening.upstreamReadyRole')"
|
|
334
|
+
if [ -z "$upstream_ready_role" ]; then
|
|
335
|
+
upstream_ready_role="$default_ready_role"
|
|
336
|
+
fi
|
|
337
|
+
|
|
338
|
+
# A non-GitHub caller's ready role is a workflow STATE, so it is never the right
|
|
339
|
+
# vocabulary for a GitHub target no matter what the target turns out to be. The
|
|
340
|
+
# classifier needs to know that even when this project declares no repo of its
|
|
341
|
+
# own to compare against.
|
|
342
|
+
caller_is_github="0"
|
|
343
|
+
if [ "$tracker" = "github" ]; then
|
|
344
|
+
caller_is_github="1"
|
|
345
|
+
fi
|
|
346
|
+
|
|
347
|
+
# Ambient-only override. Deliberately read here, from the hook process's own
|
|
348
|
+
# environment, and never from the command being inspected.
|
|
349
|
+
ambient_override="${LISA_ALLOW_DIRECT_ISSUE_CREATE:-}"
|
|
350
|
+
|
|
351
|
+
# How to write the declaration down on THIS project's tracker. The answer is
|
|
352
|
+
# not the same everywhere, and printing the GitHub answer at a Linear operator
|
|
353
|
+
# is what made this guard unsatisfiable: it named a `--label` flag that the
|
|
354
|
+
# mandated client does not have, on a role that is not a label.
|
|
355
|
+
declaration_hint() {
|
|
356
|
+
case "$tracker" in
|
|
357
|
+
jira | linear)
|
|
358
|
+
cat <<EOF
|
|
359
|
+
Your build-ready role \`$ready_role\` is a workflow STATE, not a label, and
|
|
360
|
+
the mandated client is \`curl\` — which has no flag that carries a state. So
|
|
361
|
+
declare the LIFECYCLE ROLE the access layer resolves the state from, and let
|
|
362
|
+
it do the resolving:
|
|
363
|
+
|
|
364
|
+
LIFECYCLE_ROLE=ready curl -sS -X POST <the tracker endpoint> …
|
|
365
|
+
|
|
366
|
+
or \`lifecycle_role:ready\` in the request payload, or a \`--state\` /
|
|
367
|
+
\`--status\` flag where the CLI has one. The \`$tracker\` access layer takes
|
|
368
|
+
that role, resolves it against the tracker's own state catalog, and fails
|
|
369
|
+
CLOSED if it cannot — so the token is the input that decides the lane, not a
|
|
370
|
+
decoration.
|
|
371
|
+
EOF
|
|
372
|
+
;;
|
|
373
|
+
*)
|
|
374
|
+
cat <<EOF
|
|
375
|
+
The command has to carry the configured build-ready role \`$ready_role\` as
|
|
376
|
+
the value of a \`--label\` / \`--status\` / \`--state\` flag — not in the
|
|
377
|
+
title or body, because a role named in prose is not a role applied.
|
|
378
|
+
EOF
|
|
379
|
+
;;
|
|
380
|
+
esac
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
# The branch-specific half of the remediation, printed only for the branch that
|
|
384
|
+
# fired. Every refusal this guard issues has to name an inverse the author can
|
|
385
|
+
# actually perform — a refusal with none is a state change with no inverse, and
|
|
386
|
+
# on a file-contents refusal it made a path permanently unnameable to any
|
|
387
|
+
# command the guard did not recognise as a reader (CodySwannGT/lisa#3683).
|
|
388
|
+
#
|
|
389
|
+
# NEITHER PARAGRAPH IS AN ESCAPE HATCH, and that is deliberate. `3594` records
|
|
390
|
+
# that a PreToolUse guard whose escape the guarded agent can set is not a
|
|
391
|
+
# control, so nothing here is settable: the only inverses named are edits to
|
|
392
|
+
# the refused command itself, and performing either one leaves a genuine
|
|
393
|
+
# undeclared creation refused by the checks above. Re-quoting an unlexable
|
|
394
|
+
# creation hands it to the parsed path, which refuses it; naming a document to
|
|
395
|
+
# a reader files nothing, which is the whole point. The one true escape stays
|
|
396
|
+
# what it was — an ambient variable a human exports before the session, which a
|
|
397
|
+
# tool-call shell cannot reach because its exports do not survive into this
|
|
398
|
+
# hook's environment.
|
|
399
|
+
remedy_hint() {
|
|
400
|
+
case "${1:-}" in
|
|
401
|
+
unparseable)
|
|
402
|
+
cat <<'EOF'
|
|
403
|
+
|
|
404
|
+
THIS COMMAND DID NOT LEX, so it was judged by pattern rather than by parse. An
|
|
405
|
+
unbalanced quote is the cause, and an apostrophe in ordinary prose is the
|
|
406
|
+
measured one. The pattern cannot separate a sentence about filing from a real
|
|
407
|
+
filing hidden behind a trailing `#` comment, so it refuses both.
|
|
408
|
+
|
|
409
|
+
THE INVERSE IS EXECUTABLE: re-quote the command so it lexes, and run it again.
|
|
410
|
+
It is not a bypass — a re-quoted creation is handed to the full parser, which
|
|
411
|
+
refuses it unless it declares readiness the ordinary way.
|
|
412
|
+
|
|
413
|
+
A command whose every segment runs a known READER is not refused on this branch
|
|
414
|
+
at all, so `cat`, `grep`, `ls`, `cp`, `mv`, `git` and `echo` never reach it.
|
|
415
|
+
EOF
|
|
416
|
+
;;
|
|
417
|
+
file)
|
|
418
|
+
cat <<'EOF'
|
|
419
|
+
|
|
420
|
+
THIS REFUSAL CAME FROM A FILE THIS COMMAND RUNS. A path is opened only when the
|
|
421
|
+
command puts it in a COMMAND position; a program that takes it as data — `cat`,
|
|
422
|
+
`grep`, `ls`, `cp`, `mv`, `git`, a test runner — never reaches this branch. Two
|
|
423
|
+
inverses, both executable:
|
|
424
|
+
|
|
425
|
+
- The file WRITES a payload locally and cannot transmit it (no HTTP client,
|
|
426
|
+
no CLI, no process spawn): it is read as data, not as a filing. Drop the
|
|
427
|
+
transmitting primitive, or slice the payload out of an existing file
|
|
428
|
+
instead of restating it here.
|
|
429
|
+
- The file produces no local artifact either — it is a DOCUMENT that quotes a
|
|
430
|
+
creation rather than a program that performs one. Name it to a reader
|
|
431
|
+
rather than to an interpreter; rewording its contents is not the remedy and
|
|
432
|
+
does not converge.
|
|
433
|
+
EOF
|
|
434
|
+
;;
|
|
435
|
+
esac
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
refuse() {
|
|
439
|
+
local signature="$1"
|
|
440
|
+
local roles="$2"
|
|
441
|
+
local target="$3"
|
|
442
|
+
local remedy="${4:-}"
|
|
443
|
+
if [ -n "$target" ]; then
|
|
444
|
+
refuse_cross_repo "$signature" "$roles" "$target"
|
|
445
|
+
fi
|
|
446
|
+
cat >&2 <<EOF
|
|
447
|
+
BLOCKED: refusing \`$signature\` — this filing declares no readiness.
|
|
448
|
+
|
|
449
|
+
WHY: a work item filed without the build-ready role is an incomplete handoff.
|
|
450
|
+
Build-intake scans the ready lane and nothing else, so nothing will ever pick
|
|
451
|
+
it up: the write succeeds and the work still dies. An audit of one working
|
|
452
|
+
session found 13 of 13 issues filed this way, none of them through Lisa's
|
|
453
|
+
filing path. The rule saying not to do this already existed — it did not bind,
|
|
454
|
+
so it is now enforced here.
|
|
455
|
+
|
|
456
|
+
FILE IT THE SANCTIONED WAY — one of these three, always explicit:
|
|
457
|
+
|
|
458
|
+
1. The item is complete enough to build. Use the filing flow, not the CLI:
|
|
459
|
+
|
|
460
|
+
/lisa:track "<what needs building>"
|
|
461
|
+
|
|
462
|
+
which resolves or creates exactly one live leaf through
|
|
463
|
+
\`lisa-tracker-write\` with \`build_ready: true\`, validates it before the
|
|
464
|
+
write, and claims it. Complete means the \`work-item-definition-of-ready\`
|
|
465
|
+
bar — reproduction, observed-versus-expected, Gherkin acceptance criteria.
|
|
466
|
+
|
|
467
|
+
2. A human product call is genuinely pending. Route the same way but pass
|
|
468
|
+
\`human_gate: "<why a human must judge this first>"\` in place of
|
|
469
|
+
\`build_ready: true\` — the two are mutually exclusive. The leaf is filed
|
|
470
|
+
into the default backlog role, outside the lane build-intake claims from,
|
|
471
|
+
and is deliberately NOT claimed, so it keeps attracting the human attention
|
|
472
|
+
it was filed for. The hold is stamped so it is auditable rather than
|
|
473
|
+
indistinguishable from an accident:
|
|
474
|
+
|
|
475
|
+
Held for a human product call: <reason>.
|
|
476
|
+
<!-- [lisa-human-gate] reason=<short-slug> -->
|
|
477
|
+
|
|
478
|
+
3. The item is a CONTAINER — an Epic, or any item whose state rolls up from
|
|
479
|
+
its children. It is neither of the two above and must not pretend to be:
|
|
480
|
+
\`leaf-only-lifecycle\` FORBIDS the build-ready role on a container, and a
|
|
481
|
+
human gate it does not have is a durable false hold. It declares the third
|
|
482
|
+
thing instead — the canonical container line, in place of a Target Backend
|
|
483
|
+
Environment:
|
|
484
|
+
|
|
485
|
+
## Target Backend Environment
|
|
486
|
+
|
|
487
|
+
None — container: state rolls up from children
|
|
488
|
+
|
|
489
|
+
That declaration is refused alongside a by-design leaf type
|
|
490
|
+
(\`type:Bug\` / \`type:Task\` / \`type:Sub-task\` / \`type:Improvement\`):
|
|
491
|
+
an item cannot be a container and a leaf at once.
|
|
492
|
+
|
|
493
|
+
Filed, not ready, and no \`human_gate\` is the incomplete-handoff case, and
|
|
494
|
+
\`build_ready: false\` with no reason is the same omission with a value
|
|
495
|
+
attached. See the \`ready-role-filing\` rule for the full contract.
|
|
496
|
+
|
|
497
|
+
If you must run the CLI directly, the command has to carry one of the three
|
|
498
|
+
declarations itself:
|
|
499
|
+
|
|
500
|
+
$(declaration_hint)
|
|
501
|
+
|
|
502
|
+
Or a \`[lisa-human-gate]\` marker in the body it submits, when a human
|
|
503
|
+
product call really is pending.
|
|
504
|
+
|
|
505
|
+
Or, for a container, \`None — container: state rolls up from children\` in
|
|
506
|
+
that body, and no by-design leaf type on the command.
|
|
507
|
+
|
|
508
|
+
WHERE THE DECLARATION IS READ FROM: argv, the request payload — inline, in a
|
|
509
|
+
\`--data-binary @file\`, or piped in over stdin — and the contents of a script
|
|
510
|
+
this command runs. Moving the create into a file no longer moves it out of
|
|
511
|
+
sight, so the declaration can live wherever the create does.
|
|
512
|
+
|
|
513
|
+
$(remedy_hint "$remedy")
|
|
514
|
+
|
|
515
|
+
OPERATOR ESCAPE: a human can export \`LISA_ALLOW_DIRECT_ISSUE_CREATE=1\` in the
|
|
516
|
+
environment before starting the session. It is deliberately not reachable by
|
|
517
|
+
setting it inline on this command — an inline assignment is refused.
|
|
518
|
+
EOF
|
|
519
|
+
exit 2
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
# The cross-repo refusal is a separate message, not a variable swapped into the
|
|
523
|
+
# one above, because its REMEDIATION is different. The local filing flow writes
|
|
524
|
+
# to this project's own tracker and structurally cannot reach another
|
|
525
|
+
# repository, so naming it here would send the agent to a path that cannot do
|
|
526
|
+
# the thing it was just refused for. Name the route that reaches the target,
|
|
527
|
+
# and name the target's role rather than this project's.
|
|
528
|
+
refuse_cross_repo() {
|
|
529
|
+
local signature="$1"
|
|
530
|
+
local roles="$2"
|
|
531
|
+
local target="$3"
|
|
532
|
+
cat >&2 <<EOF
|
|
533
|
+
BLOCKED: refusing \`$signature\` — this filing declares no readiness.
|
|
534
|
+
|
|
535
|
+
WHY: a work item filed without the build-ready role is an incomplete handoff.
|
|
536
|
+
Build-intake scans the ready lane and nothing else, so nothing will ever pick
|
|
537
|
+
it up: the write succeeds and the work still dies.
|
|
538
|
+
|
|
539
|
+
THIS FILING IS ADDRESSED AT ANOTHER REPOSITORY: \`$target\`.
|
|
540
|
+
That repository runs its own build queue off its own ready role, so this
|
|
541
|
+
project's role does not answer for it — and this project's filing flow writes
|
|
542
|
+
to this project's tracker, so it cannot reach the target at all.
|
|
543
|
+
|
|
544
|
+
FILE IT THE SANCTIONED WAY:
|
|
545
|
+
|
|
546
|
+
1. An upstream defect or hardening report — the highest-signal report there is,
|
|
547
|
+
because it is reproduced and attributed rather than guessed at. Use the
|
|
548
|
+
upstream filing path, which composes a redacted, public-safe body through an
|
|
549
|
+
allowlist projection instead of free-form prose:
|
|
550
|
+
|
|
551
|
+
bunx @codyswann/lisa file-upstream --input <filing-event>.json
|
|
552
|
+
|
|
553
|
+
\`lisa-persist-learning\` step 6 runs exactly this, headless, on a cron, and
|
|
554
|
+
files the result with explicit \`build_ready: true\` so the target's queue
|
|
555
|
+
picks it up.
|
|
556
|
+
|
|
557
|
+
2. If you must run the CLI directly, the command has to carry the TARGET
|
|
558
|
+
repository's build-ready role — \`$roles\` — as the value of a \`--label\`
|
|
559
|
+
flag. Configure it as \`hardening.upstreamReadyRole\` when the target renamed
|
|
560
|
+
its lane.
|
|
561
|
+
|
|
562
|
+
DO NOT reach for \`[lisa-human-gate]\` to get past this one. It still satisfies
|
|
563
|
+
the guard — it is a real declaration — but on an upstream defect report it is a
|
|
564
|
+
false one: it stamps the item as held for a human product call, and the
|
|
565
|
+
target's build queue scans the ready role and nothing else. The report is filed
|
|
566
|
+
and never picked up, which is the incomplete handoff this guard exists to
|
|
567
|
+
prevent, committed one repository over. Use it only when a human product call
|
|
568
|
+
is genuinely pending on the target.
|
|
569
|
+
|
|
570
|
+
OPERATOR ESCAPE: a human can export \`LISA_ALLOW_DIRECT_ISSUE_CREATE=1\` in the
|
|
571
|
+
environment before starting the session. It is deliberately not reachable by
|
|
572
|
+
setting it inline on this command — an inline assignment is refused.
|
|
573
|
+
EOF
|
|
574
|
+
exit 2
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
# The classifier is read into a variable with a top-level here-document rather
|
|
578
|
+
# than piped straight in from inside `$( … )`. bash 3.2 — which is what macOS
|
|
579
|
+
# still ships as /bin/bash, and therefore what this fleet's hooks run under —
|
|
580
|
+
# mis-parses a here-document nested in a command substitution, scanning the
|
|
581
|
+
# document body for quotes and parentheses it should be treating as literal.
|
|
582
|
+
# The whole script then fails to parse, which is the worst possible failure for
|
|
583
|
+
# a guard: a syntax error exits non-zero, and Claude Code reads every non-2
|
|
584
|
+
# exit as a non-blocking hook error, so the command runs unchecked.
|
|
585
|
+
classifier=""
|
|
586
|
+
read -r -d '' classifier <<'PY' || true
|
|
587
|
+
import os
|
|
588
|
+
import re
|
|
589
|
+
import shlex
|
|
590
|
+
import sys
|
|
591
|
+
|
|
592
|
+
command = os.environ.get("LISA_GUARD_COMMAND", "")
|
|
593
|
+
ready_role = os.environ.get("LISA_GUARD_READY_ROLE", "")
|
|
594
|
+
ambient_override = os.environ.get("LISA_GUARD_AMBIENT_OVERRIDE", "")
|
|
595
|
+
default_ready_role = os.environ.get("LISA_GUARD_DEFAULT_READY_ROLE", "")
|
|
596
|
+
own_repo = os.environ.get("LISA_GUARD_OWN_REPO", "").strip().lower()
|
|
597
|
+
upstream_repo = os.environ.get("LISA_GUARD_UPSTREAM_REPO", "").strip().lower()
|
|
598
|
+
upstream_ready_role = os.environ.get("LISA_GUARD_UPSTREAM_READY_ROLE", "")
|
|
599
|
+
caller_is_github = os.environ.get("LISA_GUARD_CALLER_IS_GITHUB", "") == "1"
|
|
600
|
+
tracker = os.environ.get("LISA_GUARD_TRACKER", "").strip().lower()
|
|
601
|
+
project_dir = os.environ.get("LISA_GUARD_PROJECT_DIR", "")
|
|
602
|
+
|
|
603
|
+
OVERRIDE_NAME = "LISA_ALLOW_DIRECT_ISSUE_CREATE"
|
|
604
|
+
HUMAN_GATE_MARKER = "[lisa-human-gate]"
|
|
605
|
+
|
|
606
|
+
# Decoration a hold declaration may sit behind, and nothing more: whitespace,
|
|
607
|
+
# blockquote arrows, list bullets or numbers, emphasis, an opening HTML
|
|
608
|
+
# comment. One character class plus two optional groups, deliberately not a
|
|
609
|
+
# nested quantifier — this runs over untrusted issue bodies.
|
|
610
|
+
HUMAN_GATE_DECORATION = re.compile(r"^[ \t>*_+#-]*(?:\d+[.)][ \t]*)?(?:<!--[ \t]*)?[ \t]*")
|
|
611
|
+
HUMAN_GATE_FENCE = re.compile(r"```.*?```", re.DOTALL)
|
|
612
|
+
HUMAN_GATE_CODE_SPAN = re.compile(r"`[^`\n]*`")
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def declares_human_gate(text):
|
|
616
|
+
"""Whether a text DECLARES a hold rather than mentioning one.
|
|
617
|
+
|
|
618
|
+
The comment this replaces said the marker is matched anywhere because it
|
|
619
|
+
"has no other meaning, so its presence in the title or body IS the
|
|
620
|
+
declaration". That was true when written and is false now: the marker
|
|
621
|
+
acquired a second meaning -- being discussed -- the moment the feature
|
|
622
|
+
became something people file tickets about, and a ticket about the marker
|
|
623
|
+
then declares a hold on itself (CodySwannGT/lisa#3815).
|
|
624
|
+
|
|
625
|
+
A declaration is POSITIONAL: the marker leads its line, behind at most the
|
|
626
|
+
decoration above, with fenced blocks and inline code spans removed first
|
|
627
|
+
because that is how the marker is written ABOUT. The same rule the intake
|
|
628
|
+
matcher applies, so the two surfaces cannot disagree about one body.
|
|
629
|
+
|
|
630
|
+
NOTE the direction here differs from the intake matcher's. There a match
|
|
631
|
+
HOLDS an item, so a false positive parks work. Here a match ADMITS a
|
|
632
|
+
filing, so a false positive is a hole -- which makes this the tightening
|
|
633
|
+
direction on both sides at once.
|
|
634
|
+
|
|
635
|
+
Args:
|
|
636
|
+
text: Any candidate text -- one argv value, a body file, a payload.
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
True when some line of the text declares a hold.
|
|
640
|
+
"""
|
|
641
|
+
if not text:
|
|
642
|
+
return False
|
|
643
|
+
body = HUMAN_GATE_CODE_SPAN.sub("", HUMAN_GATE_FENCE.sub("", str(text)))
|
|
644
|
+
for line in body.split("\n"):
|
|
645
|
+
if HUMAN_GATE_DECORATION.sub("", line, count=1).startswith(
|
|
646
|
+
HUMAN_GATE_MARKER
|
|
647
|
+
):
|
|
648
|
+
return True
|
|
649
|
+
if _comment_carries_marker(line):
|
|
650
|
+
return True
|
|
651
|
+
return False
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _comment_carries_marker(line):
|
|
655
|
+
"""Whether an HTML comment on this line carries the marker.
|
|
656
|
+
|
|
657
|
+
The second accepted form, and the one this guard needs most: its inputs
|
|
658
|
+
are not markdown documents. A `--body` value is a single line, and a shell
|
|
659
|
+
script writes its declaration as `# <!-- ... -->`, so a purely
|
|
660
|
+
line-leading rule would refuse two declarations people have already
|
|
661
|
+
written -- both of which are in this repository's own test corpus.
|
|
662
|
+
|
|
663
|
+
It is not a loosening. An HTML comment is invisible when rendered, so
|
|
664
|
+
nobody wraps one around the marker in order to TALK about it; a quotation
|
|
665
|
+
has to be visible to be a quotation. Measured over this repository's 42
|
|
666
|
+
matching issue bodies, adding this branch changes not one verdict.
|
|
667
|
+
|
|
668
|
+
Args:
|
|
669
|
+
line: One line of text.
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
True when some comment on the line contains the marker.
|
|
673
|
+
"""
|
|
674
|
+
segments = line.split("<!--")
|
|
675
|
+
for segment in segments[1:]:
|
|
676
|
+
close = segment.find("-->")
|
|
677
|
+
inside = segment if close == -1 else segment[:close]
|
|
678
|
+
if HUMAN_GATE_MARKER in inside:
|
|
679
|
+
return True
|
|
680
|
+
return False
|
|
681
|
+
|
|
682
|
+
# ---------------------------------------------------------------------------
|
|
683
|
+
# WHY THIS IS A TOKEN SCAN AND NOT A PROGRAM RESOLVER
|
|
684
|
+
#
|
|
685
|
+
# The first version of this classifier asked "what program is being invoked?"
|
|
686
|
+
# and answered it by stripping a fixed allowlist of wrapper programs. That
|
|
687
|
+
# question is unbounded, so the allowlist could never be complete, and every
|
|
688
|
+
# gap failed OPEN: an unrecognised prefix became the resolved program,
|
|
689
|
+
# classification returned "not a creation", and the guard allowed. A
|
|
690
|
+
# branch-enumerating probe found 36 fail-opens, including `nice -n 10 gh issue
|
|
691
|
+
# create` — and `nice` is POSIX, present on every host, so one prepended token
|
|
692
|
+
# defeated the whole control end to end.
|
|
693
|
+
#
|
|
694
|
+
# Enumerating command SHAPES had scored 21/21 against that same code. The
|
|
695
|
+
# lesson is the method, not the list: probe the parser's decision points
|
|
696
|
+
# (tokenise, split, locate, classify, declare), not the attacks you can think
|
|
697
|
+
# of.
|
|
698
|
+
#
|
|
699
|
+
# So the question is inverted. Instead of "what program is this?" — unbounded —
|
|
700
|
+
# the classifier asks "does this command line invoke a tracker CLI in a
|
|
701
|
+
# creation mode?", which is bounded by a tracker list we already maintain.
|
|
702
|
+
# Every token is examined; anything before the CLI token is irrelevant by
|
|
703
|
+
# construction. `nice`, `stdbuf`, `caffeinate`, `xcrun`, `arch`, `script`,
|
|
704
|
+
# `timeout`, `env -i`, `sudo -u`, `xargs -I{}`, absolute paths, and every
|
|
705
|
+
# wrapper nobody has thought of yet all collapse into one code path.
|
|
706
|
+
#
|
|
707
|
+
# THE DEFAULT ANSWER TO "I DO NOT UNDERSTAND THIS" IS NOW REFUSE.
|
|
708
|
+
# Tokenisation failure, a nesting bound, an unparsed operand: each refuses with
|
|
709
|
+
# an explanation rather than passing. An operator who hits a false refusal says
|
|
710
|
+
# so and the recogniser widens; an agent who hits a false allow says nothing,
|
|
711
|
+
# which is the entire problem.
|
|
712
|
+
# ---------------------------------------------------------------------------
|
|
713
|
+
|
|
714
|
+
# Tracker CLIs. Bounded and reviewable, unlike the wrapper space.
|
|
715
|
+
TRACKER_CLIS = {"gh", "jira", "linear", "acli"}
|
|
716
|
+
HTTP_CLIS = {"curl", "http", "httpie", "wget"}
|
|
717
|
+
|
|
718
|
+
# Text that looks like a tracker creation even when it cannot be tokenised.
|
|
719
|
+
# Used ONLY on the tokenise-failure path, where the choice is between refusing
|
|
720
|
+
# and permitting something we demonstrably cannot read. Deliberately narrower
|
|
721
|
+
# than "mentions a tracker CLI" so an ordinary unparseable command
|
|
722
|
+
# (`echo 'it's fine`) still passes.
|
|
723
|
+
UNPARSEABLE_CREATION = re.compile(
|
|
724
|
+
r"\b(?:gh|jira|linear)\b[^\n]*?\bissue\b[^\n]*?\bcreate\b"
|
|
725
|
+
r"|\bacli\b[^\n]*?\b(?:workitem|issue)s?\b[^\n]*?\bcreate\b"
|
|
726
|
+
r"|\bcreateIssue\b|\bissueCreate\b"
|
|
727
|
+
r"|repos/[^/\s]+/[^/\s]+/issues"
|
|
728
|
+
r"|atlassian\.net/rest/api/[^/\s]+/issue",
|
|
729
|
+
re.IGNORECASE,
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
BODY_FILE_FLAGS = {"--body-file", "-F", "--input", "--data-binary"}
|
|
733
|
+
LABEL_FLAGS = {"--label", "--labels", "--add-label", "--status", "--state"}
|
|
734
|
+
|
|
735
|
+
# ---------------------------------------------------------------------------
|
|
736
|
+
# THE THIRD DECLARATION: THIS ITEM IS A CONTAINER
|
|
737
|
+
#
|
|
738
|
+
# The two declarations above are both illegitimate for a container, and the
|
|
739
|
+
# rules say so in as many words. `leaf-only-lifecycle` FORBIDS the build-ready
|
|
740
|
+
# role on an Epic — its state rolls up from its children, and a hand-applied
|
|
741
|
+
# role on a parent is the exact input that rule's claim-time arm exists to
|
|
742
|
+
# reject. `lisa-github-write-issue` says a container needs no human gate.
|
|
743
|
+
# So a guard with only those two arms could be satisfied for a container only
|
|
744
|
+
# by writing something untrue, and BOTH untruths corrupt data another control
|
|
745
|
+
# reads: a stamped role puts a container in the build lane, and a fabricated
|
|
746
|
+
# hold marker is a state change with no inverse that reads to every later
|
|
747
|
+
# observer as a real product hold. A guard that can only be obeyed by lying is
|
|
748
|
+
# worse than one that simply refuses, because the lie is indistinguishable from
|
|
749
|
+
# a correct filing afterwards.
|
|
750
|
+
#
|
|
751
|
+
# WHY NOT `--label type:Epic`
|
|
752
|
+
#
|
|
753
|
+
# Because a declared type is a CLAIM, and one that costs a leaf nothing: the
|
|
754
|
+
# item it produces still looks buildable, so the exemption would be a free
|
|
755
|
+
# bypass of the readiness requirement for anything willing to mistype itself.
|
|
756
|
+
# That is the shape of this repository's measured case where an allowlist added
|
|
757
|
+
# to harden a guard became the way around it.
|
|
758
|
+
#
|
|
759
|
+
# WHAT IS READ INSTEAD
|
|
760
|
+
#
|
|
761
|
+
# The canonical container declaration the `derived-branch-plan` rule already
|
|
762
|
+
# defines and `lisa-github-write-issue` already stamps, in place of a Target
|
|
763
|
+
# Backend Environment:
|
|
764
|
+
#
|
|
765
|
+
# None — container: state rolls up from children
|
|
766
|
+
#
|
|
767
|
+
# It is a marker with no other meaning, so it is matched wherever the payload
|
|
768
|
+
# reaches — the asymmetry `HUMAN_GATE_MARKER` gets, and for the related
|
|
769
|
+
# reason. Reading the declaration the writer already emits is also what keeps
|
|
770
|
+
# the guard and the skill from drifting apart a second time: there is one
|
|
771
|
+
# string, defined in one rule, and both read it.
|
|
772
|
+
#
|
|
773
|
+
# It is self-limiting in a way a type label is not. Writing it costs the item
|
|
774
|
+
# its Target Backend Environment and its Branch Plan — the two fields a leaf
|
|
775
|
+
# needs to be built at all — so the declaration is only useful to something
|
|
776
|
+
# that actually is a container. A leaf that writes it does not smuggle a
|
|
777
|
+
# buildable item past the gate; it files an unbuildable one, in the lane
|
|
778
|
+
# `leaf-only-lifecycle` and `lisa-repair-intake` already sweep.
|
|
779
|
+
#
|
|
780
|
+
# The WHOLE canonical value, `None` included, not just the distinctive tail.
|
|
781
|
+
# Dropping the leading half would match ordinary prose about how a container's
|
|
782
|
+
# state behaves — including a bug report ABOUT this guard — and a declaration
|
|
783
|
+
# that ordinary prose satisfies is not a declaration. Whitespace-tolerant
|
|
784
|
+
# because a body file may wrap the line; dash-tolerant because an author
|
|
785
|
+
# retyping it by hand will not always reach for the em dash.
|
|
786
|
+
CONTAINER_DECLARATION = re.compile(
|
|
787
|
+
r"None\s*[\u2014\u2013-]\s*container:\s*state\s+rolls\s+up\s+from\s+children",
|
|
788
|
+
re.IGNORECASE,
|
|
789
|
+
)
|
|
790
|
+
# The checkable half of "is it really a container". Provenance is unobservable
|
|
791
|
+
# here, as the header notes, but a CONTRADICTION is not: an item cannot be a
|
|
792
|
+
# container and a by-design leaf at once. Quoted from `leaf-only-lifecycle` —
|
|
793
|
+
# "the by-design leaf types (Bug, Task, Sub-task, Improvement)". Story and
|
|
794
|
+
# Spike are deliberately ABSENT: the same rule's childless-parent exception
|
|
795
|
+
# makes them leaf-or-container depending on child work, and a decomposition
|
|
796
|
+
# legitimately files a parent Story before the children that make it one.
|
|
797
|
+
BY_DESIGN_LEAF_TYPES = {"bug", "task", "sub-task", "subtask", "improvement"}
|
|
798
|
+
# Where a declared type can land on the created item. The label spellings are
|
|
799
|
+
# GitHub's `type:<value>` convention; `--type` and its aliases are the JIRA and
|
|
800
|
+
# Linear clients' spelling. Read with the same positional discipline as the
|
|
801
|
+
# build-ready role — a type named in a title or body is not a type applied.
|
|
802
|
+
TYPE_FLAGS = {
|
|
803
|
+
"--label", "--labels", "--add-label",
|
|
804
|
+
"--type", "--issue-type", "--issuetype",
|
|
805
|
+
}
|
|
806
|
+
POST_METHOD_FLAGS = {"-X", "--request", "--method"}
|
|
807
|
+
POST_PAYLOAD_FLAGS = {
|
|
808
|
+
"-d", "--data", "--data-raw", "--data-binary",
|
|
809
|
+
"-f", "-F", "--raw-field", "--field", "--input",
|
|
810
|
+
}
|
|
811
|
+
# Flags whose VALUE is a payload field. `-f path=repos/o/r/issues` must not be
|
|
812
|
+
# read as an endpoint: it is data being sent, not the address being posted to.
|
|
813
|
+
PAYLOAD_VALUE_FLAGS = {"-f", "-F", "--raw-field", "--field"}
|
|
814
|
+
|
|
815
|
+
# Matched against a token's PATH COMPONENT, never the raw argument — see
|
|
816
|
+
# `endpoint_path`. The `$` anchor is load-bearing and stays: without it
|
|
817
|
+
# `repos/o/r/issues/123/comments` reads as a creation, which is a different
|
|
818
|
+
# operation this guard must not refuse. The segment classes exclude `?` and
|
|
819
|
+
# `#` so the anchor cannot be reached past a decoration even if some future
|
|
820
|
+
# caller forgets to parse first.
|
|
821
|
+
GITHUB_ISSUES_PATH = re.compile(r"repos/[^/\s?#]+/[^/\s?#]+/issues/?$")
|
|
822
|
+
GITHUB_ISSUES_URL = re.compile(r"api\.github\.com/repos/[^/\s]+/[^/\s]+/issues")
|
|
823
|
+
# The repository a creation is ADDRESSED at, which decides whose ready role
|
|
824
|
+
# answers for it. `gh` accepts the flag before or after the subcommand and in
|
|
825
|
+
# either spelling, and the REST paths carry the same pair positionally.
|
|
826
|
+
REPO_FLAGS = {"--repo", "-R"}
|
|
827
|
+
GITHUB_ISSUES_PATH_REPO = re.compile(
|
|
828
|
+
r"repos/([^/\s?#]+)/([^/\s?#]+)/issues/?$"
|
|
829
|
+
)
|
|
830
|
+
GITHUB_ISSUES_URL_REPO = re.compile(
|
|
831
|
+
r"api\.github\.com/repos/([^/\s]+)/([^/\s]+)/issues"
|
|
832
|
+
)
|
|
833
|
+
JIRA_ISSUE_URL = re.compile(r"atlassian\.net/rest/api/[^/\s]+/issue")
|
|
834
|
+
GRAPHQL_CREATE = re.compile(r"createIssue|issueCreate")
|
|
835
|
+
|
|
836
|
+
# ---------------------------------------------------------------------------
|
|
837
|
+
# REACH: THE COMMAND IS NOT THE ONLY TEXT THAT RUNS
|
|
838
|
+
#
|
|
839
|
+
# The classifier above asks the right question of the wrong text. It examines
|
|
840
|
+
# argv, and argv is not where a creation has to live:
|
|
841
|
+
#
|
|
842
|
+
# bash /tmp/create.sh # URL and `issueCreate` both inside the script
|
|
843
|
+
# node wrapper.mjs --state x # same, in JavaScript
|
|
844
|
+
# curl … --data-binary @p.json # URL in argv, mutation in the file
|
|
845
|
+
#
|
|
846
|
+
# All three ALLOWED, verified by driving the guard with synthetic payloads
|
|
847
|
+
# (CodySwannGT/lisa#3484). The detection is a CONJUNCTION — an endpoint token
|
|
848
|
+
# AND a creation verb in the SAME inspected command — so moving either half one
|
|
849
|
+
# file away means the conjunction never forms. `bash <path>` shows the
|
|
850
|
+
# classifier two tokens, neither of which is a tracker CLI.
|
|
851
|
+
#
|
|
852
|
+
# It is not an oversight that an operator had to be clever to find. Lisa's OWN
|
|
853
|
+
# guards instruct agents into this shape: `parity-safety-net.sh` refuses
|
|
854
|
+
# heredocs and says "write the payload to a file with the Write tool, then
|
|
855
|
+
# execute that file directly", and `block-shell-json-parsing.sh` pushes JSON
|
|
856
|
+
# construction into `jq` scripts. An agent complying perfectly with Lisa's
|
|
857
|
+
# guidance lands in the uninspected path BY DEFAULT. The guards were
|
|
858
|
+
# individually reasonable and jointly self-defeating.
|
|
859
|
+
#
|
|
860
|
+
# The fix follows this file's own inverted method. Enumerating interpreters —
|
|
861
|
+
# bash, sh, zsh, ksh, dash, python3, node, bun, deno, ruby, perl, `./script`,
|
|
862
|
+
# `source`, `env bash` — is the unbounded question all over again, and every
|
|
863
|
+
# gap in it fails OPEN. So the question is inverted the same way the wrapper
|
|
864
|
+
# question was: instead of "which programs execute their operands?", the
|
|
865
|
+
# classifier asks "does this command name a readable file whose CONTENTS read
|
|
866
|
+
# as an undeclared tracker creation?" — bounded by the tokens actually present.
|
|
867
|
+
#
|
|
868
|
+
# THE COST IS STATED, NOT HIDDEN. `cat create.sh` and `git add create.sh` are
|
|
869
|
+
# refused too, because proving that a program does NOT execute its operand
|
|
870
|
+
# requires exactly the allowlist this file already refuses to keep. That is the
|
|
871
|
+
# documented direction of failure: an operator who hits a false refusal says
|
|
872
|
+
# so, an agent who hits a false allow says nothing.
|
|
873
|
+
FILE_OPERAND_MAX_BYTES = 262144
|
|
874
|
+
FILE_OPERANDS_PER_SEGMENT = 8
|
|
875
|
+
# Payload sources. A creation's mutation text may arrive inline, from a file,
|
|
876
|
+
# or over stdin from an earlier stage of the same pipeline.
|
|
877
|
+
PAYLOAD_SOURCE_FLAGS = {
|
|
878
|
+
"-d", "--data", "--data-raw", "--data-binary", "--data-ascii",
|
|
879
|
+
"--data-urlencode", "--input", "-F", "--form", "--upload-file", "-T",
|
|
880
|
+
}
|
|
881
|
+
STDIN_PAYLOAD_TOKENS = {"-", "@-"}
|
|
882
|
+
# A tracker endpoint appearing anywhere in a file's text. Paired with
|
|
883
|
+
# GRAPHQL_CREATE as a CONJUNCTION, so an ordinary file that merely mentions
|
|
884
|
+
# `issueCreate` in prose — a changelog, a test name, a code comment — does not
|
|
885
|
+
# read as a creation on its own.
|
|
886
|
+
TRACKER_ENDPOINT = re.compile(
|
|
887
|
+
r"api\.linear\.app/graphql"
|
|
888
|
+
r"|api\.github\.com"
|
|
889
|
+
r"|atlassian\.net/rest/api"
|
|
890
|
+
r"|repos/[^/\s?#'\"]+/[^/\s?#'\"]+/issues",
|
|
891
|
+
re.IGNORECASE,
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
# ---------------------------------------------------------------------------
|
|
895
|
+
# A PAYLOAD HELD AS DATA IS NOT A SUBMISSION
|
|
896
|
+
#
|
|
897
|
+
# The conjunction above — an endpoint AND a creation verb in one file — cannot
|
|
898
|
+
# tell a payload being WRITTEN AS TEST DATA from one about to be SUBMITTED.
|
|
899
|
+
# Measured (CodySwannGT/lisa#3943): a helper that assembles test fixtures, and
|
|
900
|
+
# holds a mutation body as a string constant it writes to a fixture file, was
|
|
901
|
+
# refused as "a tracker creation inside <path>". It submits nothing. The same
|
|
902
|
+
# helper rewritten to SLICE the payload out of an existing file was allowed —
|
|
903
|
+
# so the discriminator was the literal, not the behaviour.
|
|
904
|
+
#
|
|
905
|
+
# The behaviour that separates them is EGRESS. A file can only file an issue if
|
|
906
|
+
# it can hand its bytes to another host or another process. A file with no such
|
|
907
|
+
# primitive anywhere in it cannot submit whatever its constants spell, so the
|
|
908
|
+
# conjunction is reading data.
|
|
909
|
+
#
|
|
910
|
+
# BOTH HALVES ARE REQUIRED, and the second one is why this is not a hole. The
|
|
911
|
+
# exemption needs POSITIVE evidence that the file produces a local artifact
|
|
912
|
+
# (`LOCAL_WRITE`) as well as the ABSENCE of egress, so it is bounded to the
|
|
913
|
+
# measured population — a fixture writer — rather than granted to any file that
|
|
914
|
+
# happens to use a transport nobody enumerated. Egress is over-matched on
|
|
915
|
+
# purpose: every miss in `PAYLOAD_EGRESS` widens the exemption, so the list
|
|
916
|
+
# reaches for `.post(`-shaped calls and process spawning as well as named HTTP
|
|
917
|
+
# clients, and a file doing both is refused. That is the guard's usual
|
|
918
|
+
# direction of failure — a false refusal is reported, a false allow is silent.
|
|
919
|
+
#
|
|
920
|
+
# RESIDUAL, stated rather than hidden: a submitting file that ALSO writes a
|
|
921
|
+
# local artifact and reaches the network through a primitive no pattern here
|
|
922
|
+
# names is allowed. Nothing about `text_declares_readiness`, the nested shell
|
|
923
|
+
# scan, the unparseable arm, or the argv path is weakened — this reads only the
|
|
924
|
+
# coarse conjunction, which is the only arm that ever inferred a submission
|
|
925
|
+
# from contents alone.
|
|
926
|
+
PAYLOAD_EGRESS = re.compile(
|
|
927
|
+
# Clients and trackers, by name.
|
|
928
|
+
r"\bcurl\b|\bwget\b|\bhttpie\b|\bgh\b|\bjira\b|\bacli\b"
|
|
929
|
+
r"|requests\.|httpx|aiohttp|pycurl|urllib|urlopen|http\.client|\bsocket\b"
|
|
930
|
+
r"|https?connection|httpurlconnection|httpclient|httprequest|webclient"
|
|
931
|
+
r"|okhttp|net::http|restclient|faraday|httparty|open-uri|\blwp\b|http::tiny"
|
|
932
|
+
r"|curl_init|curl_exec|guzzle|fsockopen|file_get_contents"
|
|
933
|
+
r"|\bfetch\s*\(|axios|xmlhttprequest|node-fetch|undici|superagent"
|
|
934
|
+
r"|http\.post|http\.newrequest"
|
|
935
|
+
# Handing the payload to another process, which can carry it anywhere.
|
|
936
|
+
r"|subprocess|os\.system|popen|child_process|execsync|spawnsync"
|
|
937
|
+
r"|\bspawn\s*\(|\bexec\s*\(|\bsystem\s*\(|bun\.spawn|deno\.command"
|
|
938
|
+
# The shape a send takes in almost any language, whatever the client is
|
|
939
|
+
# called. Over-matching here costs an exemption, never a refusal.
|
|
940
|
+
r"|\.post\s*\(|\.put\s*\(|\.patch\s*\(|\.request\s*\(|\.send\s*\("
|
|
941
|
+
r"|\.execute\s*\(|\.mutate\s*\(|\.do\s*\(",
|
|
942
|
+
re.IGNORECASE,
|
|
943
|
+
)
|
|
944
|
+
# Producing a local artifact: the positive half of the exemption.
|
|
945
|
+
LOCAL_WRITE = re.compile(
|
|
946
|
+
r"\.write\s*\(|\.writelines\s*\(|write_text\s*\(|write_bytes\s*\("
|
|
947
|
+
r"|writefilesync|writefile\s*\(|createwritestream|outputstream"
|
|
948
|
+
r"|ioutil\.writefile|\btee\b"
|
|
949
|
+
# A shell redirect. Anchored on a separator so `=>` and `->` are not read
|
|
950
|
+
# as one, and required to be followed by something path-shaped.
|
|
951
|
+
r"|(?:^|[\s;&|(])>>?\s*[\"']?[\w./$~-]",
|
|
952
|
+
re.IGNORECASE | re.MULTILINE,
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def payload_is_inert(text):
|
|
957
|
+
"""Whether this file HOLDS a creation payload rather than submitting one.
|
|
958
|
+
|
|
959
|
+
Args:
|
|
960
|
+
text: The file's contents.
|
|
961
|
+
|
|
962
|
+
Returns:
|
|
963
|
+
True when the file writes a local artifact and carries no primitive
|
|
964
|
+
capable of transmitting anything.
|
|
965
|
+
"""
|
|
966
|
+
return (
|
|
967
|
+
LOCAL_WRITE.search(text) is not None
|
|
968
|
+
and PAYLOAD_EGRESS.search(text) is None
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
# ---------------------------------------------------------------------------
|
|
973
|
+
# DECLARING BUILD-READY WHEN THE ROLE IS A STATE AND NOT A LABEL
|
|
974
|
+
#
|
|
975
|
+
# `declares_readiness` accepted the build-ready role only as the value of a
|
|
976
|
+
# LABEL_FLAGS flag in argv. That is well-formed for a LABEL-based tracker and
|
|
977
|
+
# structurally impossible for a STATE-based one:
|
|
978
|
+
#
|
|
979
|
+
# - GitHub's ready role is a label, labels are argv-native (`--label`), so
|
|
980
|
+
# `flag_values` finds it and an honest command exists.
|
|
981
|
+
# - JIRA's and Linear's ready roles are workflow STATES. The mandated access
|
|
982
|
+
# path is raw `curl` to a GraphQL/REST endpoint, `curl` has no `--state`
|
|
983
|
+
# flag, and the state lives in the request payload as an ID the guard
|
|
984
|
+
# cannot resolve without a network round-trip it refuses to make.
|
|
985
|
+
#
|
|
986
|
+
# So on a Linear-tracked project the only declaration left that passed was
|
|
987
|
+
# `[lisa-human-gate]`, which this file's own comments correctly forbid for a
|
|
988
|
+
# build-ready item: it stamps the item as held for a human product call, and
|
|
989
|
+
# build-intake scans the ready role and nothing else. An honest operator had NO
|
|
990
|
+
# compliant command and exactly one dishonest one — a guard failing in the
|
|
991
|
+
# harmful direction, where complying is worse than not.
|
|
992
|
+
#
|
|
993
|
+
# The declaration accepted here for state-based trackers is the LIFECYCLE ROLE
|
|
994
|
+
# the access layer already consumes. `lisa-linear-access` refuses to accept a
|
|
995
|
+
# caller-supplied `stateId`; it takes `lifecycle_role:<ROLE>`, resolves it
|
|
996
|
+
# against config and the team's own state catalog through
|
|
997
|
+
# `linear-state-write-target.mjs`, and fails CLOSED when it cannot. So the role
|
|
998
|
+
# token is not decoration: it is the input that decides which lane the item
|
|
999
|
+
# lands in, and a command carrying it either places the item in the ready lane
|
|
1000
|
+
# or refuses. That is the same epistemic standing as `--label status:ready`,
|
|
1001
|
+
# whose effect also happens one layer down inside `gh`.
|
|
1002
|
+
#
|
|
1003
|
+
# Scoped deliberately: accepted ONLY when the configured tracker's ready role
|
|
1004
|
+
# is a state, and ONLY for a filing addressed at this project's own tracker. A
|
|
1005
|
+
# GitHub filing — same-repo or cross-repo — still has to carry the label,
|
|
1006
|
+
# because for GitHub the label IS expressible and a second, weaker spelling
|
|
1007
|
+
# would be a hole rather than a remedy.
|
|
1008
|
+
STATE_ROLE_TRACKERS = {"jira", "linear"}
|
|
1009
|
+
#
|
|
1010
|
+
# The optional quote AFTER the key name is load-bearing, not decoration. Both
|
|
1011
|
+
# refusal messages tell the operator to put the declaration in the request
|
|
1012
|
+
# payload, and a JSON payload quotes its keys — so a pattern demanding `[:=]`
|
|
1013
|
+
# immediately after a bare key name refuses `"lifecycle_role": "ready"`, which
|
|
1014
|
+
# is the exact spelling it just asked for. Caught by review before it shipped.
|
|
1015
|
+
LIFECYCLE_ROLE_READY = re.compile(
|
|
1016
|
+
r"(?:^|[^\w.-])(?:lifecycle_role|LIFECYCLE_ROLE)[\"']?\s*[:=]\s*[\"']?ready\b"
|
|
1017
|
+
r"|(?:^|[^\w.-])--role[=\s]+[\"']?ready\b",
|
|
1018
|
+
)
|
|
1019
|
+
# Built per role at the point of use, because the role is project data. Kept
|
|
1020
|
+
# as a template rather than a compiled pattern so the role is always escaped.
|
|
1021
|
+
LABEL_FLAG_TEXT = r"--(?:label|labels|add-label|status|state)[=\s]+[\"']?%s(?![\w:.-])"
|
|
1022
|
+
|
|
1023
|
+
MAX_NESTING_DEPTH = 3
|
|
1024
|
+
# Operators that can be GLUED to an adjacent word (`true&&gh issue create`),
|
|
1025
|
+
# so they must be split out of a token. Braces and parentheses are deliberately
|
|
1026
|
+
# absent: shlex collapses a quoted argument into one token, so a GraphQL
|
|
1027
|
+
# payload arrives as `query=mutation{issueCreate(input:{})…}` and splitting on
|
|
1028
|
+
# braces tore it into fragments — silently un-refusing every GraphQL creation.
|
|
1029
|
+
# Standalone grouping punctuation is handled at segment and basename level
|
|
1030
|
+
# instead, where it cannot reach into a payload's contents.
|
|
1031
|
+
GLUED_OPERATORS = ("&&", "||", ";;", ";", "|", "&")
|
|
1032
|
+
SEGMENT_BOUNDARIES = set(GLUED_OPERATORS) | {"(", ")", "{", "}"}
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def strip_full_line_comments(text):
|
|
1036
|
+
"""Blank lines beginning with `#` after optional whitespace.
|
|
1037
|
+
|
|
1038
|
+
This bounded fallback prevents ordinary prose comments from matching a
|
|
1039
|
+
creation token after an apostrophe defeats shlex (CodySwannGT/lisa#3551).
|
|
1040
|
+
It does not determine quote context: a leading `#` inside a multiline
|
|
1041
|
+
string is also removed. This is a textual guard, not shell interpretation.
|
|
1042
|
+
|
|
1043
|
+
Args:
|
|
1044
|
+
text: The raw command or file text.
|
|
1045
|
+
|
|
1046
|
+
Returns:
|
|
1047
|
+
The text with whole-line comments blanked, line count preserved.
|
|
1048
|
+
"""
|
|
1049
|
+
return "\n".join(
|
|
1050
|
+
"" if line.lstrip().startswith("#") else line for line in text.split("\n")
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def strip_heredocs(text):
|
|
1055
|
+
"""Drop heredoc bodies so quoted prose cannot be read as argv.
|
|
1056
|
+
|
|
1057
|
+
Args:
|
|
1058
|
+
text: The raw command string.
|
|
1059
|
+
|
|
1060
|
+
Returns:
|
|
1061
|
+
The command with heredoc bodies removed.
|
|
1062
|
+
"""
|
|
1063
|
+
lines = text.splitlines()
|
|
1064
|
+
output = []
|
|
1065
|
+
pending = []
|
|
1066
|
+
marker_pattern = re.compile(
|
|
1067
|
+
r"<<-?\s*(?:'([^']+)'|\"([^\"]+)\"|([A-Za-z_][A-Za-z0-9_]*))"
|
|
1068
|
+
)
|
|
1069
|
+
index = 0
|
|
1070
|
+
while index < len(lines):
|
|
1071
|
+
line = lines[index]
|
|
1072
|
+
output.append(line)
|
|
1073
|
+
pending.extend(
|
|
1074
|
+
next(group for group in match.groups() if group)
|
|
1075
|
+
for match in marker_pattern.finditer(line)
|
|
1076
|
+
)
|
|
1077
|
+
index += 1
|
|
1078
|
+
while pending and index < len(lines):
|
|
1079
|
+
if lines[index].strip() == pending[0]:
|
|
1080
|
+
output.append(lines[index])
|
|
1081
|
+
pending.pop(0)
|
|
1082
|
+
index += 1
|
|
1083
|
+
break
|
|
1084
|
+
index += 1
|
|
1085
|
+
return "\n".join(output)
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
class LiteralToken(str):
|
|
1089
|
+
"""A source word containing quoted data, never a standalone separator."""
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def explode_operators(tokens, text=""):
|
|
1093
|
+
"""Separate unquoted operators without losing mixed-word quote boundaries.
|
|
1094
|
+
|
|
1095
|
+
Hide each quoted or escaped source fragment before the ordinary shlex pass.
|
|
1096
|
+
Split operators while those fragments are opaque, then restore their values.
|
|
1097
|
+
Per-occurrence placeholders cannot exempt a different, unquoted separator.
|
|
1098
|
+
"""
|
|
1099
|
+
operators = re.compile("(" + "|".join(re.escape(op) for op in GLUED_OPERATORS) + ")")
|
|
1100
|
+
if not text:
|
|
1101
|
+
return [piece for token in tokens for piece in operators.split(token) if piece]
|
|
1102
|
+
|
|
1103
|
+
prefix = "__lisa_literal_"
|
|
1104
|
+
while prefix in text:
|
|
1105
|
+
prefix += "_"
|
|
1106
|
+
values = []
|
|
1107
|
+
fragments = re.compile(r"""'[^']*'|"(?:\\[\s\S]|[^"\\])*"|\\[\s\S]""")
|
|
1108
|
+
|
|
1109
|
+
def hide(match):
|
|
1110
|
+
"""Decode one protected fragment through the same POSIX lexer."""
|
|
1111
|
+
value = shlex.split(match.group(0), posix=True)
|
|
1112
|
+
values.append(value[0] if value else "")
|
|
1113
|
+
return prefix + str(len(values) - 1) + "__"
|
|
1114
|
+
|
|
1115
|
+
masked = fragments.sub(hide, text)
|
|
1116
|
+
marker = re.compile(re.escape(prefix) + r"(\d+)__")
|
|
1117
|
+
|
|
1118
|
+
def restore(value):
|
|
1119
|
+
"""Restore once, so decoded text cannot become another placeholder."""
|
|
1120
|
+
return marker.sub(lambda match: values[int(match.group(1))], value)
|
|
1121
|
+
|
|
1122
|
+
shadow = shlex.split(masked, posix=True)
|
|
1123
|
+
if [restore(token) for token in shadow] != tokens:
|
|
1124
|
+
# If alignment cannot be established, grant no quoting exemptions.
|
|
1125
|
+
return [piece for token in tokens for piece in operators.split(token) if piece]
|
|
1126
|
+
|
|
1127
|
+
exploded = []
|
|
1128
|
+
for token in shadow:
|
|
1129
|
+
for piece in operators.split(token):
|
|
1130
|
+
if piece:
|
|
1131
|
+
exploded.append(LiteralToken(restore(piece)) if marker.search(piece) else piece)
|
|
1132
|
+
return exploded
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def segment(tokens):
|
|
1136
|
+
"""Split a token stream into individual commands at shell operators.
|
|
1137
|
+
|
|
1138
|
+
Args:
|
|
1139
|
+
tokens: Exploded tokens.
|
|
1140
|
+
|
|
1141
|
+
Returns:
|
|
1142
|
+
A list of argv lists.
|
|
1143
|
+
"""
|
|
1144
|
+
segments = []
|
|
1145
|
+
current = []
|
|
1146
|
+
for token in tokens:
|
|
1147
|
+
if token in SEGMENT_BOUNDARIES and not isinstance(token, LiteralToken):
|
|
1148
|
+
segments.append(current)
|
|
1149
|
+
current = []
|
|
1150
|
+
continue
|
|
1151
|
+
current.append(token)
|
|
1152
|
+
segments.append(current)
|
|
1153
|
+
return [item for item in segments if item]
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def basename(token):
|
|
1157
|
+
"""The final path component of a token, quotes stripped.
|
|
1158
|
+
|
|
1159
|
+
Args:
|
|
1160
|
+
token: A shell token.
|
|
1161
|
+
|
|
1162
|
+
Returns:
|
|
1163
|
+
The basename.
|
|
1164
|
+
"""
|
|
1165
|
+
return token.strip("'\"").strip("(){}").rsplit("/", 1)[-1]
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def is_flag_value(args, index):
|
|
1169
|
+
"""Whether the token at `index` is the value of the preceding flag.
|
|
1170
|
+
|
|
1171
|
+
This is the single position question the classifier keeps having to ask,
|
|
1172
|
+
and getting it wrong is what produced three separate bypasses: the role
|
|
1173
|
+
read from a `--title`, the role read past `--`, and a subcommand read as a
|
|
1174
|
+
flag's value. It is answered in exactly one place now.
|
|
1175
|
+
|
|
1176
|
+
Args:
|
|
1177
|
+
args: A command's arguments.
|
|
1178
|
+
index: The position to test.
|
|
1179
|
+
|
|
1180
|
+
Returns:
|
|
1181
|
+
True when the previous token is a flag that carries no `=`.
|
|
1182
|
+
"""
|
|
1183
|
+
if index == 0:
|
|
1184
|
+
return False
|
|
1185
|
+
previous = args[index - 1]
|
|
1186
|
+
return previous.startswith("-") and previous != "-" and "=" not in previous
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def bare_index(args, word, start=0):
|
|
1190
|
+
"""Index of `word` appearing as itself rather than as a flag's value.
|
|
1191
|
+
|
|
1192
|
+
Args:
|
|
1193
|
+
args: A command's arguments.
|
|
1194
|
+
word: The word to locate.
|
|
1195
|
+
start: Index to search from.
|
|
1196
|
+
|
|
1197
|
+
Returns:
|
|
1198
|
+
The index, or -1.
|
|
1199
|
+
"""
|
|
1200
|
+
for index in range(start, len(args)):
|
|
1201
|
+
if args[index] == word and not is_flag_value(args, index):
|
|
1202
|
+
return index
|
|
1203
|
+
return -1
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def invokes_verb(args, groups, verb):
|
|
1207
|
+
"""Whether a group word is followed later by a bare verb.
|
|
1208
|
+
|
|
1209
|
+
Deliberately tolerant of anything between them, because a flag may sit
|
|
1210
|
+
between the group and the verb — `gh issue --repo o/r create` is accepted
|
|
1211
|
+
by cobra, which strips persistent flags before resolving the subcommand.
|
|
1212
|
+
Tolerance is safe here only because the verb itself must be bare: that is
|
|
1213
|
+
what keeps `gh issue list --search create` from reading as a creation.
|
|
1214
|
+
|
|
1215
|
+
Args:
|
|
1216
|
+
args: A command's arguments.
|
|
1217
|
+
groups: Acceptable group words, e.g. {"issue", "workitem"}.
|
|
1218
|
+
verb: The verb, e.g. "create".
|
|
1219
|
+
|
|
1220
|
+
Returns:
|
|
1221
|
+
True when the invocation names the verb.
|
|
1222
|
+
"""
|
|
1223
|
+
# The bare-token filter is applied to the VERB only, never to the group
|
|
1224
|
+
# word, and the asymmetry is the point. `--verbose` is boolean, so treating
|
|
1225
|
+
# the token after any flag as that flag's value swallowed `api` in
|
|
1226
|
+
# `gh --verbose api …` and `issue` in `gh --verbose issue create`. Being
|
|
1227
|
+
# permissive about the group costs nothing, because the verb still has to
|
|
1228
|
+
# match; being permissive about the VERB is what would read
|
|
1229
|
+
# `gh issue list --search create` as a creation. Over-include where the
|
|
1230
|
+
# consequence is another check, filter where the consequence is a refusal.
|
|
1231
|
+
for group in groups:
|
|
1232
|
+
if group not in args:
|
|
1233
|
+
continue
|
|
1234
|
+
group_at = args.index(group)
|
|
1235
|
+
if bare_index(args, verb, group_at + 1) >= 0:
|
|
1236
|
+
return True
|
|
1237
|
+
return False
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def is_write_request(args):
|
|
1241
|
+
"""Whether the arguments describe an HTTP write rather than a read.
|
|
1242
|
+
|
|
1243
|
+
Args:
|
|
1244
|
+
args: A command's arguments.
|
|
1245
|
+
|
|
1246
|
+
Returns:
|
|
1247
|
+
True if a POST method or a payload-bearing flag is present.
|
|
1248
|
+
"""
|
|
1249
|
+
for index, token in enumerate(args):
|
|
1250
|
+
if token in POST_METHOD_FLAGS:
|
|
1251
|
+
if index + 1 < len(args) and args[index + 1].upper() == "POST":
|
|
1252
|
+
return True
|
|
1253
|
+
if "=" in token:
|
|
1254
|
+
head, value = token.split("=", 1)
|
|
1255
|
+
if head in POST_METHOD_FLAGS and value.upper() == "POST":
|
|
1256
|
+
return True
|
|
1257
|
+
if token.upper() == "-XPOST":
|
|
1258
|
+
return True
|
|
1259
|
+
if token in POST_PAYLOAD_FLAGS:
|
|
1260
|
+
return True
|
|
1261
|
+
return False
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def endpoint_path(token):
|
|
1265
|
+
"""The path component of an endpoint-shaped argument.
|
|
1266
|
+
|
|
1267
|
+
An endpoint is a URL, and a URL is not its path: `?query` and `#fragment`
|
|
1268
|
+
are separate components that address the SAME resource. Comparing the raw
|
|
1269
|
+
argument against a path pattern therefore recognised a URL SHAPE rather
|
|
1270
|
+
than an endpoint, and `repos/o/r/issues?foo=1` — the identical request —
|
|
1271
|
+
was classified as a non-creation and allowed (#2939).
|
|
1272
|
+
|
|
1273
|
+
Trailing whitespace is stripped for the same reason: `"repos/o/r/issues "`
|
|
1274
|
+
survives shlex as one token and addresses the same endpoint, but defeats an
|
|
1275
|
+
end-anchored comparison just as a query string does.
|
|
1276
|
+
|
|
1277
|
+
Args:
|
|
1278
|
+
token: One argument, possibly a decorated endpoint.
|
|
1279
|
+
|
|
1280
|
+
Returns:
|
|
1281
|
+
The token with any fragment, query, and surrounding whitespace removed.
|
|
1282
|
+
"""
|
|
1283
|
+
# Fragment first: it is the LAST component of a URL, so `path?q#f` yields
|
|
1284
|
+
# `path?q` here and `path` after the query split, while a malformed
|
|
1285
|
+
# `path#a?b` still collapses to `path` rather than keeping `a?b`.
|
|
1286
|
+
return token.split("#", 1)[0].split("?", 1)[0].strip()
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def endpoint_paths(args, pattern):
|
|
1290
|
+
"""Endpoint path components matching a pattern, excluding payload values.
|
|
1291
|
+
|
|
1292
|
+
Scans every token rather than a filtered positional list, because a BOOLEAN
|
|
1293
|
+
flag has no value to skip and filtering swallowed the endpoint behind one:
|
|
1294
|
+
`gh api -X POST --silent repos/o/r/issues -f title=x` hid the endpoint
|
|
1295
|
+
behind `--silent`. Payload values are excluded the other way, so
|
|
1296
|
+
`-f path=repos/o/r/issues` is not mistaken for the address being posted to.
|
|
1297
|
+
|
|
1298
|
+
That payload exclusion is tested on the PATH, not the raw token, and the
|
|
1299
|
+
order matters: a query string carries `=`, so testing the raw token skipped
|
|
1300
|
+
`repos/o/r/issues?foo=1` before the pattern ever ran. Two independent
|
|
1301
|
+
mechanisms — this filter and the pattern's end anchor — hid the same
|
|
1302
|
+
creation, which is why fixing only the anchor would not have closed it.
|
|
1303
|
+
|
|
1304
|
+
Args:
|
|
1305
|
+
args: A command's arguments.
|
|
1306
|
+
pattern: The endpoint regex, written against a path component.
|
|
1307
|
+
|
|
1308
|
+
Returns:
|
|
1309
|
+
The matching path components, decoration removed.
|
|
1310
|
+
"""
|
|
1311
|
+
found = []
|
|
1312
|
+
for index, token in enumerate(args):
|
|
1313
|
+
path = endpoint_path(token)
|
|
1314
|
+
if "=" in path:
|
|
1315
|
+
continue
|
|
1316
|
+
if index > 0 and args[index - 1] in PAYLOAD_VALUE_FLAGS:
|
|
1317
|
+
continue
|
|
1318
|
+
if pattern.search(path):
|
|
1319
|
+
found.append(path)
|
|
1320
|
+
return found
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def resolve_operand(token):
|
|
1324
|
+
"""A readable regular file named by one token, or None.
|
|
1325
|
+
|
|
1326
|
+
Bounded on purpose. The size cap keeps a PreToolUse hook off a multi-
|
|
1327
|
+
megabyte read on every intercepted command, and a file too large to
|
|
1328
|
+
inspect is skipped rather than half-read: a truncated scan reports a
|
|
1329
|
+
confident ALLOW about text it never saw.
|
|
1330
|
+
|
|
1331
|
+
Args:
|
|
1332
|
+
token: One argument, possibly quoted or `@`-prefixed for curl.
|
|
1333
|
+
|
|
1334
|
+
Returns:
|
|
1335
|
+
An existing path, or None.
|
|
1336
|
+
"""
|
|
1337
|
+
text = token.strip().strip("'\"")
|
|
1338
|
+
# `-d@payload.json` names a file just as plainly as `@payload.json` does.
|
|
1339
|
+
attached = attached_value(text)
|
|
1340
|
+
if attached is not None:
|
|
1341
|
+
text = attached
|
|
1342
|
+
if text.startswith("@"):
|
|
1343
|
+
text = text[1:]
|
|
1344
|
+
if not text or text in STDIN_PAYLOAD_TOKENS:
|
|
1345
|
+
return None
|
|
1346
|
+
candidates = [text]
|
|
1347
|
+
if project_dir and not os.path.isabs(text):
|
|
1348
|
+
candidates.append(os.path.join(project_dir, text))
|
|
1349
|
+
for candidate in candidates:
|
|
1350
|
+
try:
|
|
1351
|
+
if not os.path.isfile(candidate):
|
|
1352
|
+
continue
|
|
1353
|
+
if os.path.getsize(candidate) > FILE_OPERAND_MAX_BYTES:
|
|
1354
|
+
continue
|
|
1355
|
+
except OSError:
|
|
1356
|
+
continue
|
|
1357
|
+
return candidate
|
|
1358
|
+
return None
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def read_operand(path):
|
|
1362
|
+
"""The text of a file the command names.
|
|
1363
|
+
|
|
1364
|
+
Args:
|
|
1365
|
+
path: A path from `resolve_operand`.
|
|
1366
|
+
|
|
1367
|
+
Returns:
|
|
1368
|
+
The contents, or an empty string when unreadable.
|
|
1369
|
+
"""
|
|
1370
|
+
try:
|
|
1371
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
1372
|
+
return handle.read(FILE_OPERAND_MAX_BYTES)
|
|
1373
|
+
except OSError:
|
|
1374
|
+
return ""
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def attached_value(token):
|
|
1378
|
+
"""A payload flag's value when it is GLUED to the flag.
|
|
1379
|
+
|
|
1380
|
+
`curl -d@payload.json` and `curl -d'{"query":…}'` are one token each, so a
|
|
1381
|
+
parser that only looks at `args[i + 1]` and at `flag=value` sees neither —
|
|
1382
|
+
and `-d@file` is the ordinary spelling, not an exotic one. Caught by review
|
|
1383
|
+
before it shipped, and it was a real bypass: the mutation stayed in the
|
|
1384
|
+
file, the conjunction never formed, and an undeclared creation passed.
|
|
1385
|
+
|
|
1386
|
+
Longest flag first, so `--data-binary@f` is not read as `--data` with a
|
|
1387
|
+
`-binary@f` value. A remainder starting with `-` is rejected for the same
|
|
1388
|
+
reason: it is another option, not this one's value.
|
|
1389
|
+
|
|
1390
|
+
Args:
|
|
1391
|
+
token: One argument.
|
|
1392
|
+
|
|
1393
|
+
Returns:
|
|
1394
|
+
The attached value, or None.
|
|
1395
|
+
"""
|
|
1396
|
+
for flag in sorted(PAYLOAD_SOURCE_FLAGS, key=len, reverse=True):
|
|
1397
|
+
if not token.startswith(flag) or len(token) == len(flag):
|
|
1398
|
+
continue
|
|
1399
|
+
rest = token[len(flag) :]
|
|
1400
|
+
if rest.startswith("="):
|
|
1401
|
+
rest = rest[1:]
|
|
1402
|
+
if not rest or rest.startswith("-"):
|
|
1403
|
+
continue
|
|
1404
|
+
return rest
|
|
1405
|
+
return None
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def payload_text(args, whole_command):
|
|
1409
|
+
"""The body this command will submit, wherever it is coming from.
|
|
1410
|
+
|
|
1411
|
+
Three sources, because a request body has three places to live and the
|
|
1412
|
+
guard was reading only the first: inline after `-d`, in a file after
|
|
1413
|
+
`--data-binary @path` / `--input path`, or on stdin from an earlier stage
|
|
1414
|
+
of the same pipeline.
|
|
1415
|
+
|
|
1416
|
+
The stdin case takes the WHOLE command text rather than the segment,
|
|
1417
|
+
because `jq -n … | curl … --data-binary @-` is one logical command that
|
|
1418
|
+
`segment` has already split in two — the payload literally is the other
|
|
1419
|
+
half. That is the shape `lisa-linear-access` mandates, so it has to be
|
|
1420
|
+
readable rather than invisible.
|
|
1421
|
+
|
|
1422
|
+
Args:
|
|
1423
|
+
args: A command's arguments.
|
|
1424
|
+
whole_command: The full intercepted command string.
|
|
1425
|
+
|
|
1426
|
+
Returns:
|
|
1427
|
+
The payload text, possibly empty.
|
|
1428
|
+
"""
|
|
1429
|
+
parts = []
|
|
1430
|
+
for index, token in enumerate(args):
|
|
1431
|
+
value = None
|
|
1432
|
+
if token in PAYLOAD_SOURCE_FLAGS and index + 1 < len(args):
|
|
1433
|
+
value = args[index + 1]
|
|
1434
|
+
elif "=" in token:
|
|
1435
|
+
head, rhs = token.split("=", 1)
|
|
1436
|
+
if head in PAYLOAD_SOURCE_FLAGS:
|
|
1437
|
+
value = rhs
|
|
1438
|
+
if value is None:
|
|
1439
|
+
value = attached_value(token)
|
|
1440
|
+
if value is None:
|
|
1441
|
+
continue
|
|
1442
|
+
stripped = value.strip().strip("'\"")
|
|
1443
|
+
if stripped in STDIN_PAYLOAD_TOKENS:
|
|
1444
|
+
parts.append(whole_command)
|
|
1445
|
+
continue
|
|
1446
|
+
parts.append(stripped)
|
|
1447
|
+
path = resolve_operand(stripped)
|
|
1448
|
+
if path is not None:
|
|
1449
|
+
parts.append(read_operand(path))
|
|
1450
|
+
return "\n".join(parts)
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def text_declares_readiness(text):
|
|
1454
|
+
"""Whether a file's or payload's own text carries a declaration.
|
|
1455
|
+
|
|
1456
|
+
The token-position machinery above cannot be reused here: a file is not
|
|
1457
|
+
argv, and a payload is JSON. What is checked is deliberately the SAME
|
|
1458
|
+
three declarations, matched textually and never loosened into "the role
|
|
1459
|
+
string appears somewhere" — a state-based ready role is an ordinary word
|
|
1460
|
+
like `Ready`, and accepting a bare occurrence of it would let prose in a
|
|
1461
|
+
description declare readiness the item does not have.
|
|
1462
|
+
|
|
1463
|
+
Args:
|
|
1464
|
+
text: File or payload contents.
|
|
1465
|
+
|
|
1466
|
+
Returns:
|
|
1467
|
+
True when the text declares build-ready or a human gate.
|
|
1468
|
+
"""
|
|
1469
|
+
if declares_human_gate(text):
|
|
1470
|
+
return True
|
|
1471
|
+
for role in (ready_role, upstream_ready_role, default_ready_role):
|
|
1472
|
+
if not role:
|
|
1473
|
+
continue
|
|
1474
|
+
if re.search(LABEL_FLAG_TEXT % re.escape(role), text):
|
|
1475
|
+
return True
|
|
1476
|
+
if tracker in STATE_ROLE_TRACKERS:
|
|
1477
|
+
if LIFECYCLE_ROLE_READY.search(text):
|
|
1478
|
+
return True
|
|
1479
|
+
if ready_role and re.search(
|
|
1480
|
+
r"\"(?:state|status|stateName|statusName|state_name|name|transition)\""
|
|
1481
|
+
r"\s*:\s*\"%s\"" % re.escape(ready_role),
|
|
1482
|
+
text,
|
|
1483
|
+
re.IGNORECASE,
|
|
1484
|
+
):
|
|
1485
|
+
return True
|
|
1486
|
+
return False
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def scope_declaration(text, state_role_ok):
|
|
1490
|
+
"""Whether a whole text declares readiness for everything inside it.
|
|
1491
|
+
|
|
1492
|
+
Only the two MARKERS qualify, never the `--label` flag, and that asymmetry
|
|
1493
|
+
is the same one the argv check already makes. A marker has no other
|
|
1494
|
+
meaning, so its presence anywhere in a script IS the declaration for that
|
|
1495
|
+
script. A label flag is positional and belongs to one create; letting it
|
|
1496
|
+
vouch for a second, unlabelled create further down the same file would be a
|
|
1497
|
+
hole rather than a convenience.
|
|
1498
|
+
|
|
1499
|
+
`state_role_ok` carries the SAME scoping the argv path applies, and it has
|
|
1500
|
+
to be threaded here rather than recomputed from the tracker alone: a Linear
|
|
1501
|
+
project whose script files `gh issue create --repo <other>` is a cross-repo
|
|
1502
|
+
GitHub filing, and this project's workflow role does not answer for another
|
|
1503
|
+
repository's queue. Checking only `tracker in STATE_ROLE_TRACKERS` waved
|
|
1504
|
+
exactly that through. Caught by review before it shipped.
|
|
1505
|
+
|
|
1506
|
+
Args:
|
|
1507
|
+
text: A script's contents, or one command segment.
|
|
1508
|
+
state_role_ok: Whether a lifecycle-role declaration answers here.
|
|
1509
|
+
|
|
1510
|
+
Returns:
|
|
1511
|
+
True when the text carries a whole-scope declaration.
|
|
1512
|
+
"""
|
|
1513
|
+
if declares_human_gate(text):
|
|
1514
|
+
return True
|
|
1515
|
+
return bool(state_role_ok and LIFECYCLE_ROLE_READY.search(text))
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
def creation_signature(name, args, extra=""):
|
|
1519
|
+
"""Classify a tracker CLI invocation as a creation.
|
|
1520
|
+
|
|
1521
|
+
Args:
|
|
1522
|
+
name: The CLI basename.
|
|
1523
|
+
args: Every token after it in this segment.
|
|
1524
|
+
extra: Payload text this command submits, from a file or stdin.
|
|
1525
|
+
|
|
1526
|
+
Returns:
|
|
1527
|
+
A short human-readable signature, or None.
|
|
1528
|
+
"""
|
|
1529
|
+
if "--help" in args or "-h" in args:
|
|
1530
|
+
return None
|
|
1531
|
+
joined = " ".join(args)
|
|
1532
|
+
if extra:
|
|
1533
|
+
joined = joined + "\n" + extra
|
|
1534
|
+
|
|
1535
|
+
if name == "gh":
|
|
1536
|
+
if invokes_verb(args, {"issue"}, "create"):
|
|
1537
|
+
return "gh issue create"
|
|
1538
|
+
# Same reasoning: `api` is located without the flag-value filter, since
|
|
1539
|
+
# an endpoint match and a write method must both also hold.
|
|
1540
|
+
if "api" in args:
|
|
1541
|
+
if GRAPHQL_CREATE.search(joined):
|
|
1542
|
+
return "gh api graphql issue creation"
|
|
1543
|
+
if endpoint_paths(args, GITHUB_ISSUES_PATH) and is_write_request(args):
|
|
1544
|
+
return "gh api POST .../issues"
|
|
1545
|
+
return None
|
|
1546
|
+
|
|
1547
|
+
if name in {"linear", "jira"}:
|
|
1548
|
+
if invokes_verb(args, {"issue", "issues"}, "create"):
|
|
1549
|
+
return "%s issue create" % name
|
|
1550
|
+
return None
|
|
1551
|
+
|
|
1552
|
+
if name == "acli":
|
|
1553
|
+
if invokes_verb(args, {"workitem", "workitems", "issue", "issues"}, "create"):
|
|
1554
|
+
return "acli … create"
|
|
1555
|
+
return None
|
|
1556
|
+
|
|
1557
|
+
if name in HTTP_CLIS:
|
|
1558
|
+
if not is_write_request(args):
|
|
1559
|
+
return None
|
|
1560
|
+
for token in args:
|
|
1561
|
+
if GITHUB_ISSUES_URL.search(token):
|
|
1562
|
+
return "%s POST api.github.com/…/issues" % name
|
|
1563
|
+
if JIRA_ISSUE_URL.search(token):
|
|
1564
|
+
return "%s POST …/rest/api/…/issue" % name
|
|
1565
|
+
if "api.linear.app/graphql" in token and GRAPHQL_CREATE.search(joined):
|
|
1566
|
+
return "%s POST api.linear.app/graphql issueCreate" % name
|
|
1567
|
+
return None
|
|
1568
|
+
|
|
1569
|
+
return None
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
def before_end_of_options(args):
|
|
1573
|
+
"""The arguments up to a bare `--`.
|
|
1574
|
+
|
|
1575
|
+
Everything after `--` is an operand, not a flag, so it cannot reach the
|
|
1576
|
+
created item — crediting a declaration from there is the same mistake as
|
|
1577
|
+
reading the role out of a title, one position over.
|
|
1578
|
+
|
|
1579
|
+
The two CLIs available for testing disagree about it, which is why the
|
|
1580
|
+
guard cannot lean on any of them being strict: gh 2.96.0 rejects a
|
|
1581
|
+
post-`--` flag outright, while `acli` parses straight past it and proceeds
|
|
1582
|
+
to create the work item with the trailing `--status` silently unapplied.
|
|
1583
|
+
That made it a live bypass on the JIRA path, verified by running it.
|
|
1584
|
+
|
|
1585
|
+
Args:
|
|
1586
|
+
args: A command's arguments.
|
|
1587
|
+
|
|
1588
|
+
Returns:
|
|
1589
|
+
The arguments preceding the first bare `--`.
|
|
1590
|
+
"""
|
|
1591
|
+
return args[: args.index("--")] if "--" in args else args
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
def body_file_paths(args):
|
|
1595
|
+
"""Paths the command will submit as the item body.
|
|
1596
|
+
|
|
1597
|
+
Args:
|
|
1598
|
+
args: A command's arguments.
|
|
1599
|
+
|
|
1600
|
+
Returns:
|
|
1601
|
+
Candidate file paths, unverified.
|
|
1602
|
+
"""
|
|
1603
|
+
paths = []
|
|
1604
|
+
for index, token in enumerate(args):
|
|
1605
|
+
if token in BODY_FILE_FLAGS and index + 1 < len(args):
|
|
1606
|
+
paths.append(args[index + 1])
|
|
1607
|
+
if "=" in token:
|
|
1608
|
+
head, value = token.split("=", 1)
|
|
1609
|
+
if head in BODY_FILE_FLAGS:
|
|
1610
|
+
paths.append(value)
|
|
1611
|
+
if token.startswith("@") and len(token) > 1:
|
|
1612
|
+
paths.append(token[1:])
|
|
1613
|
+
return paths
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
def normalise_repo(value):
|
|
1617
|
+
"""A `--repo` value reduced to a comparable `owner/name`.
|
|
1618
|
+
|
|
1619
|
+
`gh` accepts `OWNER/REPO`, `HOST/OWNER/REPO`, and a full browser URL, and
|
|
1620
|
+
GitHub itself is case-insensitive about both halves — so comparing the raw
|
|
1621
|
+
token would call the same repository two different places depending on how
|
|
1622
|
+
it was typed.
|
|
1623
|
+
|
|
1624
|
+
Casing is PRESERVED here and folded only at the point of comparison. The
|
|
1625
|
+
refusal names this string back to an operator, and echoing
|
|
1626
|
+
`codyswanngt/lisa` at someone who typed `CodySwannGT/lisa` reads as a
|
|
1627
|
+
different repository — a message that has to be squinted at is the thing
|
|
1628
|
+
this change is repairing.
|
|
1629
|
+
|
|
1630
|
+
Args:
|
|
1631
|
+
value: The raw token.
|
|
1632
|
+
|
|
1633
|
+
Returns:
|
|
1634
|
+
An as-typed `owner/name`, or None when the token names no repository.
|
|
1635
|
+
"""
|
|
1636
|
+
text = value.strip().strip("'\"")
|
|
1637
|
+
if text.endswith(".git"):
|
|
1638
|
+
text = text[: -len(".git")]
|
|
1639
|
+
parts = [part for part in text.split("/") if part and not part.endswith(":")]
|
|
1640
|
+
if len(parts) < 2:
|
|
1641
|
+
return None
|
|
1642
|
+
return "%s/%s" % (parts[-2], parts[-1])
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
def target_repository(args):
|
|
1646
|
+
"""The repository this creation is addressed at, when it names one.
|
|
1647
|
+
|
|
1648
|
+
Read only from positions that actually reach the created item: a flag
|
|
1649
|
+
before the end-of-options marker, or the endpoint the write is posted to.
|
|
1650
|
+
A `-f repo=o/r` payload field is data being SENT, not the address being
|
|
1651
|
+
posted to, and `endpoint_paths` already excludes it.
|
|
1652
|
+
|
|
1653
|
+
Args:
|
|
1654
|
+
args: A creating command's arguments.
|
|
1655
|
+
|
|
1656
|
+
Returns:
|
|
1657
|
+
A lowercased `owner/name`, or None when the calling project is the
|
|
1658
|
+
target — which is the overwhelmingly common case and today's behaviour.
|
|
1659
|
+
"""
|
|
1660
|
+
scoped = before_end_of_options(args)
|
|
1661
|
+
for index, token in enumerate(scoped):
|
|
1662
|
+
if token in REPO_FLAGS and index + 1 < len(scoped):
|
|
1663
|
+
return normalise_repo(scoped[index + 1])
|
|
1664
|
+
if "=" in token:
|
|
1665
|
+
head, value = token.split("=", 1)
|
|
1666
|
+
if head in REPO_FLAGS:
|
|
1667
|
+
return normalise_repo(value)
|
|
1668
|
+
for path in endpoint_paths(scoped, GITHUB_ISSUES_PATH):
|
|
1669
|
+
match = GITHUB_ISSUES_PATH_REPO.search(path)
|
|
1670
|
+
if match:
|
|
1671
|
+
return normalise_repo("%s/%s" % (match.group(1), match.group(2)))
|
|
1672
|
+
for token in scoped:
|
|
1673
|
+
match = GITHUB_ISSUES_URL_REPO.search(token)
|
|
1674
|
+
if match:
|
|
1675
|
+
return normalise_repo("%s/%s" % (match.group(1), match.group(2)))
|
|
1676
|
+
return None
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
def roles_for(target):
|
|
1680
|
+
"""Which ready-role tokens satisfy a creation addressed at `target`.
|
|
1681
|
+
|
|
1682
|
+
The guard demands a declaration either way; this decides only WHOSE
|
|
1683
|
+
vocabulary the declaration is written in.
|
|
1684
|
+
|
|
1685
|
+
The indeterminate case is the last branch: a GitHub-tracked project that
|
|
1686
|
+
declares no `github.org`/`github.repo` cannot be compared against a target,
|
|
1687
|
+
so both roles are accepted rather than inventing a refusal. That is
|
|
1688
|
+
permissive about which token, never about whether one is required.
|
|
1689
|
+
|
|
1690
|
+
Args:
|
|
1691
|
+
target: The addressed repository as typed, or None.
|
|
1692
|
+
|
|
1693
|
+
Returns:
|
|
1694
|
+
A (roles, cross_repo_target) pair. The target is None when the calling
|
|
1695
|
+
project is the one being written to. When set it is the as-typed
|
|
1696
|
+
spelling, because the refusal names it back to an operator.
|
|
1697
|
+
"""
|
|
1698
|
+
# GitHub is case-insensitive about owner and name, so the comparison folds
|
|
1699
|
+
# case while the reported string keeps the operator's own spelling.
|
|
1700
|
+
folded = target.lower() if target is not None else None
|
|
1701
|
+
if folded is None or (own_repo and folded == own_repo):
|
|
1702
|
+
return [ready_role], None
|
|
1703
|
+
if upstream_repo and folded == upstream_repo:
|
|
1704
|
+
role = upstream_ready_role
|
|
1705
|
+
else:
|
|
1706
|
+
# Another repository Lisa has no configuration for. Its lane is
|
|
1707
|
+
# whatever GitHub's stock one is; the caller's token is categorically
|
|
1708
|
+
# not it.
|
|
1709
|
+
role = default_ready_role
|
|
1710
|
+
if own_repo or not caller_is_github:
|
|
1711
|
+
return [role], target
|
|
1712
|
+
# Indeterminate, and the target is deliberately NOT reported. The refusal
|
|
1713
|
+
# would otherwise say "this filing is addressed at another repository" and
|
|
1714
|
+
# "this project's role does not answer for it" — the first unproven and the
|
|
1715
|
+
# second flatly false, since this branch accepts the project's role. A
|
|
1716
|
+
# message naming a token that does not work is the remediation pointing
|
|
1717
|
+
# away from the fix, which is the defect being repaired here.
|
|
1718
|
+
return [ready_role, role], None
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
def declares_readiness(raw_args, roles, extra="", state_role_ok=False):
|
|
1722
|
+
"""Whether the create carries one of the required declarations.
|
|
1723
|
+
|
|
1724
|
+
Args:
|
|
1725
|
+
raw_args: The creating command's arguments.
|
|
1726
|
+
roles: The build-ready role tokens that satisfy this filing.
|
|
1727
|
+
extra: Payload text this command submits, from a file or stdin.
|
|
1728
|
+
state_role_ok: Whether a lifecycle-role declaration answers here. True
|
|
1729
|
+
only for a state-based tracker filing into its own tracker, where
|
|
1730
|
+
no argv flag on the mandated client can carry the state.
|
|
1731
|
+
|
|
1732
|
+
Returns:
|
|
1733
|
+
True when a build-ready role or a human-gate marker is present.
|
|
1734
|
+
"""
|
|
1735
|
+
args = before_end_of_options(raw_args)
|
|
1736
|
+
for role in roles:
|
|
1737
|
+
if not role:
|
|
1738
|
+
continue
|
|
1739
|
+
for raw in flag_values(args, LABEL_FLAGS):
|
|
1740
|
+
candidates = [part.strip().strip("'\"") for part in raw.split(",")]
|
|
1741
|
+
if role in candidates:
|
|
1742
|
+
return True
|
|
1743
|
+
# Checked per ARGV VALUE rather than against `" ".join(args)`, and that is
|
|
1744
|
+
# what makes the positional rule mean anything here. Joining first destroys
|
|
1745
|
+
# position: after a join, no value's line start survives except the first
|
|
1746
|
+
# argument's, so "the marker leads its line" would be decided by argument
|
|
1747
|
+
# order. A `--title` value is its own single line, which is exactly the
|
|
1748
|
+
# unit the rule is about -- and titles must keep being read, since this
|
|
1749
|
+
# guard reads them and the intake matcher does not.
|
|
1750
|
+
if any(declares_human_gate(value) for value in args):
|
|
1751
|
+
return True
|
|
1752
|
+
for path in body_file_paths(args):
|
|
1753
|
+
try:
|
|
1754
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
1755
|
+
if declares_human_gate(handle.read()):
|
|
1756
|
+
return True
|
|
1757
|
+
except OSError:
|
|
1758
|
+
continue
|
|
1759
|
+
if declares_human_gate(extra):
|
|
1760
|
+
return True
|
|
1761
|
+
# The state-based path. Scoped by `state_role_ok` rather than checked
|
|
1762
|
+
# unconditionally, so this adds a compliant command where none existed and
|
|
1763
|
+
# takes none away where one already did.
|
|
1764
|
+
if state_role_ok:
|
|
1765
|
+
if LIFECYCLE_ROLE_READY.search(" ".join(args)):
|
|
1766
|
+
return True
|
|
1767
|
+
if extra and text_declares_readiness(extra):
|
|
1768
|
+
return True
|
|
1769
|
+
return False
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
def declares_leaf_type(args):
|
|
1773
|
+
"""Whether the filing declares a by-design leaf type as a flag value.
|
|
1774
|
+
|
|
1775
|
+
Read only from positions that reach the created item, and never from a
|
|
1776
|
+
title or body — the same discipline the build-ready role is held to, for
|
|
1777
|
+
the same reason: a type named in prose is not a type applied.
|
|
1778
|
+
|
|
1779
|
+
Args:
|
|
1780
|
+
args: The creating command's arguments, already scoped to the flags.
|
|
1781
|
+
|
|
1782
|
+
Returns:
|
|
1783
|
+
True when a Bug / Task / Sub-task / Improvement is declared.
|
|
1784
|
+
"""
|
|
1785
|
+
for raw in flag_values(args, TYPE_FLAGS):
|
|
1786
|
+
for part in raw.split(","):
|
|
1787
|
+
value = part.strip().strip("'\"").lower()
|
|
1788
|
+
if value.startswith("type:"):
|
|
1789
|
+
value = value[len("type:") :]
|
|
1790
|
+
if value in BY_DESIGN_LEAF_TYPES:
|
|
1791
|
+
return True
|
|
1792
|
+
return False
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def declares_container(raw_args, texts=()):
|
|
1796
|
+
"""Whether the create declares the item a container.
|
|
1797
|
+
|
|
1798
|
+
A container is neither build-ready nor human-gated by construction, so this
|
|
1799
|
+
is the third accepted declaration rather than a third way to satisfy the
|
|
1800
|
+
first two. See the CONTAINER_DECLARATION block for why it reads the
|
|
1801
|
+
canonical declaration and not a `type:Epic` claim.
|
|
1802
|
+
|
|
1803
|
+
Args:
|
|
1804
|
+
raw_args: The creating command's arguments.
|
|
1805
|
+
texts: Payload or script text this command submits or runs.
|
|
1806
|
+
|
|
1807
|
+
Returns:
|
|
1808
|
+
True when the container declaration is present and uncontradicted.
|
|
1809
|
+
"""
|
|
1810
|
+
args = before_end_of_options(raw_args)
|
|
1811
|
+
# The contradiction check comes FIRST, so a filing that declares itself
|
|
1812
|
+
# both a container and a by-design leaf is refused rather than allowed on
|
|
1813
|
+
# the strength of the half that suits it.
|
|
1814
|
+
if declares_leaf_type(args):
|
|
1815
|
+
return False
|
|
1816
|
+
if CONTAINER_DECLARATION.search(" ".join(args)):
|
|
1817
|
+
return True
|
|
1818
|
+
for path in body_file_paths(args):
|
|
1819
|
+
try:
|
|
1820
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
1821
|
+
if CONTAINER_DECLARATION.search(handle.read()):
|
|
1822
|
+
return True
|
|
1823
|
+
except OSError:
|
|
1824
|
+
continue
|
|
1825
|
+
for text in texts:
|
|
1826
|
+
if text and CONTAINER_DECLARATION.search(text):
|
|
1827
|
+
return True
|
|
1828
|
+
return False
|
|
1829
|
+
|
|
1830
|
+
|
|
1831
|
+
def flag_values(args, names):
|
|
1832
|
+
"""Every value assigned to one of the named flags.
|
|
1833
|
+
|
|
1834
|
+
Args:
|
|
1835
|
+
args: A command's arguments.
|
|
1836
|
+
names: The flag spellings to collect.
|
|
1837
|
+
|
|
1838
|
+
Returns:
|
|
1839
|
+
The raw values, unsplit and unquoted.
|
|
1840
|
+
"""
|
|
1841
|
+
values = []
|
|
1842
|
+
for index, token in enumerate(args):
|
|
1843
|
+
if token in names and index + 1 < len(args):
|
|
1844
|
+
values.append(args[index + 1])
|
|
1845
|
+
if "=" in token:
|
|
1846
|
+
head, value = token.split("=", 1)
|
|
1847
|
+
if head in names:
|
|
1848
|
+
values.append(value)
|
|
1849
|
+
return values
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
def nested_operands(argv):
|
|
1853
|
+
"""Command strings this argv hands to another interpreter.
|
|
1854
|
+
|
|
1855
|
+
Position-scoped rather than shell-allowlisted: the operand after `-c` (or
|
|
1856
|
+
after `eval`) is a command by the calling convention itself, whoever the
|
|
1857
|
+
program is. That covers `bash -c`, `sh -c`, `zsh -c`, `python -c`, and the
|
|
1858
|
+
POSIX builtin `eval`, without an allowlist to keep complete.
|
|
1859
|
+
|
|
1860
|
+
Recursing into arbitrary trailing quoted operands was considered and
|
|
1861
|
+
rejected: it re-refuses `git commit -m "the gh issue create guard"`, which
|
|
1862
|
+
is an ordinary and correct command. `ssh host '…'` is therefore NOT
|
|
1863
|
+
intercepted — a documented limit, since that runs against another host's
|
|
1864
|
+
tracker config and needs that host's own guard.
|
|
1865
|
+
|
|
1866
|
+
Args:
|
|
1867
|
+
argv: One command's tokens.
|
|
1868
|
+
|
|
1869
|
+
Returns:
|
|
1870
|
+
Nested command strings.
|
|
1871
|
+
"""
|
|
1872
|
+
operands = []
|
|
1873
|
+
for index, token in enumerate(argv):
|
|
1874
|
+
if index + 1 >= len(argv):
|
|
1875
|
+
continue
|
|
1876
|
+
if token == "-c" or token.endswith("-c") and token.startswith("-"):
|
|
1877
|
+
operands.append(argv[index + 1])
|
|
1878
|
+
elif basename(token) == "eval":
|
|
1879
|
+
operands.append(argv[index + 1])
|
|
1880
|
+
return operands
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
inspected_files = set()
|
|
1884
|
+
|
|
1885
|
+
|
|
1886
|
+
# Interpreters whose OPERAND is a program they run. Broader than a shell list
|
|
1887
|
+
# because `file_creation` recognises a creation in any language — the
|
|
1888
|
+
# `node wrapper.mjs` that speaks HTTP directly is exactly the shape #3484
|
|
1889
|
+
# measured as a fail-open, so narrowing this to shells would reopen it.
|
|
1890
|
+
EXECUTING_INTERPRETERS = {
|
|
1891
|
+
"bash", "dash", "ksh", "sh", "zsh",
|
|
1892
|
+
"bun", "deno", "node", "nodejs", "perl", "php",
|
|
1893
|
+
"python", "python3", "ruby", "ts-node", "tsx",
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
# `.` and its `source` alias run the named file in the CURRENT shell.
|
|
1897
|
+
SOURCE_BUILTINS = {"source", "."}
|
|
1898
|
+
|
|
1899
|
+
# Wrappers that run what FOLLOWS them without changing what it is, mapped to
|
|
1900
|
+
# (options whose value is a SEPARATE token, positional operands consumed).
|
|
1901
|
+
# The positional count is why this is a table: `timeout 5 bash create.sh` puts
|
|
1902
|
+
# an operand between the wrapper and the interpreter, so a walk that steps over
|
|
1903
|
+
# `-flags` only stops at `5` and never reaches the interpreter.
|
|
1904
|
+
EXEC_WRAPPERS = {
|
|
1905
|
+
"builtin": (frozenset(), 0),
|
|
1906
|
+
"command": (frozenset(), 0),
|
|
1907
|
+
"exec": (frozenset({"-a"}), 0),
|
|
1908
|
+
"env": (frozenset({"-u", "--unset", "-C", "--chdir", "--argv0"}), 0),
|
|
1909
|
+
"nice": (frozenset({"-n", "--adjustment"}), 0),
|
|
1910
|
+
"nohup": (frozenset(), 0),
|
|
1911
|
+
"setsid": (frozenset(), 0),
|
|
1912
|
+
"stdbuf": (frozenset({"-i", "-o", "-e"}), 0),
|
|
1913
|
+
"sudo": (frozenset({
|
|
1914
|
+
"-u", "--user", "-g", "--group", "-C", "--close-from",
|
|
1915
|
+
"-p", "--prompt", "-h", "--host", "-r", "--role",
|
|
1916
|
+
"-t", "--type", "-U", "--other-user",
|
|
1917
|
+
}), 0),
|
|
1918
|
+
"time": (frozenset(), 0),
|
|
1919
|
+
"timeout": (frozenset({"-k", "--kill-after", "-s", "--signal"}), 1),
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
# Interpreter options that mean "no script file follows": a command string or a
|
|
1923
|
+
# module name, both of which the guard reads by other means or not at all. These
|
|
1924
|
+
# are interpreter-specific on purpose. `bash -e create.sh` and `sh -m create.sh`
|
|
1925
|
+
# still execute the following file; treating Python and Node flags as universal
|
|
1926
|
+
# silently stopped the scan before those shell operands.
|
|
1927
|
+
NO_SCRIPT_OPTIONS = {
|
|
1928
|
+
"bun": {"-e", "--eval"},
|
|
1929
|
+
"node": {"-e", "--eval"},
|
|
1930
|
+
"nodejs": {"-e", "--eval"},
|
|
1931
|
+
"perl": {"-e"},
|
|
1932
|
+
"php": {"-r"},
|
|
1933
|
+
"python": {"-c", "-m", "--command", "--module"},
|
|
1934
|
+
"python3": {"-c", "-m", "--command", "--module"},
|
|
1935
|
+
"ruby": {"-e"},
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
# Interpreter switches whose value is a separate token, followed by the real
|
|
1939
|
+
# script operand. Treating the value as the script makes the guard inspect a
|
|
1940
|
+
# preload or option value and never reach what the interpreter executes.
|
|
1941
|
+
INTERPRETER_VALUE_OPTIONS = {
|
|
1942
|
+
"bash": {"-o", "--option"},
|
|
1943
|
+
"dash": {"-o"},
|
|
1944
|
+
"deno": {"-c", "--config"},
|
|
1945
|
+
"ksh": {"-o"},
|
|
1946
|
+
"node": {"-r", "--require"},
|
|
1947
|
+
"nodejs": {"-r", "--require"},
|
|
1948
|
+
"sh": {"-o"},
|
|
1949
|
+
"zsh": {"-o"},
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
# Runtime subcommands that introduce the script path rather than name it.
|
|
1953
|
+
INTERPRETER_SUBCOMMANDS = {
|
|
1954
|
+
"bun": {"run"},
|
|
1955
|
+
"deno": {"run"},
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
|
|
1959
|
+
def is_assignment_word(token):
|
|
1960
|
+
"""Whether token is a POSIX shell assignment in command-prefix position."""
|
|
1961
|
+
return re.match(r"^[A-Za-z_][A-Za-z0-9_]*\+?=", token) is not None
|
|
1962
|
+
|
|
1963
|
+
# Programs that READ their operands and never execute them as programs.
|
|
1964
|
+
#
|
|
1965
|
+
# This is an allowlist of readers rather than the inverse, and the asymmetry is
|
|
1966
|
+
# deliberate. An UNKNOWN program with a file operand is still followed, because
|
|
1967
|
+
# whether it executes that operand is genuinely unknowable from the command —
|
|
1968
|
+
# and a probe in this guard's own suite drives it with a runner nobody
|
|
1969
|
+
# enumerated precisely so a fix keyed on a list of interpreters fails. Failing
|
|
1970
|
+
# closed on the unknown keeps that coverage.
|
|
1971
|
+
#
|
|
1972
|
+
# What the list buys is the measured population of #3705, every member of which
|
|
1973
|
+
# is a well-known inspector: `git diff`, `grep -n`, `wc -l`, a `git grep`
|
|
1974
|
+
# PATHSPEC, `sed -n '1,50p'`, and a test run. `git` is here wholesale because no
|
|
1975
|
+
# git subcommand executes a named file as a program — `git commit -F <file>` and
|
|
1976
|
+
# `git grep -- <pathspec>` both take the path as data.
|
|
1977
|
+
#
|
|
1978
|
+
# Test runners are here on purpose and it is the one entry worth arguing about.
|
|
1979
|
+
# `vitest <file>` does run that file, so the executes-rule would follow it — but
|
|
1980
|
+
# running a test is not filing an issue, and refusing a test run is the sharpest
|
|
1981
|
+
# harm #3705 records, because it can block the verification of a fix while
|
|
1982
|
+
# saying nothing about why.
|
|
1983
|
+
#
|
|
1984
|
+
# RELOCATION AND ECHOING ARE READS TOO, and leaving them off is what turned a
|
|
1985
|
+
# false positive into a property of the repository (CodySwannGT/lisa#3683). A
|
|
1986
|
+
# file that QUOTES a creation was already reachable by `grep`, `cat` and `ls`
|
|
1987
|
+
# once this list existed — but not by `cp` or `mv`, so the file could be read
|
|
1988
|
+
# and never moved, by any agent and by CI, for as long as the literal stayed in
|
|
1989
|
+
# it. The measured case shows why "reword it" is not the inverse: the refusal
|
|
1990
|
+
# recurred AFTER the content had been reworded, on a different file quoting the
|
|
1991
|
+
# same command. None of these four executes an operand: `cp` and `mv` copy
|
|
1992
|
+
# bytes, `echo` and `printf` write them to stdout. They are also the vocabulary
|
|
1993
|
+
# `unparseable_reads_only` answers with when the text could not be lexed at all.
|
|
1994
|
+
#
|
|
1995
|
+
# RESIDUAL, stated rather than hidden: a reader NOT on this list is still
|
|
1996
|
+
# followed and can still over-refuse. The set of read-only tools is unbounded,
|
|
1997
|
+
# so this closes the measured population and not the class. Add names here as
|
|
1998
|
+
# they are measured; do not invert the default to close it by fiat, or the
|
|
1999
|
+
# executed-script reach that #3484 bought is lost.
|
|
2000
|
+
#
|
|
2001
|
+
# The copy/move/link family is measured, not inferred: `cp <a file that files>`
|
|
2002
|
+
# was refused as "an unparseable command that reads as a tracker creation
|
|
2003
|
+
# inside <path>" (CodySwannGT/lisa#3683, trip 5, reproduced in this guard's own
|
|
2004
|
+
# suite). These four relocate bytes and never execute an operand, so following
|
|
2005
|
+
# them can only ever over-refuse — and the harm is the propagating kind this
|
|
2006
|
+
# ticket is about, because a file that quotes a creation then cannot be copied
|
|
2007
|
+
# by any agent or by CI.
|
|
2008
|
+
#
|
|
2009
|
+
# `mv`, `ln` and `install` join `cp` as the same operation rather than as a
|
|
2010
|
+
# guess: the list already carries whole families (`git` wholesale, `bat`/`xxd`
|
|
2011
|
+
# beside `cat`) on exactly that reasoning. Path arithmetic and metadata tools
|
|
2012
|
+
# are deliberately NOT added — unmeasured, and the residual note above is the
|
|
2013
|
+
# standing instruction to add on measurement rather than by sweep.
|
|
2014
|
+
READ_ONLY_PROGRAMS = {
|
|
2015
|
+
"awk", "bat", "cat", "cksum", "cmp", "column", "comm", "cp", "cut",
|
|
2016
|
+
"diff", "du", "echo", "file", "fold", "git", "grep", "head",
|
|
2017
|
+
"hexdump", "install", "jest", "jq", "less", "ln", "ls", "md5",
|
|
2018
|
+
"md5sum", "more", "mv", "nl", "od", "printf", "pytest", "rg",
|
|
2019
|
+
"sed", "sha1sum", "sha256sum", "shellcheck", "shfmt", "sort",
|
|
2020
|
+
"stat", "strings", "tail", "tee", "uniq", "vitest", "wc", "xxd",
|
|
2021
|
+
"yamllint",
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
|
|
2025
|
+
def executing_command(argv):
|
|
2026
|
+
"""The program this command runs, and the tokens after it.
|
|
2027
|
+
|
|
2028
|
+
Steps over leading variable assignments and over wrappers that do not
|
|
2029
|
+
change what runs. Reading a wrapper as the command is how a payload hides
|
|
2030
|
+
from a classifier that inspects only a segment's first word.
|
|
2031
|
+
|
|
2032
|
+
Args:
|
|
2033
|
+
argv: One command's tokens.
|
|
2034
|
+
|
|
2035
|
+
Returns:
|
|
2036
|
+
A triple (token, program, args); (None, None, []) when none is named.
|
|
2037
|
+
The raw token is carried alongside the basename because a script run by
|
|
2038
|
+
bare path — `./create.sh`, relying on its shebang — has no interpreter
|
|
2039
|
+
to recognise, and the command word IS the thing being executed.
|
|
2040
|
+
"""
|
|
2041
|
+
index = 0
|
|
2042
|
+
while index < len(argv):
|
|
2043
|
+
token = argv[index]
|
|
2044
|
+
if is_assignment_word(token):
|
|
2045
|
+
index += 1
|
|
2046
|
+
continue
|
|
2047
|
+
program = basename(token)
|
|
2048
|
+
if program not in EXEC_WRAPPERS:
|
|
2049
|
+
return (token, program, argv[index + 1 :])
|
|
2050
|
+
separate, positional = EXEC_WRAPPERS[program]
|
|
2051
|
+
index += 1
|
|
2052
|
+
while index < len(argv) and argv[index].startswith("-"):
|
|
2053
|
+
option = argv[index]
|
|
2054
|
+
if option == "--":
|
|
2055
|
+
index += 1
|
|
2056
|
+
break
|
|
2057
|
+
index += 2 if option in separate else 1
|
|
2058
|
+
index += positional
|
|
2059
|
+
return (None, None, [])
|
|
2060
|
+
|
|
2061
|
+
|
|
2062
|
+
# Operators that end a command in raw shell text. Used ONLY where `shlex` has
|
|
2063
|
+
# already refused the text, so a quote-aware split is not available — and
|
|
2064
|
+
# OVER-splitting is the safe direction here, because an extra segment can only
|
|
2065
|
+
# add a command word to check, never remove one.
|
|
2066
|
+
RAW_SEGMENT_SPLIT = re.compile(r"&&|\|\||[;|&\n]")
|
|
2067
|
+
|
|
2068
|
+
|
|
2069
|
+
def raw_command_words(text):
|
|
2070
|
+
"""The program each segment of unlexable text puts in command position.
|
|
2071
|
+
|
|
2072
|
+
Answered on a whitespace split because the reason this path exists is that
|
|
2073
|
+
`shlex` refused the text. It still routes through `executing_command`, so a
|
|
2074
|
+
leading assignment and a wrapper — `nice`, `env`, `sudo`, `timeout` — are
|
|
2075
|
+
stepped over here exactly as they are on the parsed path. Reading the
|
|
2076
|
+
wrapper as the command is the bypass this guard's own comments record.
|
|
2077
|
+
|
|
2078
|
+
Args:
|
|
2079
|
+
text: The raw command string.
|
|
2080
|
+
|
|
2081
|
+
Returns:
|
|
2082
|
+
One entry per non-empty segment: the resolved program name, or None
|
|
2083
|
+
when the segment names none.
|
|
2084
|
+
"""
|
|
2085
|
+
words = []
|
|
2086
|
+
for chunk in RAW_SEGMENT_SPLIT.split(text):
|
|
2087
|
+
tokens = chunk.split()
|
|
2088
|
+
if not tokens:
|
|
2089
|
+
continue
|
|
2090
|
+
words.append(executing_command(tokens)[1])
|
|
2091
|
+
return words
|
|
2092
|
+
|
|
2093
|
+
|
|
2094
|
+
def unparseable_reads_only(text):
|
|
2095
|
+
"""Whether unlexable text only READS, and therefore files nothing.
|
|
2096
|
+
|
|
2097
|
+
`UNPARSEABLE_CREATION` matches raw text, so it cannot tell a filing from a
|
|
2098
|
+
sentence about one. `echo the guard's <creation> behaviour` fails to lex
|
|
2099
|
+
for the apostrophe alone, and was refused as a tracker creation — a command
|
|
2100
|
+
that files nothing, told that "this filing declares no readiness", with no
|
|
2101
|
+
printed remedy that applied to any part of it (CodySwannGT/lisa#3683).
|
|
2102
|
+
|
|
2103
|
+
The fallback is NOT removable and is not narrowed here. `<creation> #'` is
|
|
2104
|
+
a real filing that bash runs and `shlex` rejects, so "I could not parse it"
|
|
2105
|
+
must keep meaning refuse. What this asks instead is the COMMAND POSITION
|
|
2106
|
+
question the parsed path already asks and this arm skipped: only a command
|
|
2107
|
+
position can run anything, and a reader takes its operands as data.
|
|
2108
|
+
|
|
2109
|
+
Fails closed on everything else, which is the same asymmetry
|
|
2110
|
+
`READ_ONLY_PROGRAMS` documents: an unresolvable command word, an
|
|
2111
|
+
unrecognised program, or a text with no segments at all is refused.
|
|
2112
|
+
|
|
2113
|
+
Args:
|
|
2114
|
+
text: The raw command string that would not lex.
|
|
2115
|
+
|
|
2116
|
+
Returns:
|
|
2117
|
+
True when every segment's command word is a known reader.
|
|
2118
|
+
"""
|
|
2119
|
+
# Unlexable operands cannot be certified as data when shell substitutions
|
|
2120
|
+
# may execute inside them. Preserve the plain apostrophe reader exemption.
|
|
2121
|
+
if any(operator in text for operator in ("$(", "`", "<(", ">(")):
|
|
2122
|
+
return False
|
|
2123
|
+
words = raw_command_words(text)
|
|
2124
|
+
return bool(words) and all(word in READ_ONLY_PROGRAMS for word in words)
|
|
2125
|
+
|
|
2126
|
+
|
|
2127
|
+
def executed_operand(argv):
|
|
2128
|
+
"""The path this command EXECUTES, or None.
|
|
2129
|
+
|
|
2130
|
+
THIS IS THE COMMAND-POSITION QUESTION, and declining to ask it is what
|
|
2131
|
+
#3705 records: the guard opened any readable file any token named, then
|
|
2132
|
+
justified a refusal from that FILE's contents rather than from the
|
|
2133
|
+
COMMAND's behaviour. Measured refusals included `git diff`, `grep -n`,
|
|
2134
|
+
`wc -l`, a `git grep` PATHSPEC and a test run — every one of them read-only,
|
|
2135
|
+
and one of them blocked running the tests that would have proved a fix.
|
|
2136
|
+
|
|
2137
|
+
`parity-safety-net.sh` already states the rule this restores, and names the
|
|
2138
|
+
opposite as the known-wrong fix (`origin/main`, lines 337-346 and 704-705):
|
|
2139
|
+
"Only a COMMAND POSITION can execute something. A path anywhere else is an
|
|
2140
|
+
argument, and an argument is data." `block-no-verify.sh:495-513`
|
|
2141
|
+
(`strip_command_prefix`) implements the same split with a fail-closed third
|
|
2142
|
+
state. Both live in this directory; this guard was the one that declined.
|
|
2143
|
+
|
|
2144
|
+
NOT narrowed to shells, and NOT narrowed to an enumerated interpreter list.
|
|
2145
|
+
`file_creation` recognises a creation in any language, so `node wrapper.mjs`
|
|
2146
|
+
must still be followed — a measured fail-open in #3484 — and this guard's
|
|
2147
|
+
own suite drives it with a runner nobody enumerated, precisely so a fix
|
|
2148
|
+
keyed on such a list fails. So the default for an unrecognised program is to
|
|
2149
|
+
FOLLOW, and only a known reader is exempt. A script run by bare path with no
|
|
2150
|
+
interpreter at all (`./create.sh`, via its shebang) is followed too: there
|
|
2151
|
+
the command word is itself the thing being executed.
|
|
2152
|
+
|
|
2153
|
+
Reading a REQUEST PAYLOAD is a separate question with a separate answer:
|
|
2154
|
+
`payload_text` resolves `--data-binary @file` and friends by flag name, so
|
|
2155
|
+
narrowing this walk does not stop a creation whose body lives in a file
|
|
2156
|
+
from being seen.
|
|
2157
|
+
|
|
2158
|
+
Args:
|
|
2159
|
+
argv: One command's tokens.
|
|
2160
|
+
|
|
2161
|
+
Returns:
|
|
2162
|
+
The operand token naming a program that will run, or None.
|
|
2163
|
+
"""
|
|
2164
|
+
token, program, args = executing_command(argv)
|
|
2165
|
+
if program is None:
|
|
2166
|
+
return None
|
|
2167
|
+
# A reader takes its operands as data. This is the #3705 population.
|
|
2168
|
+
if program in READ_ONLY_PROGRAMS:
|
|
2169
|
+
return None
|
|
2170
|
+
if program in SOURCE_BUILTINS:
|
|
2171
|
+
return args[0] if args else None
|
|
2172
|
+
# A command word that is ITSELF a readable file is being executed by its
|
|
2173
|
+
# shebang — `./create.sh` names no interpreter and runs all the same.
|
|
2174
|
+
if program not in EXECUTING_INTERPRETERS and resolve_operand(token) is not None:
|
|
2175
|
+
return token
|
|
2176
|
+
# Reached by a known interpreter AND by a program this parser cannot name.
|
|
2177
|
+
# The unknown case is followed on purpose — see READ_ONLY_PROGRAMS.
|
|
2178
|
+
redirected = None
|
|
2179
|
+
index = 0
|
|
2180
|
+
while index < len(args):
|
|
2181
|
+
argument = args[index]
|
|
2182
|
+
if argument == "<":
|
|
2183
|
+
if index + 1 < len(args):
|
|
2184
|
+
redirected = args[index + 1]
|
|
2185
|
+
index += 2
|
|
2186
|
+
continue
|
|
2187
|
+
if argument == "--":
|
|
2188
|
+
index += 1
|
|
2189
|
+
return args[index] if index < len(args) else redirected
|
|
2190
|
+
if argument.startswith("-"):
|
|
2191
|
+
# A command string or a module name; no script file follows.
|
|
2192
|
+
head = argument.split("=", 1)[0]
|
|
2193
|
+
if program in {"bash", "dash", "ksh", "sh", "zsh"}:
|
|
2194
|
+
# Shell `-c` may be clustered (`-ec`), while `-e` and `-m` are
|
|
2195
|
+
# ordinary shell options and do not consume the script path.
|
|
2196
|
+
if not head.startswith("--") and "c" in head[1:]:
|
|
2197
|
+
return None
|
|
2198
|
+
# `-n` is noexec: the shell READS and parses the file and then
|
|
2199
|
+
# exits without running a line of it. `bash -n <file>` is the
|
|
2200
|
+
# one shape that puts a path at a command position while
|
|
2201
|
+
# provably executing nothing, so it is the exact seam between
|
|
2202
|
+
# reading and running — and this walk got it wrong in BOTH
|
|
2203
|
+
# directions (CodySwannGT/lisa#3781). A creation-shaped file
|
|
2204
|
+
# earned a false refusal; a file carrying a human-gate marker
|
|
2205
|
+
# earned a false ALLOW, adjudicating as "declared" a command
|
|
2206
|
+
# that filed nothing. The silent direction is the worse one.
|
|
2207
|
+
#
|
|
2208
|
+
# Not a read-only-command exemption: `bash <file>` still runs
|
|
2209
|
+
# the file and is still refused. The distinction is noexec, not
|
|
2210
|
+
# the program.
|
|
2211
|
+
if head == "--noexec" or (
|
|
2212
|
+
not head.startswith("--") and "n" in head[1:]
|
|
2213
|
+
):
|
|
2214
|
+
return None
|
|
2215
|
+
elif head in NO_SCRIPT_OPTIONS.get(program, set()):
|
|
2216
|
+
return None
|
|
2217
|
+
takes_value = head in INTERPRETER_VALUE_OPTIONS.get(program, set())
|
|
2218
|
+
index += 2 if takes_value and "=" not in argument else 1
|
|
2219
|
+
continue
|
|
2220
|
+
if argument in INTERPRETER_SUBCOMMANDS.get(program, set()):
|
|
2221
|
+
index += 1
|
|
2222
|
+
continue
|
|
2223
|
+
return argument
|
|
2224
|
+
# `bash < create.sh` runs stdin only when no explicit script was named.
|
|
2225
|
+
return redirected
|
|
2226
|
+
|
|
2227
|
+
|
|
2228
|
+
def file_operands(argv):
|
|
2229
|
+
"""The file this command EXECUTES, if it executes one.
|
|
2230
|
+
|
|
2231
|
+
Deciding WHICH programs execute their operands is the question this file
|
|
2232
|
+
used to refuse to ask, and refusing it is what made the guard read a path
|
|
2233
|
+
quoted as prose inside a `--body-file` markdown and attribute its contents
|
|
2234
|
+
to the command. It is answered in `executed_operand` now.
|
|
2235
|
+
|
|
2236
|
+
Args:
|
|
2237
|
+
argv: One command's tokens.
|
|
2238
|
+
|
|
2239
|
+
Returns:
|
|
2240
|
+
Existing paths, deduplicated across the whole scan and capped.
|
|
2241
|
+
"""
|
|
2242
|
+
paths = []
|
|
2243
|
+
operand = executed_operand(argv)
|
|
2244
|
+
for token in [operand] if operand is not None else []:
|
|
2245
|
+
if len(paths) >= FILE_OPERANDS_PER_SEGMENT:
|
|
2246
|
+
break
|
|
2247
|
+
path = resolve_operand(token)
|
|
2248
|
+
if path is None:
|
|
2249
|
+
continue
|
|
2250
|
+
try:
|
|
2251
|
+
key = os.path.realpath(path)
|
|
2252
|
+
except OSError:
|
|
2253
|
+
key = path
|
|
2254
|
+
if key in inspected_files:
|
|
2255
|
+
continue
|
|
2256
|
+
inspected_files.add(key)
|
|
2257
|
+
paths.append(path)
|
|
2258
|
+
return paths
|
|
2259
|
+
|
|
2260
|
+
|
|
2261
|
+
def file_creation(text, depth):
|
|
2262
|
+
"""An undeclared tracker creation inside a file's contents, or None.
|
|
2263
|
+
|
|
2264
|
+
Two recognisers, because a creation inside a file is not always shell.
|
|
2265
|
+
The shell path handles `bash create.sh`; the CONJUNCTION path handles
|
|
2266
|
+
`node wrapper.mjs`, a Python client, or anything else that speaks HTTP
|
|
2267
|
+
directly — it needs a tracker endpoint AND a creation verb in the same
|
|
2268
|
+
file, which is what keeps a changelog that merely mentions `issueCreate`
|
|
2269
|
+
from reading as a creation. It also needs the file to be capable of
|
|
2270
|
+
SENDING what it spells: a helper that writes the same payload into a test
|
|
2271
|
+
fixture submits nothing. See `payload_is_inert`.
|
|
2272
|
+
|
|
2273
|
+
Args:
|
|
2274
|
+
text: The file's contents.
|
|
2275
|
+
depth: Current nesting depth.
|
|
2276
|
+
|
|
2277
|
+
Returns:
|
|
2278
|
+
A (signature, roles, cross_repo_target) triple, or None.
|
|
2279
|
+
"""
|
|
2280
|
+
nested = scan(text, depth + 1, from_file=True)
|
|
2281
|
+
if nested is not None:
|
|
2282
|
+
return nested
|
|
2283
|
+
# The coarse path answers to a coarse declaration check, and the precise
|
|
2284
|
+
# path above answers to the precise one. Reversing that — screening the
|
|
2285
|
+
# whole file first — would let a declaration on one create in a script
|
|
2286
|
+
# vouch for a different, undeclared one further down.
|
|
2287
|
+
if (
|
|
2288
|
+
GRAPHQL_CREATE.search(text)
|
|
2289
|
+
and TRACKER_ENDPOINT.search(text)
|
|
2290
|
+
and not text_declares_readiness(text)
|
|
2291
|
+
# A payload the file WRITES rather than SENDS is data. See
|
|
2292
|
+
# `payload_is_inert` for why the absence of egress alone is not enough.
|
|
2293
|
+
and not payload_is_inert(text)
|
|
2294
|
+
):
|
|
2295
|
+
return "a tracker creation", [ready_role], None
|
|
2296
|
+
return None
|
|
2297
|
+
|
|
2298
|
+
|
|
2299
|
+
def scan(text, depth, from_file=False):
|
|
2300
|
+
"""Find the first undeclared tracker creation in a command string.
|
|
2301
|
+
|
|
2302
|
+
Args:
|
|
2303
|
+
text: A shell command.
|
|
2304
|
+
depth: Current nesting depth.
|
|
2305
|
+
from_file: Whether `text` is a file's contents rather than a typed
|
|
2306
|
+
command.
|
|
2307
|
+
|
|
2308
|
+
Returns:
|
|
2309
|
+
A (signature, roles, cross_repo_target) triple, or None when nothing
|
|
2310
|
+
creation-shaped was found.
|
|
2311
|
+
"""
|
|
2312
|
+
try:
|
|
2313
|
+
stripped = strip_heredocs(text)
|
|
2314
|
+
tokens = explode_operators(shlex.split(stripped, posix=True), stripped)
|
|
2315
|
+
except ValueError:
|
|
2316
|
+
# Bash's grammar is not shlex's. `gh issue create --title x #'` is a
|
|
2317
|
+
# comment to bash, which strips it and RUNS the create, while shlex
|
|
2318
|
+
# raises on the unbalanced quote. Two appended characters, no binary
|
|
2319
|
+
# required. "I could not parse it" must never mean "it is fine".
|
|
2320
|
+
#
|
|
2321
|
+
# A FILE that does not lex is judged by the same recogniser rather than
|
|
2322
|
+
# waved through: an unbalanced quote inside a script is the identical
|
|
2323
|
+
# two-character trick moved one file away, and skipping it would hand
|
|
2324
|
+
# the bypass straight back.
|
|
2325
|
+
#
|
|
2326
|
+
# The declaration check is NOT gated on `from_file`, and that is the
|
|
2327
|
+
# #3727 fix. Tokenisation is what feeds every argv-based check below,
|
|
2328
|
+
# so when it throws, a TYPED command was previously judged with no
|
|
2329
|
+
# declaration consulted at all — not its `--body-file`, not even a
|
|
2330
|
+
# plain `--label <ready role>` sitting in argv. A correctly declared
|
|
2331
|
+
# filing was refused for the unrelated reason that its command happened
|
|
2332
|
+
# not to lex, and told "this filing declares no readiness", which was
|
|
2333
|
+
# false. Reading the text here is the only check available once `shlex`
|
|
2334
|
+
# has failed.
|
|
2335
|
+
#
|
|
2336
|
+
# It cannot reopen the bypass the paragraph above defends. That bypass
|
|
2337
|
+
# is an UNDECLARED create hiding behind a quote, and an undeclared
|
|
2338
|
+
# create has no marker and no `--label <ready role>` for
|
|
2339
|
+
# `text_declares_readiness` to match — which is asserted, not assumed,
|
|
2340
|
+
# by the rejection controls covering a bare create, the role mentioned
|
|
2341
|
+
# in prose, and the role appearing inside a URL.
|
|
2342
|
+
#
|
|
2343
|
+
# A marker in a SEPARATE `--body-file` stays refused, because finding
|
|
2344
|
+
# that operand needs the tokenisation that just failed. That case is
|
|
2345
|
+
# reachable by declaring inline instead, which this change makes work.
|
|
2346
|
+
if text_declares_readiness(text):
|
|
2347
|
+
return None
|
|
2348
|
+
# The command-position arm of the same reasoning `executed_operand`
|
|
2349
|
+
# applies. A text every one of whose segments runs a known READER
|
|
2350
|
+
# cannot file anything, whatever its prose says — see
|
|
2351
|
+
# `unparseable_reads_only` for why this does not narrow the fallback.
|
|
2352
|
+
if unparseable_reads_only(text):
|
|
2353
|
+
return None
|
|
2354
|
+
# Ignore whole-line prose comments when applying the fallback matcher.
|
|
2355
|
+
if UNPARSEABLE_CREATION.search(strip_full_line_comments(text)):
|
|
2356
|
+
return (
|
|
2357
|
+
"an unparseable command that reads as a tracker creation",
|
|
2358
|
+
[ready_role],
|
|
2359
|
+
None,
|
|
2360
|
+
)
|
|
2361
|
+
return None
|
|
2362
|
+
|
|
2363
|
+
for argv in segment(tokens):
|
|
2364
|
+
for index, token in enumerate(argv):
|
|
2365
|
+
name = basename(token)
|
|
2366
|
+
if name not in TRACKER_CLIS and name not in HTTP_CLIS:
|
|
2367
|
+
continue
|
|
2368
|
+
args = argv[index + 1 :]
|
|
2369
|
+
submitted = payload_text(args, text)
|
|
2370
|
+
signature = creation_signature(name, args, submitted)
|
|
2371
|
+
if signature is None:
|
|
2372
|
+
continue
|
|
2373
|
+
# The ambient override is the human operator's. An inline
|
|
2374
|
+
# assignment is the agent granting itself the exemption, so it
|
|
2375
|
+
# disqualifies the override rather than supplying it.
|
|
2376
|
+
if ambient_override and not inline_override:
|
|
2377
|
+
continue
|
|
2378
|
+
roles, target = roles_for(target_repository(args))
|
|
2379
|
+
state_role_ok = tracker in STATE_ROLE_TRACKERS and target is None
|
|
2380
|
+
if declares_readiness(args, roles, submitted, state_role_ok):
|
|
2381
|
+
continue
|
|
2382
|
+
# The container arm. Checked after readiness, so it adds a
|
|
2383
|
+
# compliant filing where none existed and takes none away where one
|
|
2384
|
+
# already did. `text` joins the scan only for a creation found
|
|
2385
|
+
# INSIDE a file, where the declaration lives in the same file as
|
|
2386
|
+
# the create it answers for.
|
|
2387
|
+
if declares_container(
|
|
2388
|
+
args, (submitted, text) if from_file else (submitted,)
|
|
2389
|
+
):
|
|
2390
|
+
continue
|
|
2391
|
+
# `LIFECYCLE_ROLE=ready curl …` puts the declaration BEFORE the
|
|
2392
|
+
# client, which is where an inline assignment has to go, so the
|
|
2393
|
+
# role is read from the whole segment rather than from the client's
|
|
2394
|
+
# own arguments. Scoped to the segment and not the command, so a
|
|
2395
|
+
# declaration cannot be shouted from an unrelated pipeline stage.
|
|
2396
|
+
if state_role_ok and LIFECYCLE_ROLE_READY.search(" ".join(argv)):
|
|
2397
|
+
continue
|
|
2398
|
+
# A script declares once, for itself. See `scope_declaration`.
|
|
2399
|
+
if from_file and scope_declaration(text, state_role_ok):
|
|
2400
|
+
continue
|
|
2401
|
+
return signature, roles, target
|
|
2402
|
+
|
|
2403
|
+
for operand in nested_operands(argv):
|
|
2404
|
+
if depth >= MAX_NESTING_DEPTH:
|
|
2405
|
+
# Refuse at the bound rather than skipping past it. Skipping
|
|
2406
|
+
# made a creation inside a 4th `bash -c` layer pass, which is
|
|
2407
|
+
# the depth cap being used as the bypass.
|
|
2408
|
+
if UNPARSEABLE_CREATION.search(operand):
|
|
2409
|
+
return (
|
|
2410
|
+
"a tracker creation nested past the inspection depth",
|
|
2411
|
+
[ready_role],
|
|
2412
|
+
None,
|
|
2413
|
+
)
|
|
2414
|
+
continue
|
|
2415
|
+
nested = scan(operand, depth + 1)
|
|
2416
|
+
if nested is not None:
|
|
2417
|
+
return nested
|
|
2418
|
+
|
|
2419
|
+
# The locate step, which used to stop at `bash` and never reach the
|
|
2420
|
+
# operand. Deliberately last: an inline creation is the cheaper and
|
|
2421
|
+
# more precise finding, so it is reported before a file is opened.
|
|
2422
|
+
# The operator's ambient override is checked BEFORE the depth bound,
|
|
2423
|
+
# not after it. The other order meant a creation reached past the cap
|
|
2424
|
+
# was refused even with `LISA_ALLOW_DIRECT_ISSUE_CREATE=1` exported —
|
|
2425
|
+
# while the refusal text advertised that escape. An escape hatch the
|
|
2426
|
+
# refusal names and the code ignores is worse than none. Caught by
|
|
2427
|
+
# review before it shipped.
|
|
2428
|
+
if ambient_override and not inline_override:
|
|
2429
|
+
continue
|
|
2430
|
+
for path in file_operands(argv):
|
|
2431
|
+
contents = read_operand(path)
|
|
2432
|
+
if not contents:
|
|
2433
|
+
continue
|
|
2434
|
+
if depth >= MAX_NESTING_DEPTH:
|
|
2435
|
+
if GRAPHQL_CREATE.search(contents) or UNPARSEABLE_CREATION.search(
|
|
2436
|
+
contents
|
|
2437
|
+
):
|
|
2438
|
+
return (
|
|
2439
|
+
"a tracker creation inside %s, nested past the "
|
|
2440
|
+
"inspection depth" % path,
|
|
2441
|
+
[ready_role],
|
|
2442
|
+
None,
|
|
2443
|
+
)
|
|
2444
|
+
continue
|
|
2445
|
+
found = file_creation(contents, depth)
|
|
2446
|
+
if found is not None:
|
|
2447
|
+
return ("%s inside %s" % (found[0], path), found[1], found[2])
|
|
2448
|
+
return None
|
|
2449
|
+
|
|
2450
|
+
|
|
2451
|
+
# The inline-override check runs over the WHOLE raw command text, not over
|
|
2452
|
+
# parsed tokens, so it catches `X=1 gh …`, `env X=1 gh …`, `export X=1 && gh …`,
|
|
2453
|
+
# and the same forms buried inside a nested `bash -c '…'` string alike. Any
|
|
2454
|
+
# appearance of the assignment disqualifies the ambient override: this is the
|
|
2455
|
+
# one place the guard deliberately over-matches, because a false positive costs
|
|
2456
|
+
# a human one retry and a false negative costs the entire control.
|
|
2457
|
+
inline_override = (OVERRIDE_NAME + "=") in command
|
|
2458
|
+
|
|
2459
|
+
|
|
2460
|
+
def remedy_for(signature):
|
|
2461
|
+
"""Which extra remediation paragraph this refusal earns.
|
|
2462
|
+
|
|
2463
|
+
A guard that prints every remedy it knows prints one that does not apply,
|
|
2464
|
+
and a remedy that cannot be performed is the failure this ticket is about
|
|
2465
|
+
(CodySwannGT/lisa#3683): the file-payload paragraph told an author to drop
|
|
2466
|
+
a transmitting primitive that a DOCUMENT does not have, and the
|
|
2467
|
+
unparseable arm printed declaration advice for a command that files
|
|
2468
|
+
nothing. Both are now keyed off which branch actually fired.
|
|
2469
|
+
|
|
2470
|
+
Args:
|
|
2471
|
+
signature: The refusal signature.
|
|
2472
|
+
|
|
2473
|
+
Returns:
|
|
2474
|
+
A key the shell half maps to a paragraph, or an empty string.
|
|
2475
|
+
"""
|
|
2476
|
+
if signature.startswith("an unparseable command"):
|
|
2477
|
+
return "unparseable"
|
|
2478
|
+
if " inside " in signature:
|
|
2479
|
+
return "file"
|
|
2480
|
+
return ""
|
|
2481
|
+
|
|
2482
|
+
|
|
2483
|
+
found = scan(command, 0)
|
|
2484
|
+
if found is not None:
|
|
2485
|
+
signature, roles, target = found
|
|
2486
|
+
# Key=value lines rather than one delimited string: a signature contains
|
|
2487
|
+
# spaces and slashes, and a role may contain a colon, so anything the shell
|
|
2488
|
+
# would have to split on appears inside a value already.
|
|
2489
|
+
print("REFUSE")
|
|
2490
|
+
print("signature=%s" % signature)
|
|
2491
|
+
print("roles=%s" % ", ".join(role for role in roles if role))
|
|
2492
|
+
print("target=%s" % (target or ""))
|
|
2493
|
+
print("remedy=%s" % remedy_for(signature))
|
|
2494
|
+
sys.exit(0)
|
|
2495
|
+
|
|
2496
|
+
print("ALLOW")
|
|
2497
|
+
|
|
2498
|
+
PY
|
|
2499
|
+
|
|
2500
|
+
set +e
|
|
2501
|
+
# ── The structured substrate ──────────────────────────────────────────────
|
|
2502
|
+
#
|
|
2503
|
+
# An MCP call carries named fields, not a command line, so `scan()` — which
|
|
2504
|
+
# tokenises shell text — has nothing to parse. The declaration is read from the
|
|
2505
|
+
# payload instead, and the recogniser is deliberately small: this path decides
|
|
2506
|
+
# ONE question (is a build-ready role or a human-gate marker present anywhere
|
|
2507
|
+
# in the submitted fields), where the shell path has to answer "which of these
|
|
2508
|
+
# tokens is a client, an endpoint, a flag value, or a file it executes".
|
|
2509
|
+
#
|
|
2510
|
+
# Every string in the payload is searched, at any depth, because a role lands
|
|
2511
|
+
# in a different field on every tracker — `labels[]` on GitHub, `stateId` or a
|
|
2512
|
+
# workflow state name on Linear, a transition `id` on JIRA — and enumerating
|
|
2513
|
+
# field names per vendor is the same brittleness as enumerating tool names.
|
|
2514
|
+
# Over-collecting is safe here: the values are compared against ONE configured
|
|
2515
|
+
# role string, so an unrelated field cannot accidentally satisfy it.
|
|
2516
|
+
if [ -n "$structured_call" ]; then
|
|
2517
|
+
# The operator's ambient escape works on both substrates. There is no inline
|
|
2518
|
+
# form to disqualify it here — a structured call has no shell in which to
|
|
2519
|
+
# assign one — so the override is simply honoured.
|
|
2520
|
+
if [ -n "$ambient_override" ]; then
|
|
2521
|
+
exit 0
|
|
2522
|
+
fi
|
|
2523
|
+
|
|
2524
|
+
# A PACKED label string counts. Exact equality against the flattened value
|
|
2525
|
+
# list reads `labels: ["status:ready"]` and nothing else, but the same
|
|
2526
|
+
# compliant filing spelled `labels: "status:ready,type:Bug"` — the shape
|
|
2527
|
+
# `gh issue create --label` takes, and the shape several MCP servers pass
|
|
2528
|
+
# through verbatim — carries no value equal to the role, so the guard
|
|
2529
|
+
# refused a filing that had declared exactly what it demanded. A false
|
|
2530
|
+
# positive in a guard costs more than a miss: it teaches the operator that
|
|
2531
|
+
# the guard is wrong, and the next refusal is argued with rather than
|
|
2532
|
+
# obeyed.
|
|
2533
|
+
#
|
|
2534
|
+
# Split on the DELIMITERS a packed list uses (comma, semicolon, newline) and
|
|
2535
|
+
# trimmed, never on a `contains` match. `contains` would accept a body that
|
|
2536
|
+
# merely mentions the role in prose — "do not mark this status:ready" — and
|
|
2537
|
+
# that is a fail-open on the one question this path exists to answer.
|
|
2538
|
+
structured_declaration="$(
|
|
2539
|
+
printf '%s' "$input" |
|
|
2540
|
+
jq -r --arg role "$ready_role" '
|
|
2541
|
+
[(.tool_input // {}) | .. | strings] as $values
|
|
2542
|
+
| ($values + ($values
|
|
2543
|
+
| map(splits("[,;\n]"))
|
|
2544
|
+
| map(sub("^\\s+"; "") | sub("\\s+$"; "")))) as $atoms
|
|
2545
|
+
| if ($atoms | index($role)) then "role"
|
|
2546
|
+
elif ($values | map(select(contains("[lisa-human-gate]"))) | length) > 0 then "gate"
|
|
2547
|
+
else "" end
|
|
2548
|
+
' 2>/dev/null || printf 'UNREADABLE'
|
|
2549
|
+
)"
|
|
2550
|
+
|
|
2551
|
+
# Fail closed on a creation-shaped call whose payload cannot be read. Silence
|
|
2552
|
+
# about a filing the guard could not inspect is the defect this whole ticket
|
|
2553
|
+
# is about, one substrate over.
|
|
2554
|
+
if [ "$structured_declaration" = "UNREADABLE" ]; then
|
|
2555
|
+
refuse "an unreadable $tool_name payload that reads as a tracker creation" \
|
|
2556
|
+
"$ready_role" ""
|
|
2557
|
+
fi
|
|
2558
|
+
|
|
2559
|
+
if [ -z "$structured_declaration" ]; then
|
|
2560
|
+
refuse "a tracker creation through $tool_name" "$ready_role" ""
|
|
2561
|
+
fi
|
|
2562
|
+
exit 0
|
|
2563
|
+
fi
|
|
2564
|
+
|
|
2565
|
+
verdict="$(
|
|
2566
|
+
printf '%s' "$classifier" |
|
|
2567
|
+
LISA_GUARD_COMMAND="$command_str" \
|
|
2568
|
+
LISA_GUARD_READY_ROLE="$ready_role" \
|
|
2569
|
+
LISA_GUARD_AMBIENT_OVERRIDE="$ambient_override" \
|
|
2570
|
+
LISA_GUARD_DEFAULT_READY_ROLE="$default_ready_role" \
|
|
2571
|
+
LISA_GUARD_OWN_REPO="$own_repo" \
|
|
2572
|
+
LISA_GUARD_UPSTREAM_REPO="$upstream_repo" \
|
|
2573
|
+
LISA_GUARD_UPSTREAM_READY_ROLE="$upstream_ready_role" \
|
|
2574
|
+
LISA_GUARD_CALLER_IS_GITHUB="$caller_is_github" \
|
|
2575
|
+
LISA_GUARD_TRACKER="$tracker" \
|
|
2576
|
+
LISA_GUARD_PROJECT_DIR="$project_dir" \
|
|
2577
|
+
python3 -
|
|
2578
|
+
)"
|
|
2579
|
+
python_status=$?
|
|
2580
|
+
set -e
|
|
2581
|
+
|
|
2582
|
+
# A crashed classifier must not be read as "allow" without saying so.
|
|
2583
|
+
if [ "$python_status" -ne 0 ]; then
|
|
2584
|
+
printf 'block-direct-issue-create: classifier failed (exit %s); enforcement is NOT active for this call\n' \
|
|
2585
|
+
"$python_status" >&2
|
|
2586
|
+
exit 0
|
|
2587
|
+
fi
|
|
2588
|
+
|
|
2589
|
+
verdict_field() {
|
|
2590
|
+
printf '%s\n' "$verdict" | sed -n "s/^$1=//p" | head -1
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
case "$verdict" in
|
|
2594
|
+
REFUSE*)
|
|
2595
|
+
refuse \
|
|
2596
|
+
"$(verdict_field signature)" \
|
|
2597
|
+
"$(verdict_field roles)" \
|
|
2598
|
+
"$(verdict_field target)" \
|
|
2599
|
+
"$(verdict_field remedy)"
|
|
2600
|
+
;;
|
|
2601
|
+
esac
|
|
2602
|
+
|
|
2603
|
+
exit 0
|