@soleur/opencode 0.0.0-stage → 1.0.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/agents.manifest.json +476 -0
- package/.claude-plugin/plugin.json +42 -0
- package/README.md +44 -2
- package/agents/community/.gitkeep +0 -0
- package/agents/engineering/cto.md +87 -0
- package/agents/engineering/design/ddd-architect.md +95 -0
- package/agents/engineering/discovery/agent-finder.md +214 -0
- package/agents/engineering/discovery/functional-discovery.md +182 -0
- package/agents/engineering/infra/infra-security.md +151 -0
- package/agents/engineering/infra/platform-strategist.md +109 -0
- package/agents/engineering/infra/terraform-architect.md +171 -0
- package/agents/engineering/prompt-engineer.md +37 -0
- package/agents/engineering/research/best-practices-researcher.md +115 -0
- package/agents/engineering/research/framework-docs-researcher.md +93 -0
- package/agents/engineering/research/git-history-analyzer.md +53 -0
- package/agents/engineering/research/learnings-researcher.md +285 -0
- package/agents/engineering/research/repo-research-analyst.md +118 -0
- package/agents/engineering/review/agent-native-reviewer.md +279 -0
- package/agents/engineering/review/architecture-strategist.md +61 -0
- package/agents/engineering/review/code-quality-analyst.md +92 -0
- package/agents/engineering/review/code-simplicity-reviewer.md +109 -0
- package/agents/engineering/review/data-integrity-guardian.md +82 -0
- package/agents/engineering/review/data-migration-expert.md +98 -0
- package/agents/engineering/review/deployment-verification-agent.md +183 -0
- package/agents/engineering/review/dhh-rails-reviewer.md +46 -0
- package/agents/engineering/review/kieran-rails-reviewer.md +87 -0
- package/agents/engineering/review/legacy-code-expert.md +55 -0
- package/agents/engineering/review/observability-coverage-reviewer.md +147 -0
- package/agents/engineering/review/pattern-recognition-specialist.md +59 -0
- package/agents/engineering/review/performance-oracle.md +130 -0
- package/agents/engineering/review/security-sentinel.md +167 -0
- package/agents/engineering/review/semgrep-sast.md +36 -0
- package/agents/engineering/review/test-design-reviewer.md +150 -0
- package/agents/engineering/review/user-impact-reviewer.md +70 -0
- package/agents/engineering/workflow/pr-comment-resolver.md +69 -0
- package/agents/finance/budget-analyst.md +29 -0
- package/agents/finance/cfo.md +44 -0
- package/agents/finance/financial-reporter.md +30 -0
- package/agents/finance/revenue-analyst.md +30 -0
- package/agents/legal/clo.md +115 -0
- package/agents/legal/legal-compliance-auditor.md +110 -0
- package/agents/legal/legal-document-generator.md +134 -0
- package/agents/marketing/analytics-analyst.md +25 -0
- package/agents/marketing/brand-architect.md +181 -0
- package/agents/marketing/cmo.md +76 -0
- package/agents/marketing/conversion-optimizer.md +19 -0
- package/agents/marketing/copywriter.md +20 -0
- package/agents/marketing/fact-checker.md +70 -0
- package/agents/marketing/growth-strategist.md +145 -0
- package/agents/marketing/paid-media-strategist.md +28 -0
- package/agents/marketing/pricing-strategist.md +17 -0
- package/agents/marketing/programmatic-seo-specialist.md +25 -0
- package/agents/marketing/retention-strategist.md +23 -0
- package/agents/marketing/seo-aeo-analyst.md +164 -0
- package/agents/operations/coo.md +50 -0
- package/agents/operations/ops-advisor.md +78 -0
- package/agents/operations/ops-provisioner.md +101 -0
- package/agents/operations/ops-research.md +52 -0
- package/agents/operations/service-automator.md +278 -0
- package/agents/product/business-validator.md +206 -0
- package/agents/product/competitive-intelligence.md +89 -0
- package/agents/product/cpo.md +63 -0
- package/agents/product/design/ux-design-lead.md +209 -0
- package/agents/product/spec-flow-analyzer.md +122 -0
- package/agents/sales/cro.md +44 -0
- package/agents/sales/deal-architect.md +29 -0
- package/agents/sales/outbound-strategist.md +29 -0
- package/agents/sales/pipeline-analyst.md +29 -0
- package/agents/support/cco.md +48 -0
- package/agents/support/community-manager.md +570 -0
- package/agents/support/ticket-triage.md +70 -0
- package/bin/soleur-opencode.js +29 -0
- package/commands/go.md +659 -0
- package/commands/help.md +273 -0
- package/commands/sync.md +977 -0
- package/commands/version.md +103 -0
- package/hooks/antigravity-hooks.json +84 -0
- package/hooks/antigravity-session-start.sh +15 -0
- package/hooks/bare-root-cwd-guard.sh +131 -0
- package/hooks/browser-snapshot-credential-guard.sh +200 -0
- package/hooks/codex-session-start.sh +9 -0
- package/hooks/compaction-state.sh +525 -0
- package/hooks/destructive-command-guard.sh +1387 -0
- package/hooks/devin-session-start.sh +90 -0
- package/hooks/ext-filter-detect.sh +171 -0
- package/hooks/hooks.json +133 -0
- package/hooks/lib/hook-tool-kind.sh +24 -0
- package/hooks/lib/log-rotation.sh +176 -0
- package/hooks/lib/shell-argv.pl +576 -0
- package/hooks/memory-backstop.sh +1114 -0
- package/hooks/operator-stage-approval.sh +391 -0
- package/hooks/stop-hook.sh +367 -0
- package/hooks/unkept-promise-hook.sh +383 -0
- package/hooks/version-banner.sh +127 -0
- package/hooks/welcome-hook.sh +50 -0
- package/install.js +108 -0
- package/lib/agent-registry.ts +154 -0
- package/lib/c4-canonical-cli.mjs +34 -0
- package/lib/c4-canonical.mjs +46 -0
- package/lib/c4-from-components.ts +554 -0
- package/lib/go-routing.ts +81 -0
- package/lib/grok-inspect-contract.ts +311 -0
- package/lib/harness-model-map.ts +101 -0
- package/lib/harness-parity.ts +726 -0
- package/lib/harness-tool-map.ts +180 -0
- package/lib/harness.ts +716 -0
- package/lib/heartbeat-live-reconcile.ts +1153 -0
- package/lib/heartbeat-manifest.ts +342 -0
- package/lib/kb-coverage.ts +252 -0
- package/lib/pr-merge-poll.ts +103 -0
- package/lib/workflow-fidelity.ts +388 -0
- package/opencode/INSTRUCTIONS.md +152 -0
- package/opencode/agents/engineering/cto.md +10 -0
- package/opencode/agents/engineering/design/ddd-architect.md +10 -0
- package/opencode/agents/engineering/discovery/agent-finder.md +10 -0
- package/opencode/agents/engineering/discovery/functional-discovery.md +10 -0
- package/opencode/agents/engineering/infra/infra-security.md +10 -0
- package/opencode/agents/engineering/infra/platform-strategist.md +10 -0
- package/opencode/agents/engineering/infra/terraform-architect.md +10 -0
- package/opencode/agents/engineering/prompt-engineer.md +10 -0
- package/opencode/agents/engineering/research/best-practices-researcher.md +10 -0
- package/opencode/agents/engineering/research/framework-docs-researcher.md +10 -0
- package/opencode/agents/engineering/research/git-history-analyzer.md +10 -0
- package/opencode/agents/engineering/research/learnings-researcher.md +10 -0
- package/opencode/agents/engineering/research/repo-research-analyst.md +10 -0
- package/opencode/agents/engineering/review/agent-native-reviewer.md +10 -0
- package/opencode/agents/engineering/review/architecture-strategist.md +10 -0
- package/opencode/agents/engineering/review/code-quality-analyst.md +10 -0
- package/opencode/agents/engineering/review/code-simplicity-reviewer.md +10 -0
- package/opencode/agents/engineering/review/data-integrity-guardian.md +10 -0
- package/opencode/agents/engineering/review/data-migration-expert.md +10 -0
- package/opencode/agents/engineering/review/deployment-verification-agent.md +10 -0
- package/opencode/agents/engineering/review/dhh-rails-reviewer.md +10 -0
- package/opencode/agents/engineering/review/kieran-rails-reviewer.md +10 -0
- package/opencode/agents/engineering/review/legacy-code-expert.md +10 -0
- package/opencode/agents/engineering/review/observability-coverage-reviewer.md +10 -0
- package/opencode/agents/engineering/review/pattern-recognition-specialist.md +10 -0
- package/opencode/agents/engineering/review/performance-oracle.md +10 -0
- package/opencode/agents/engineering/review/security-sentinel.md +10 -0
- package/opencode/agents/engineering/review/semgrep-sast.md +10 -0
- package/opencode/agents/engineering/review/test-design-reviewer.md +10 -0
- package/opencode/agents/engineering/review/user-impact-reviewer.md +10 -0
- package/opencode/agents/engineering/workflow/pr-comment-resolver.md +10 -0
- package/opencode/agents/finance/budget-analyst.md +10 -0
- package/opencode/agents/finance/cfo.md +10 -0
- package/opencode/agents/finance/financial-reporter.md +10 -0
- package/opencode/agents/finance/revenue-analyst.md +10 -0
- package/opencode/agents/legal/clo.md +10 -0
- package/opencode/agents/legal/legal-compliance-auditor.md +10 -0
- package/opencode/agents/legal/legal-document-generator.md +10 -0
- package/opencode/agents/marketing/analytics-analyst.md +10 -0
- package/opencode/agents/marketing/brand-architect.md +10 -0
- package/opencode/agents/marketing/cmo.md +10 -0
- package/opencode/agents/marketing/conversion-optimizer.md +10 -0
- package/opencode/agents/marketing/copywriter.md +10 -0
- package/opencode/agents/marketing/fact-checker.md +10 -0
- package/opencode/agents/marketing/growth-strategist.md +10 -0
- package/opencode/agents/marketing/paid-media-strategist.md +10 -0
- package/opencode/agents/marketing/pricing-strategist.md +10 -0
- package/opencode/agents/marketing/programmatic-seo-specialist.md +10 -0
- package/opencode/agents/marketing/retention-strategist.md +10 -0
- package/opencode/agents/marketing/seo-aeo-analyst.md +10 -0
- package/opencode/agents/operations/coo.md +10 -0
- package/opencode/agents/operations/ops-advisor.md +10 -0
- package/opencode/agents/operations/ops-provisioner.md +10 -0
- package/opencode/agents/operations/ops-research.md +10 -0
- package/opencode/agents/operations/service-automator.md +10 -0
- package/opencode/agents/product/business-validator.md +10 -0
- package/opencode/agents/product/competitive-intelligence.md +10 -0
- package/opencode/agents/product/cpo.md +10 -0
- package/opencode/agents/product/design/ux-design-lead.md +10 -0
- package/opencode/agents/product/spec-flow-analyzer.md +10 -0
- package/opencode/agents/sales/cro.md +10 -0
- package/opencode/agents/sales/deal-architect.md +10 -0
- package/opencode/agents/sales/outbound-strategist.md +10 -0
- package/opencode/agents/sales/pipeline-analyst.md +10 -0
- package/opencode/agents/support/cco.md +10 -0
- package/opencode/agents/support/community-manager.md +10 -0
- package/opencode/agents/support/ticket-triage.md +10 -0
- package/opencode/commands/go.md +10 -0
- package/opencode/commands/help.md +10 -0
- package/opencode/commands/sync.md +10 -0
- package/opencode/commands/version.md +11 -0
- package/opencode/opencode.json +8 -0
- package/opencode/plugin.ts +493 -0
- package/package.json +36 -4
- package/scripts/admin-merge-ready.sh +545 -0
- package/scripts/admin-merge-ready.test.sh +753 -0
- package/scripts/affected-tests.sh +908 -0
- package/scripts/affected-tests.test.sh +1797 -0
- package/scripts/alpha-metrics.sh +78 -0
- package/scripts/alpha-metrics.test.sh +115 -0
- package/scripts/ancestor-signal-helpers.baseline.txt +16 -0
- package/scripts/audit-flag-flip.sh +107 -0
- package/scripts/check-red-on-main.sh +316 -0
- package/scripts/ci-head-verdict.sh +363 -0
- package/scripts/cloud-detect.sh +193 -0
- package/scripts/delegation-verify.sh +657 -0
- package/scripts/delegation-verify.test.sh +1424 -0
- package/scripts/deploy-arm.sh +475 -0
- package/scripts/domain-model-drift.sh +419 -0
- package/scripts/emit-decision.sh +144 -0
- package/scripts/emit-decision.test.sh +144 -0
- package/scripts/ext-filter-divergence-corpus.txt +15 -0
- package/scripts/ext-filter-divergence.sh +189 -0
- package/scripts/generate-c4-from-components.ts +442 -0
- package/scripts/grok-fidelity-gate.sh +68 -0
- package/scripts/grok-pre-push-gate.sh +232 -0
- package/scripts/harness-discovery-smoke.ts +906 -0
- package/scripts/harness-parity-census.ts +92 -0
- package/scripts/inngest-luks-alert-armed-probe.ts +29 -0
- package/scripts/install-opencode-adapter.ts +520 -0
- package/scripts/lib/affected-tests/common.sh +365 -0
- package/scripts/lib/affected-tests/go.sh +157 -0
- package/scripts/lib/affected-tests/js.sh +188 -0
- package/scripts/lib/affected-tests/nx-turbo.sh +98 -0
- package/scripts/lib/affected-tests/python.sh +185 -0
- package/scripts/lib/context-sanitize.sh +49 -0
- package/scripts/lib/domain-model-lib.sh +180 -0
- package/scripts/lib/ext-filter-grammar.tsv +34 -0
- package/scripts/lib/operator-script.sh +1236 -0
- package/scripts/lib/proc.sh +580 -0
- package/scripts/lib/session-state.sh +748 -0
- package/scripts/lib/tmp-classify.sh +843 -0
- package/scripts/lib/wrapper-boundary.sh +47 -0
- package/scripts/lint-ratchet.py +1547 -0
- package/scripts/monitor-pr-checks.sh +441 -0
- package/scripts/pipeline-tally.sh +756 -0
- package/scripts/pipeline-tally.test.sh +740 -0
- package/scripts/plugin-version.sh +528 -0
- package/scripts/precommit-guard.sh +113 -0
- package/scripts/precommit-guard.test.sh +254 -0
- package/scripts/reconcile-live-heartbeats.ts +705 -0
- package/scripts/render-c4-model.sh +217 -0
- package/scripts/resolve-git-root.sh +55 -0
- package/scripts/resolve-regenerable-conflicts.sh +363 -0
- package/scripts/resolve-regenerable-conflicts.test.sh +780 -0
- package/scripts/ruff-requirements.txt +22 -0
- package/scripts/run-in-pid-namespace.sh +98 -0
- package/scripts/run-in-pid-namespace.test.sh +290 -0
- package/scripts/scan-ancestor-signal-helpers.py +556 -0
- package/scripts/scan-ancestor-signal-helpers.test.sh +670 -0
- package/scripts/setup-ralph-loop.sh +238 -0
- package/scripts/sync-cursor-name-map.ts +316 -0
- package/scripts/sync-grok-agent-compat.ts +188 -0
- package/scripts/sync-npm-package.ts +178 -0
- package/scripts/sync-opencode-adapter.ts +479 -0
- package/scripts/sync-pr-behind.sh +534 -0
- package/scripts/sync-pr-behind.test.sh +252 -0
- package/scripts/taste-profile-update.sh +180 -0
- package/scripts/taste-profile-update.test.sh +167 -0
- package/scripts/write-budget-marker.sh +23 -0
- package/scripts/write-kb-coverage.ts +113 -0
- package/skills/admin-ip-refresh/SKILL.md +81 -0
- package/skills/admin-ip-refresh/references/admin-ip-refresh-procedure.md +201 -0
- package/skills/agent-browser/SKILL.md +698 -0
- package/skills/agent-browser/scripts/playwright-mcp-profile-slot.sh +304 -0
- package/skills/agent-browser/scripts/playwright-mcp-redact-proxy.py +964 -0
- package/skills/agent-browser/scripts/redact-a11y-snapshot.py +443 -0
- package/skills/agent-browser/test/fixtures/capture-playwright-mcp-fixtures.py +217 -0
- package/skills/agent-browser/test/fixtures/fake-passthrough-proxy.py +24 -0
- package/skills/agent-browser/test/fixtures/fake-playwright-mcp.py +131 -0
- package/skills/agent-browser/test/fixtures/make-proxy-mutant.py +48 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/dialog-link-injection.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/empty-content.json +5 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-and-result.json +4 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-code-bool.json +6 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-code-string.json +6 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-message-tree.json +6 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-no-data.json +6 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-string.json +4 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/error-with-data.json +9 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/evaluate-escaped-object.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/invalid-utf8.json +4 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/link-result.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/list-two-results.json +6 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/meta-in-result.json +13 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/no-content.json +5 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/quoted-key.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/resource-block.json +18 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/result-and-method.json +11 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/result-string.json +3 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/run-code-escaped-one-row.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/run-code-escaped.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/structured-content.json +13 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/tools-list-no-tools.json +5 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/tree-no-credential.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/two-text-blocks.json +14 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/unparsable-line.json +3 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/value-contains-brace.json +10 -0
- package/skills/agent-browser/test/fixtures/odd-shapes/value-contains-link-substring.json +10 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/close.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/evaluate.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/find.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/framing.json +6 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/initialize.json +14 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/navigate-link.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/navigate.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/roots-list-request.json +5 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/screenshot.json +17 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-filename.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-meta-json.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot-meta-raw.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/snapshot.json +12 -0
- package/skills/agent-browser/test/fixtures/playwright-mcp-0.0.78/tools-list.json +827 -0
- package/skills/agent-browser/test/fixtures/proxy-session-driver.py +138 -0
- package/skills/agent-browser/test/fixtures/session-facts.py +102 -0
- package/skills/agent-browser/test/playwright-mcp-lifetime.test.sh +1620 -0
- package/skills/agent-browser/test/playwright-mcp-redact-proxy.test.sh +1599 -0
- package/skills/agent-browser/test/redact-a11y-snapshot.test.sh +527 -0
- package/skills/agent-native-architecture/SKILL.md +479 -0
- package/skills/agent-native-architecture/references/action-parity-discipline.md +430 -0
- package/skills/agent-native-architecture/references/agent-execution-patterns.md +483 -0
- package/skills/agent-native-architecture/references/agent-native-testing.md +605 -0
- package/skills/agent-native-architecture/references/architecture-patterns.md +493 -0
- package/skills/agent-native-architecture/references/dynamic-context-injection.md +358 -0
- package/skills/agent-native-architecture/references/files-universal-interface.md +314 -0
- package/skills/agent-native-architecture/references/from-primitives-to-domain-tools.md +381 -0
- package/skills/agent-native-architecture/references/mcp-tool-design.md +524 -0
- package/skills/agent-native-architecture/references/mobile-patterns.md +897 -0
- package/skills/agent-native-architecture/references/product-implications.md +478 -0
- package/skills/agent-native-architecture/references/refactoring-to-prompt-native.md +342 -0
- package/skills/agent-native-architecture/references/self-modification.md +294 -0
- package/skills/agent-native-architecture/references/shared-workspace-architecture.md +700 -0
- package/skills/agent-native-architecture/references/system-prompt-design.md +263 -0
- package/skills/agent-native-audit/SKILL.md +300 -0
- package/skills/agent-native-audit/workflows/agent-native-audit.workflow.js +423 -0
- package/skills/andrew-kane-gem-writer/SKILL.md +189 -0
- package/skills/andrew-kane-gem-writer/references/database-adapters.md +231 -0
- package/skills/andrew-kane-gem-writer/references/module-organization.md +121 -0
- package/skills/andrew-kane-gem-writer/references/rails-integration.md +183 -0
- package/skills/andrew-kane-gem-writer/references/resources.md +126 -0
- package/skills/andrew-kane-gem-writer/references/testing-patterns.md +261 -0
- package/skills/architecture/SKILL.md +436 -0
- package/skills/architecture/references/adr-template.md +154 -0
- package/skills/architecture/references/likec4-reference.md +136 -0
- package/skills/architecture/references/nfr-reference.md +126 -0
- package/skills/archive-kb/SKILL.md +86 -0
- package/skills/archive-kb/scripts/archive-kb.sh +300 -0
- package/skills/archive-kb/test/archive-kb-partial-run.test.sh +276 -0
- package/skills/atdd-developer/SKILL.md +65 -0
- package/skills/brainstorm/SKILL.md +701 -0
- package/skills/brainstorm/references/brainstorm-brand-workshop.md +153 -0
- package/skills/brainstorm/references/brainstorm-domain-config.md +63 -0
- package/skills/brainstorm/references/brainstorm-validation-workshop.md +65 -0
- package/skills/brainstorm/references/ui-surface-terms.md +42 -0
- package/skills/brainstorm-techniques/SKILL.md +231 -0
- package/skills/brainstorm-techniques/references/decision-principles.md +150 -0
- package/skills/brainstorm-techniques/references/phase-boundaries.md +103 -0
- package/skills/brainstorm-techniques/references/standing-delegation.md +407 -0
- package/skills/brainstorm-techniques/references/technical-ruling-delegation.md +174 -0
- package/skills/campaign-calendar/SKILL.md +145 -0
- package/skills/cf-token-scope/SKILL.md +164 -0
- package/skills/cf-token-scope/references/widen-playbook.md +114 -0
- package/skills/cf-token-scope/scripts/cf-token-scope.sh +253 -0
- package/skills/changelog/SKILL.md +142 -0
- package/skills/code-to-prd/SKILL.md +66 -0
- package/skills/code-to-prd/references/banner-template.md +12 -0
- package/skills/code-to-prd/references/prd-template.md +41 -0
- package/skills/code-to-prd/scripts/code-to-prd.sh +634 -0
- package/skills/code-to-prd/test/code-to-prd.test.sh +447 -0
- package/skills/code-to-prd/test/fixture/.env.example +7 -0
- package/skills/code-to-prd/test/fixture/app/about/page.tsx +11 -0
- package/skills/code-to-prd/test/fixture/app/api/health/route.ts +10 -0
- package/skills/code-to-prd/test/fixture/app/page.tsx +14 -0
- package/skills/code-to-prd/test/fixture/next.config.mjs +3 -0
- package/skills/code-to-prd/test/fixture/package.json +6 -0
- package/skills/cohort-status/SKILL.md +62 -0
- package/skills/community/SKILL.md +210 -0
- package/skills/community/scripts/bsky-community.sh +644 -0
- package/skills/community/scripts/bsky-setup.sh +631 -0
- package/skills/community/scripts/community-router.sh +93 -0
- package/skills/community/scripts/discord-community.sh +487 -0
- package/skills/community/scripts/discord-setup.sh +784 -0
- package/skills/community/scripts/github-community.sh +706 -0
- package/skills/community/scripts/hn-community.sh +233 -0
- package/skills/community/scripts/lib/hmac-sha1-b64.sh +126 -0
- package/skills/community/scripts/linkedin-community.sh +750 -0
- package/skills/community/scripts/linkedin-setup.sh +758 -0
- package/skills/community/scripts/x-community.sh +890 -0
- package/skills/community/scripts/x-setup.sh +713 -0
- package/skills/community/test/community-argv.test.sh +2922 -0
- package/skills/community/test/github-community.test.sh +981 -0
- package/skills/competitive-analysis/SKILL.md +69 -0
- package/skills/competitive-analysis/references/peer-plugin-audit.md +218 -0
- package/skills/compound/SKILL.md +673 -0
- package/skills/compound/scripts/token-efficiency-report.sh +334 -0
- package/skills/compound/test/phase-16.test.sh +379 -0
- package/skills/compound-capture/SKILL.md +791 -0
- package/skills/compound-capture/assets/critical-pattern-template.md +37 -0
- package/skills/compound-capture/assets/resolution-template.md +116 -0
- package/skills/compound-capture/references/yaml-schema.md +67 -0
- package/skills/compound-capture/schema.yaml +181 -0
- package/skills/constraint-scaffold/SKILL.md +175 -0
- package/skills/constraint-scaffold/references/boundary-readme.template +35 -0
- package/skills/constraint-scaffold/references/constraint-gates-workflow.template +69 -0
- package/skills/constraint-scaffold/references/depcruise-config.template +230 -0
- package/skills/constraint-scaffold/references/fix-constraints-stage-a.template +240 -0
- package/skills/constraint-scaffold/references/fix-constraints-stage-b.template +313 -0
- package/skills/constraint-scaffold/references/shared-runner.template +135 -0
- package/skills/constraint-scaffold/scripts/constraint-scaffold.sh +589 -0
- package/skills/constraint-scaffold/test/bite-proof.test.sh +1338 -0
- package/skills/constraint-scaffold/test/boundary.test.sh +734 -0
- package/skills/constraint-scaffold/test/emit-fix-constraints.test.sh +193 -0
- package/skills/constraint-scaffold/test/generator.test.sh +93 -0
- package/skills/constraint-scaffold/test/parity.test.sh +285 -0
- package/skills/content-writer/SKILL.md +242 -0
- package/skills/content-writer/scripts/blog-jargon-scan.sh +42 -0
- package/skills/cron-delete/SKILL.md +56 -0
- package/skills/cron-list/SKILL.md +54 -0
- package/skills/deepen-plan/SKILL.md +937 -0
- package/skills/deepen-plan/workflows/deepen-plan.workflow.js +488 -0
- package/skills/deploy/SKILL.md +115 -0
- package/skills/deploy/references/hetzner-setup.md +65 -0
- package/skills/deploy/scripts/deploy.sh +37 -0
- package/skills/deploy-docs/SKILL.md +95 -0
- package/skills/dhh-rails-style/SKILL.md +203 -0
- package/skills/dhh-rails-style/references/architecture.md +717 -0
- package/skills/dhh-rails-style/references/controllers.md +323 -0
- package/skills/dhh-rails-style/references/frontend.md +556 -0
- package/skills/dhh-rails-style/references/gems.md +316 -0
- package/skills/dhh-rails-style/references/models.md +388 -0
- package/skills/dhh-rails-style/references/testing.md +354 -0
- package/skills/discord-content/SKILL.md +126 -0
- package/skills/docs-site/SKILL.md +214 -0
- package/skills/drain-labeled-backlog/SKILL.md +203 -0
- package/skills/drain-labeled-backlog/scripts/group-by-area.sh +208 -0
- package/skills/drain-labeled-backlog/workflows/drain-labeled-backlog.workflow.js +433 -0
- package/skills/drain-prs/SKILL.md +142 -0
- package/skills/drain-prs/scripts/triage-prs.sh +192 -0
- package/skills/drain-prs/test/argv-ceiling.test.sh +183 -0
- package/skills/dspy-ruby/SKILL.md +634 -0
- package/skills/dspy-ruby/assets/config-template.rb +359 -0
- package/skills/dspy-ruby/assets/module-template.rb +326 -0
- package/skills/dspy-ruby/assets/signature-template.rb +143 -0
- package/skills/dspy-ruby/references/core-concepts.md +268 -0
- package/skills/dspy-ruby/references/optimization.md +623 -0
- package/skills/dspy-ruby/references/providers.md +335 -0
- package/skills/eval-harness/README.md +194 -0
- package/skills/eval-harness/SKILL.md +157 -0
- package/skills/eval-harness/enums/go-routes.json +13 -0
- package/skills/eval-harness/enums/incident-threshold.json +5 -0
- package/skills/eval-harness/enums/lane.json +5 -0
- package/skills/eval-harness/enums/tool-selection.json +20 -0
- package/skills/eval-harness/enums/triage-levels.json +5 -0
- package/skills/eval-harness/gated-skills.json +34 -0
- package/skills/eval-harness/models.generated.json +5 -0
- package/skills/eval-harness/promptfooconfig-go-routing.yaml +35 -0
- package/skills/eval-harness/promptfooconfig-incident-threshold.yaml +32 -0
- package/skills/eval-harness/promptfooconfig-lane-inference.yaml +33 -0
- package/skills/eval-harness/promptfooconfig-ticket-triage.yaml +34 -0
- package/skills/eval-harness/promptfooconfig-tool-selection.yaml +33 -0
- package/skills/eval-harness/prompts/go-baseline.txt +6 -0
- package/skills/eval-harness/prompts/go-skill.txt +21 -0
- package/skills/eval-harness/prompts/incident-threshold-baseline.txt +6 -0
- package/skills/eval-harness/prompts/incident-threshold-skill.txt +12 -0
- package/skills/eval-harness/prompts/lane-inference-baseline.txt +6 -0
- package/skills/eval-harness/prompts/lane-inference-skill.txt +15 -0
- package/skills/eval-harness/prompts/tool-selection-baseline.txt +19 -0
- package/skills/eval-harness/prompts/tool-selection-skill.txt +22 -0
- package/skills/eval-harness/prompts/triage-baseline.txt +7 -0
- package/skills/eval-harness/prompts/triage-skill.txt +10 -0
- package/skills/eval-harness/scripts/eval-gate.cjs +318 -0
- package/skills/eval-harness/scripts/extract-block.cjs +87 -0
- package/skills/eval-harness/scripts/gate-classification.cjs +28 -0
- package/skills/eval-harness/scripts/gen-models.sh +41 -0
- package/skills/eval-harness/scripts/gen-skill-prompt.cjs +177 -0
- package/skills/eval-harness/scripts/measure-classification.cjs +30 -0
- package/skills/eval-harness/scripts/parse-label.cjs +93 -0
- package/skills/eval-harness/scripts/verdict.cjs +132 -0
- package/skills/eval-harness/tasks/go-routing.jsonl +8 -0
- package/skills/eval-harness/tasks/incident-threshold.jsonl +7 -0
- package/skills/eval-harness/tasks/lane-inference.jsonl +7 -0
- package/skills/eval-harness/tasks/ticket-triage.jsonl +6 -0
- package/skills/eval-harness/tasks/tool-selection.jsonl +15 -0
- package/skills/eval-harness/test/eval-gate.test.sh +132 -0
- package/skills/eval-harness/test/extract-block.test.sh +87 -0
- package/skills/eval-harness/test/gate-classification.test.sh +77 -0
- package/skills/eval-harness/test/gen-models.test.sh +75 -0
- package/skills/eval-harness/test/measure-classification.test.sh +74 -0
- package/skills/eval-harness/test/parse-label.test.sh +129 -0
- package/skills/eval-harness/test/registry-completeness.test.sh +109 -0
- package/skills/eval-harness/test/verdict.test.sh +123 -0
- package/skills/every-style-editor/SKILL.md +142 -0
- package/skills/every-style-editor/references/EVERY_WRITE_STYLE.md +529 -0
- package/skills/feature-tweet/SKILL.md +199 -0
- package/skills/feature-video/SKILL.md +412 -0
- package/skills/feature-video/scripts/check_deps.sh +281 -0
- package/skills/file-todos/SKILL.md +267 -0
- package/skills/file-todos/assets/todo-template.md +166 -0
- package/skills/fix-issue/SKILL.md +278 -0
- package/skills/fix-issue/references/agent-authored-exclusion.md +118 -0
- package/skills/fix-issue/references/exclude-label-jq-snippet.md +96 -0
- package/skills/flag-bootstrap/SETUP.md +142 -0
- package/skills/flag-create/SKILL.md +145 -0
- package/skills/flag-create/scripts/create.sh +273 -0
- package/skills/flag-delete/SKILL.md +167 -0
- package/skills/flag-delete/scripts/delete.sh +288 -0
- package/skills/flag-list/SKILL.md +99 -0
- package/skills/flag-list/scripts/list.sh +275 -0
- package/skills/flag-set-role/SKILL.md +229 -0
- package/skills/flag-set-role/scripts/flip.sh +774 -0
- package/skills/frontend-anti-slop/SKILL.md +106 -0
- package/skills/frontend-anti-slop/references/anti-patterns.md +199 -0
- package/skills/frontend-anti-slop/references/slop-rules.md +65 -0
- package/skills/frontend-anti-slop/scripts/tier1-scan.ts +525 -0
- package/skills/frontend-design/SKILL.md +77 -0
- package/skills/gdpr-gate/NOTICE +136 -0
- package/skills/gdpr-gate/SKILL.md +341 -0
- package/skills/gdpr-gate/references/fields.md +160 -0
- package/skills/gdpr-gate/references/layers/api-layer.md +355 -0
- package/skills/gdpr-gate/references/layers/auth-sessions.md +290 -0
- package/skills/gdpr-gate/references/layers/data-in-transit.md +331 -0
- package/skills/gdpr-gate/references/layers/data-lifecycle.md +341 -0
- package/skills/gdpr-gate/references/layers/frontend.md +249 -0
- package/skills/gdpr-gate/references/layers/testing-seeding.md +256 -0
- package/skills/gdpr-gate/references/leakage-vectors.md +248 -0
- package/skills/gdpr-gate/references/legacy/legal-consent-v1-prose.md +48 -0
- package/skills/gdpr-gate/references/legal-consent.md +140 -0
- package/skills/gdpr-gate/references/non-negotiables.md +60 -0
- package/skills/gdpr-gate/scripts/gdpr-gate.sh +271 -0
- package/skills/gdpr-gate/scripts/notice-frontmatter.sh +337 -0
- package/skills/gdpr-gate/scripts/path-denylist.txt +39 -0
- package/skills/gdpr-gate/scripts/repo-scan.sh +158 -0
- package/skills/gdpr-gate/scripts/vendor-drift-classify.sh +120 -0
- package/skills/gdpr-gate/scripts/vendor-pin-integrity.sh +237 -0
- package/skills/gemini-imagegen/SKILL.md +283 -0
- package/skills/gemini-imagegen/requirements.txt +5 -0
- package/skills/gemini-imagegen/scripts/_error_handling.py +146 -0
- package/skills/gemini-imagegen/scripts/compose_images.py +150 -0
- package/skills/gemini-imagegen/scripts/edit_image.py +137 -0
- package/skills/gemini-imagegen/scripts/gemini_images.py +256 -0
- package/skills/gemini-imagegen/scripts/generate_image.py +147 -0
- package/skills/gemini-imagegen/scripts/multi_turn_chat.py +223 -0
- package/skills/gemini-imagegen/scripts/test_error_handling.py +179 -0
- package/skills/git-worktree/SKILL.md +534 -0
- package/skills/git-worktree/scripts/git-repo-readiness-diag.sh +98 -0
- package/skills/git-worktree/scripts/worktree-manager.sh +4773 -0
- package/skills/git-worktree/test/create-from-origin-main.test.sh +116 -0
- package/skills/git-worktree/test/lease-protects-active.test.sh +1056 -0
- package/skills/git-worktree/test/no-repo-fail-loud.test.sh +88 -0
- package/skills/git-worktree/test/orphan-reaper-honest-count.test.sh +379 -0
- package/skills/git-worktree/test/reap-archive-persistence.test.sh +725 -0
- package/skills/git-worktree/test/stale-lock-sweep.test.sh +176 -0
- package/skills/go/SKILL.md +54 -0
- package/skills/growth/SKILL.md +164 -0
- package/skills/harvest-debt/SKILL.md +78 -0
- package/skills/harvest-debt/scripts/harvest-debt.sh +114 -0
- package/skills/heal-skill/SKILL.md +173 -0
- package/skills/help/SKILL.md +46 -0
- package/skills/incident/NOTICE +39 -0
- package/skills/incident/SKILL.md +395 -0
- package/skills/incident/scripts/dry-run.sh +448 -0
- package/skills/incident/scripts/redact-engine.py +412 -0
- package/skills/incident/scripts/redact-sentinel.sh +44 -0
- package/skills/incident/templates/pir.md +139 -0
- package/skills/incident/test/fixtures/dry-run-incident.json +26 -0
- package/skills/incident/test/fixtures/dry-run-secret-leak.json +26 -0
- package/skills/incident/test/fixtures/legacy-bash-scanner.sh +82 -0
- package/skills/incident/test/fixtures/positive-corpus.md +87 -0
- package/skills/incident/test/redact-sentinel.test.sh +1420 -0
- package/skills/invoice/SKILL.md +206 -0
- package/skills/kb-glossary/SKILL.md +71 -0
- package/skills/kb-glossary/references/glossary-format.md +111 -0
- package/skills/kb-glossary/references/rejected-request-register.md +115 -0
- package/skills/kb-search/SKILL.md +276 -0
- package/skills/kb-search/scripts/kb-search-cache.sh +123 -0
- package/skills/legal-audit/SKILL.md +161 -0
- package/skills/legal-generate/NOTICE +242 -0
- package/skills/legal-generate/SKILL.md +244 -0
- package/skills/legal-generate/references/templates/advisor-agreement/template.md +12 -0
- package/skills/legal-generate/references/templates/business-associate-agreement/template.md +112 -0
- package/skills/legal-generate/references/templates/cookie-notice/template.md +82 -0
- package/skills/legal-generate/references/templates/dpa-global/template.md +439 -0
- package/skills/legal-generate/references/templates/dpa-us/template.md +222 -0
- package/skills/legal-generate/references/templates/employee-offer-letter/template.md +75 -0
- package/skills/legal-generate/references/templates/master-services-agreement/template.md +252 -0
- package/skills/legal-generate/references/templates/mutual-nda/template.md +66 -0
- package/skills/legal-generate/references/templates/one-way-nda/template.md +38 -0
- package/skills/legal-generate/references/templates/privacy-policy-gdpr/template.md +427 -0
- package/skills/legal-generate/references/templates/privacy-policy-us/template.md +313 -0
- package/skills/legal-generate/references/templates/terms-of-use/template.md +75 -0
- package/skills/legal-generate/scripts/strip-vendor-credit.sh +96 -0
- package/skills/linear-fetch/SKILL.md +247 -0
- package/skills/linear-fetch/scripts/assert-no-linear-telemetry.sh +66 -0
- package/skills/linear-fetch/scripts/redact-linear-urls.sh +51 -0
- package/skills/linear-fetch/scripts/render-caller-template.sh +36 -0
- package/skills/linear-fetch/test/assert-no-linear-telemetry.test.sh +113 -0
- package/skills/linear-fetch/test/parity.test.sh +112 -0
- package/skills/linear-fetch/test/persist-safe-integration.test.sh +191 -0
- package/skills/linear-fetch/test/redact-linear-urls.test.sh +226 -0
- package/skills/merge-pr/SKILL.md +668 -0
- package/skills/model-launch-review/SKILL.md +255 -0
- package/skills/model-launch-review/scripts/audit-models.sh +501 -0
- package/skills/one-shot/SKILL.md +316 -0
- package/skills/one-shot/references/token-discipline.md +74 -0
- package/skills/operator-bootstrap/SKILL.md +412 -0
- package/skills/operator-bootstrap/template.sh +439 -0
- package/skills/operator-digest/SKILL.md +287 -0
- package/skills/operator-digest/assets/operator-digest.workflow.yml +175 -0
- package/skills/operator-digest/scripts/digest-scrub.sh +119 -0
- package/skills/operator-digest/scripts/provision-operator-digest-repo.sh +147 -0
- package/skills/operator-rephrase/SKILL.md +98 -0
- package/skills/pencil-setup/SKILL.md +201 -0
- package/skills/pencil-setup/scripts/check_deps.sh +556 -0
- package/skills/pencil-setup/scripts/copy_adapter.sh +80 -0
- package/skills/pencil-setup/scripts/package-lock.json +1172 -0
- package/skills/pencil-setup/scripts/package.json +14 -0
- package/skills/pencil-setup/scripts/pencil-error-enrichment.mjs +21 -0
- package/skills/pencil-setup/scripts/pencil-mcp-adapter.mjs +733 -0
- package/skills/pencil-setup/scripts/pencil-response-classify.mjs +38 -0
- package/skills/pencil-setup/scripts/pencil-save-gate.mjs +26 -0
- package/skills/pencil-setup/scripts/sanitize-filename.mjs +25 -0
- package/skills/plan/SKILL.md +1140 -0
- package/skills/plan/references/differential-migration-testing.md +185 -0
- package/skills/plan/references/plan-community-discovery.md +42 -0
- package/skills/plan/references/plan-founder-check.md +137 -0
- package/skills/plan/references/plan-functional-overlap.md +25 -0
- package/skills/plan/references/plan-host-fanout.md +51 -0
- package/skills/plan/references/plan-issue-templates.md +779 -0
- package/skills/plan/references/plan-network-outage-checklist.md +131 -0
- package/skills/plan/references/plan-scope-check.md +126 -0
- package/skills/plan/references/plan-sharp-edges.md +325 -0
- package/skills/plan/references/plan-signoff-lifecycle.md +12 -0
- package/skills/plan/references/webapp-cwv-observability.md +143 -0
- package/skills/plan-review/SKILL.md +91 -0
- package/skills/plan-review/lib/named-panel.mjs +58 -0
- package/skills/plan-review/workflows/plan-review.workflow.js +477 -0
- package/skills/postmerge/SKILL.md +642 -0
- package/skills/postmerge/references/deploy-status-debugging.md +138 -0
- package/skills/postmerge/references/graceful-degradation.md +20 -0
- package/skills/postmerge/references/sentry-error-count-delta.md +137 -0
- package/skills/preflight/SKILL.md +1553 -0
- package/skills/preflight/references/check-13-founder-check.md +286 -0
- package/skills/preflight/scripts/founder-check.py +1640 -0
- package/skills/preflight/scripts/parse-form-a.awk +107 -0
- package/skills/preflight/scripts/probe-verb-gate.sh +108 -0
- package/skills/product-roadmap/SKILL.md +357 -0
- package/skills/product-roadmap/scripts/roadmap-reconcile.sh +327 -0
- package/skills/provision-cloudflare/SKILL.md +67 -0
- package/skills/provision-cloudflare/scripts/provision-cloudflare.sh +271 -0
- package/skills/provision-doppler/SKILL.md +58 -0
- package/skills/provision-doppler/scripts/provision-doppler.sh +358 -0
- package/skills/provision-github/SKILL.md +59 -0
- package/skills/provision-github/scripts/provision-github.sh +300 -0
- package/skills/provision-hetzner/SKILL.md +76 -0
- package/skills/provision-hetzner/scripts/provision-hetzner.sh +262 -0
- package/skills/provision-hetzner/test/fixture/knowledge-base/legal/tenant-dpa-register.md +26 -0
- package/skills/provision-hetzner/test/fixture/provision-hetzner-dry-run.golden +34 -0
- package/skills/provision-hetzner/test/provision-hetzner-characterization.test.sh +227 -0
- package/skills/qa/SKILL.md +263 -0
- package/skills/qa/references/browser-preflight.md +22 -0
- package/skills/questionnaire-generate/SKILL.md +328 -0
- package/skills/questionnaire-generate/references/questionnaire.template +60 -0
- package/skills/rclone/SKILL.md +162 -0
- package/skills/rclone/scripts/check_setup.sh +63 -0
- package/skills/release-announce/SKILL.md +65 -0
- package/skills/release-docs/SKILL.md +92 -0
- package/skills/reproduce-bug/SKILL.md +358 -0
- package/skills/resolve-debt/SKILL.md +85 -0
- package/skills/resolve-debt/scripts/resolve-debt.py +584 -0
- package/skills/resolve-parallel/SKILL.md +45 -0
- package/skills/resolve-parallel/workflows/resolve-parallel.workflow.js +415 -0
- package/skills/resolve-pr-parallel/SKILL.md +68 -0
- package/skills/resolve-pr-parallel/workflows/resolve-pr-parallel.workflow.js +419 -0
- package/skills/resolve-todo-parallel/SKILL.md +57 -0
- package/skills/resolve-todo-parallel/workflows/resolve-todo-parallel.workflow.js +398 -0
- package/skills/review/SKILL.md +1621 -0
- package/skills/review/references/anti-slop-scanner-hook.md +40 -0
- package/skills/review/references/defect-classes-migrations.md +9 -0
- package/skills/review/references/generated-input-oracle-surfaces.md +15 -0
- package/skills/review/references/review-e2e-testing.md +116 -0
- package/skills/review/references/review-todo-structure.md +165 -0
- package/skills/review/references/risk-tier-and-fix-rounds.md +95 -0
- package/skills/review/references/semgrep-custom-rules.yaml +174 -0
- package/skills/review/references/ultrathink-phases.md +98 -0
- package/skills/review/references/wfs.md +6 -0
- package/skills/review/scripts/emit-review-trailer.sh +499 -0
- package/skills/review/scripts/ensure-semgrep.sh +99 -0
- package/skills/review/scripts/fix-round-seats.sh +197 -0
- package/skills/review/test/emit-review-trailer.test.sh +387 -0
- package/skills/review/test/ensure-semgrep.test.sh +111 -0
- package/skills/review/test/fix-round-seats.test.sh +237 -0
- package/skills/review/workflows/README.md +147 -0
- package/skills/review/workflows/review.workflow.js +667 -0
- package/skills/schedule/SKILL.md +796 -0
- package/skills/seo-aeo/SKILL.md +139 -0
- package/skills/seo-aeo/scripts/validate-csp.sh +233 -0
- package/skills/seo-aeo/scripts/validate-seo.sh +262 -0
- package/skills/ship/SKILL.md +3022 -0
- package/skills/ship/references/adr-ordinal-collision.md +47 -0
- package/skills/ship/references/affected-gate-contract.md +115 -0
- package/skills/ship/references/ci-run-diagnosis.md +29 -0
- package/skills/ship/references/ci-workflow-authoring.md +68 -0
- package/skills/ship/references/counsel-review-clo-attestation.md +38 -0
- package/skills/ship/references/cto-ruling-armed-push.md +5 -0
- package/skills/ship/references/decision-challenges-render.md +71 -0
- package/skills/ship/references/detached-battery-verdict.md +13 -0
- package/skills/ship/references/feature-tweet-draft.md +42 -0
- package/skills/ship/references/followthrough-issue-body-template.md +47 -0
- package/skills/ship/references/followthrough-stub-template.sh +92 -0
- package/skills/ship/references/merge-queue-dequeue.md +39 -0
- package/skills/ship/references/operator-holds.md +149 -0
- package/skills/ship/references/pipeline-tally-render.md +43 -0
- package/skills/ship/references/pr-overlap.md +153 -0
- package/skills/ship/references/queue-mode.md +7 -0
- package/skills/ship/references/ready-run-wait.md +36 -0
- package/skills/ship/references/red-on-main-quarantine.md +110 -0
- package/skills/ship/references/register-update-pr-pattern.md +88 -0
- package/skills/ship/references/settle-then-admin-merge.md +130 -0
- package/skills/ship/references/supabase-column-probe.md +13 -0
- package/skills/ship/scripts/auto-close-scan.sh +76 -0
- package/skills/ship/scripts/battery-owed.sh +403 -0
- package/skills/ship/scripts/hold-gate-eval.sh +410 -0
- package/skills/ship/scripts/net-issue-flow.sh +665 -0
- package/skills/ship/scripts/pr-overlap-preflight.sh +483 -0
- package/skills/ship/scripts/ship-pir-action-items-gate.sh +246 -0
- package/skills/ship/scripts/technical-ruling-gate.sh +444 -0
- package/skills/skill-creator/SKILL.md +299 -0
- package/skills/skill-creator/references/api-security.md +248 -0
- package/skills/skill-creator/references/authoring-levers.md +110 -0
- package/skills/skill-creator/references/be-clear-and-direct.md +587 -0
- package/skills/skill-creator/references/best-practices.md +431 -0
- package/skills/skill-creator/references/common-patterns.md +639 -0
- package/skills/skill-creator/references/core-principles.md +193 -0
- package/skills/skill-creator/references/executable-code.md +188 -0
- package/skills/skill-creator/references/iteration-and-testing.md +509 -0
- package/skills/skill-creator/references/official-spec.md +193 -0
- package/skills/skill-creator/references/recommended-structure.md +180 -0
- package/skills/skill-creator/references/skill-structure.md +396 -0
- package/skills/skill-creator/references/using-scripts.md +122 -0
- package/skills/skill-creator/references/using-templates.md +120 -0
- package/skills/skill-creator/references/workflows-and-validation.md +541 -0
- package/skills/skill-creator/scripts/init_skill.py +322 -0
- package/skills/skill-creator/scripts/package_skill.py +110 -0
- package/skills/skill-creator/scripts/quick_validate.py +64 -0
- package/skills/skill-creator/templates/router-skill.md +73 -0
- package/skills/skill-creator/templates/simple-skill.md +34 -0
- package/skills/skill-creator/workflows/add-reference.md +96 -0
- package/skills/skill-creator/workflows/add-script.md +104 -0
- package/skills/skill-creator/workflows/add-template.md +83 -0
- package/skills/skill-creator/workflows/add-workflow.md +128 -0
- package/skills/skill-creator/workflows/audit-skill.md +159 -0
- package/skills/skill-creator/workflows/create-domain-expertise-skill.md +623 -0
- package/skills/skill-creator/workflows/create-new-skill.md +207 -0
- package/skills/skill-creator/workflows/get-guidance.md +134 -0
- package/skills/skill-creator/workflows/upgrade-to-router.md +170 -0
- package/skills/skill-creator/workflows/verify-skill.md +226 -0
- package/skills/skill-security-scan/SKILL.md +145 -0
- package/skills/skill-security-scan/references/disclaimer.md +21 -0
- package/skills/skill-security-scan/references/first-party-allowlist.yaml +38 -0
- package/skills/skill-security-scan/references/override-artifact-schema.json +68 -0
- package/skills/skill-security-scan/references/override-mechanism.md +171 -0
- package/skills/skill-security-scan/references/redirect-domains.yaml +27 -0
- package/skills/skill-security-scan/references/regex-patterns.md +136 -0
- package/skills/skill-security-scan/references/rules/code-exec.yaml +70 -0
- package/skills/skill-security-scan/references/rules/filesystem-boundary.yaml +29 -0
- package/skills/skill-security-scan/references/rules/manifest.yaml +44 -0
- package/skills/skill-security-scan/references/rules/supply-chain.yaml +36 -0
- package/skills/skill-security-scan/references/scan-meta-schema.json +63 -0
- package/skills/skill-security-scan/references/test-fixtures/clean-soleur-style.skill.md +23 -0
- package/skills/skill-security-scan/references/test-fixtures/clean-third-party.skill.md +26 -0
- package/skills/skill-security-scan/references/test-fixtures/malicious-codeexec.skill.md +23 -0
- package/skills/skill-security-scan/references/test-fixtures/malicious-curl-pipe-bash.skill.md +69 -0
- package/skills/skill-security-scan/references/test-fixtures/malicious-prompt-injection.skill.md +17 -0
- package/skills/skill-security-scan/references/test-fixtures/malicious-telemetry-beacon.skill.md +23 -0
- package/skills/skill-security-scan/references/typosquat-targets.yaml +65 -0
- package/skills/skill-security-scan/scripts/check-codeexec.sh +41 -0
- package/skills/skill-security-scan/scripts/check-filesystem-boundary.sh +19 -0
- package/skills/skill-security-scan/scripts/check-prompt-injection.sh +95 -0
- package/skills/skill-security-scan/scripts/check-supply-chain.sh +219 -0
- package/skills/skill-security-scan/scripts/check-telemetry-surface.sh +126 -0
- package/skills/skill-security-scan/scripts/lib.sh +197 -0
- package/skills/skill-security-scan/scripts/parse-override.sh +231 -0
- package/skills/skill-security-scan/scripts/run-scan.sh +342 -0
- package/skills/skill-security-scan/scripts/run-self-test.sh +95 -0
- package/skills/skill-security-scan/test/argv-ceiling.test.sh +180 -0
- package/skills/skill-security-scan/test/meta-dir-reap.test.sh +411 -0
- package/skills/skill-security-scan/test/parse-override.test.sh +607 -0
- package/skills/skill-security-scan/test/redact-pii-ghs-shape.test.sh +251 -0
- package/skills/social-distribute/SKILL.md +485 -0
- package/skills/spec-templates/SKILL.md +274 -0
- package/skills/sync/SKILL.md +49 -0
- package/skills/test-browser/SKILL.md +464 -0
- package/skills/test-browser/references/feature-map.md +161 -0
- package/skills/test-fix-loop/SKILL.md +159 -0
- package/skills/test-fix-loop/references/determinism-checklist.md +154 -0
- package/skills/triage/SKILL.md +402 -0
- package/skills/trigger-cron/SKILL.md +136 -0
- package/skills/trigger-cron/scripts/trigger.sh +198 -0
- package/skills/user-set-role/SKILL.md +147 -0
- package/skills/user-set-role/scripts/set-role.sh +218 -0
- package/skills/user-story-writer/SKILL.md +77 -0
- package/skills/ux-audit/SKILL.md +251 -0
- package/skills/ux-audit/references/finding.schema.json +62 -0
- package/skills/ux-audit/references/route-list.yaml +95 -0
- package/skills/ux-audit/scripts/bot-fixture.ts +270 -0
- package/skills/ux-audit/scripts/bot-signin.ts +135 -0
- package/skills/ux-audit/scripts/dedup-hash.ts +46 -0
- package/skills/work/SKILL.md +1479 -0
- package/skills/work/references/end-of-work-resume.md +7 -0
- package/skills/work/references/hook-test-main-cwd.md +24 -0
- package/skills/work/references/key-principles.md +83 -0
- package/skills/work/references/property-testing.md +122 -0
- package/skills/work/references/sql-security-definer-rpc-scaffold.sql +78 -0
- package/skills/work/references/work-agent-teams.md +97 -0
- package/skills/work/references/work-lifecycle-parallel.md +147 -0
- package/skills/work/references/work-lockfile-bumps.md +97 -0
- package/skills/work/references/work-scratch-sandboxes.md +212 -0
- package/skills/work/references/work-subagent-fanout.md +61 -0
- package/skills/work/references/work-test-harness-sharp-edges.md +73 -0
- package/skills/xcode-test/SKILL.md +360 -0
|
@@ -0,0 +1,1553 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: preflight
|
|
3
|
+
description: "This skill should be used when running pre-ship checks on migrations, security headers, and lockfiles."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<!-- soleur-cloud-mode:start -->
|
|
7
|
+
**Cloud Mode (Devin):** before pipeline work run `bash "${CLAUDE_PLUGIN_ROOT}/scripts/cloud-detect.sh"`. If `CLAUDE_PLUGIN_ROOT` is unset (cloud exec shells do not export it), resolve the root by IDENTITY, never by script basename: for `d` in `"$HOME/.local/share/devin/cli/plugins/cache"` and `/opt/.devin/plugins`, skip unless `[ -d "$d" ]`, then `MANIFEST="$(find "$d" -path '*/.claude-plugin/plugin.json' -exec grep -l '"name"[[:space:]]*:[[:space:]]*"soleur"' {} + 2>/dev/null | head -1)"`, `ROOT="${MANIFEST%/.claude-plugin/plugin.json}"` — one resolution, two consumers: `$ROOT/scripts/cloud-detect.sh` and `$ROOT/scripts/precommit-guard.sh`. (Shape check, not authentication: a planted `{"name":"soleur"}` dir passes — ADR-179 A11.) `local` or `not-local:no-devin-env` proceeds normally; any other `not-local:<reason>` applies `<plugin-root>/devin/INSTRUCTIONS.md` §Cloud Mode: emit the `--banner`, fan out sequentially with `Reviewed-Coverage: sequential-fallback` disclosure (never claim an independent review ran), `message_user` ack before any secrets read or production mutation, and run `precommit-guard.sh` before any `git commit` — hooks do not fire in cloud.
|
|
8
|
+
<!-- soleur-cloud-mode:end -->
|
|
9
|
+
|
|
10
|
+
<!-- grok-harness-invoke:start -->
|
|
11
|
+
**Grok Build (`plugins/soleur/lib/harness.ts` `invokeSkill()`):** Read this SKILL.md in this process and run it to completion. A one-segment `soleur:<name>` in this document names a SKILL — on Grok Build, Read `plugins/soleur/skills/<name>/SKILL.md` in this process; it is not a nested tool_use. A multi-segment id such as `soleur:<domain>:<name>` names an AGENT: spawn it, never Read it, and on Grok Build spawn_subagent takes the id with its colons replaced by hyphens (`agentIdToGrokSubagentType`). **Claude Code:** Skill tool for a skill (`soleur:<name>`), Task tool with `subagent_type` for an agent. Forbidden is executing a subset, not the Read.
|
|
12
|
+
<!-- grok-harness-invoke:end -->
|
|
13
|
+
|
|
14
|
+
# preflight Skill
|
|
15
|
+
|
|
16
|
+
**Purpose:** Validate technical readiness of code changes before a PR is created, catching the class of bugs that only appear in production context -- unapplied database migrations, CSP violations from injected scripts, and bare-repo stale file reads.
|
|
17
|
+
|
|
18
|
+
**CRITICAL: No command substitution.** Never use `$()` in Bash commands. When a step says "get value X, then use it in command Y", run them as **two separate Bash tool calls** -- first get the value, then use it literally in the next call. The one exception is a fenced block that says it is run as ONE Bash call (Step 1.4): its `$()` fetches a credential into a variable that the same block checks and then feeds to curl's stdin config, because a literal value pasted into a later call would sit in the transcript.
|
|
19
|
+
|
|
20
|
+
<!-- operator-typed-render:start -->
|
|
21
|
+
**Any message this skill PRINTS that tells the operator to run a skill or command renders at emit time.** The doc names it canonically (`soleur:<name>`, ADR-226); before printing, render it as the active harness's **operator-typed form** per `formatSkillInvocation` (`plugins/soleur/lib/harness.ts`), which owns the per-harness slash and sigil forms — the operator types that string into a fresh session where no routing contract is in context, so a bare canonical name is model-discretion there rather than a dispatch. This covers abort messages, `AskUserQuestion` prompts and options, `Display`/`echo` lines and resume prompts alike; an agent-read instruction stays canonical.
|
|
22
|
+
<!-- operator-typed-render:end -->
|
|
23
|
+
|
|
24
|
+
## Headless Mode Detection
|
|
25
|
+
|
|
26
|
+
If `$ARGUMENTS` contains `--headless`, set `HEADLESS_MODE=true`. Strip `--headless` from `$ARGUMENTS` before processing remaining args.
|
|
27
|
+
|
|
28
|
+
When `HEADLESS_MODE=true`:
|
|
29
|
+
|
|
30
|
+
- On any FAIL: abort with error details, no prompt
|
|
31
|
+
- On all PASS/SKIP: continue silently. **Two exceptions, both Check 10 only, both always emitted:** **SKIP-DECLARED** — continue, but emit one line naming the declared credential scope (a verification waiver exists to be reviewable, so silencing it defeats it); and **SKIP-NOSANDBOX** — continue, but emit one line stating that Check 10 did not run at all on this host. The second is the more important of the two: it means a security-relevant gate is dark, and folded into the silent set it is indistinguishable from the gate having nothing to do.
|
|
32
|
+
|
|
33
|
+
**Check 13 headless contract.** Check 13 never prompts in headless mode, and headless is the default: treat the run as interactive only when the founder is present in this session (started without `--headless`, `CI` unset); when unsure, headless. A founder-stated check that FAILED, is INVALID, CHANGED-SINCE-APPROVAL, UNTRUSTED, BLOCK-REJECTED or `needs-your-eyes`, or that cannot run for want of a sandbox, is a FAIL recorded as `STOPPED-AWAITING-FOUNDER`; an agent never accepts, overrides or confirms on the founder's behalf. Always emitted, like Check 10's: the no-block banner and the no-sandbox line (`founder-check.py text no-block` / `text no-sandbox`).
|
|
34
|
+
|
|
35
|
+
**`--founder-check-baseline`.** If `$ARGUMENTS` contains `--founder-check-baseline`, strip it like `--headless`, **skip Checks 1–12 explicitly**, and run only Check 13 in baseline polarity (reference section 7). Invoked by `soleur:plan` at capture, before the freeze commit.
|
|
36
|
+
|
|
37
|
+
## Phase 0: Context Detection
|
|
38
|
+
|
|
39
|
+
Run `git rev-parse --abbrev-ref HEAD` to get the current branch name.
|
|
40
|
+
|
|
41
|
+
**Branch safety check (defense-in-depth):** If the branch is `main` or `master`, abort immediately with: "Error: preflight cannot run on main/master. Checkout a feature branch first."
|
|
42
|
+
|
|
43
|
+
### Step 0.1: Compute changed-file path-set (diff classifier)
|
|
44
|
+
|
|
45
|
+
Run `git diff --name-only origin/main...HEAD` ONCE up-front and cache the result so each path-gated check re-uses the same path-set instead of re-running diff. Skill convention forbids `$()` command substitution — write the diff to a tmpfile so checks can `grep -E` the predicate they care about.
|
|
46
|
+
|
|
47
|
+
Use `git rev-parse --git-dir` to resolve a writable tmp path that works in both regular checkouts and worktrees: in a worktree `.git` is a file (gitdir pointer), not a directory, so `> .git/<filename>` fails with `Not a directory (os error 20)`. The resolver returns the worktree's actual gitdir (e.g., `<bare>/worktrees/<name>/`):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
PREFLIGHT_TMP="$(git rev-parse --git-dir)"
|
|
51
|
+
git diff --name-only origin/main...HEAD > "$PREFLIGHT_TMP/preflight-diff-files.txt"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
All downstream `cat .git/<filename>` references in the predicates below must use `"$PREFLIGHT_TMP/<filename>"` instead. The pre-existing `.git/...` literals worked in non-worktree checkouts but silently broke in worktrees (where every preflight increasingly happens by default).
|
|
55
|
+
|
|
56
|
+
If the command fails (e.g., offline, no remote), every path-gated check falls back to its existing `git diff` form — operators do not need to handle this case explicitly. Between Phase 0 and Phase 1 nothing mutates the working tree or fetches `origin/main`, so the cache is valid for the duration of one preflight invocation. The cache lives under the per-worktree gitdir (user-owned, scoped to one worktree) — this isolates concurrent preflight runs across sibling worktrees and avoids the `/tmp/` symlink-clobber class that affects fixed-name shared paths on multi-user hosts.
|
|
57
|
+
|
|
58
|
+
**Fast-path SKIP overview.** For diffs whose path-set matches a recognized "guaranteed-SKIP" shape, the relevant check returns SKIP at its first step without further work. The predicates below are the existing inner predicates of each check — only the diff source is changed.
|
|
59
|
+
|
|
60
|
+
| Check | Fast-path SKIP predicate (against `"$PREFLIGHT_TMP/preflight-diff-files.txt"`) |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| 1 (Migrations) | Zero matches for `(^\|/)supabase/migrations/.*\.sql$`. |
|
|
63
|
+
| 2 (Sec headers) | Zero matches for `\.(tsx\|css\|html)$`, `middleware\.ts$`, `next\.config\.`, `\.tf$`, `Dockerfile`, `nginx`, `\.github/workflows/`. |
|
|
64
|
+
| 3 (Lockfiles) | Existing predicate (uses `--name-status`, status letters load-bearing — does NOT use the cached path-set; see Sharp Edges). |
|
|
65
|
+
| 4 (Env isolation) | Always runs (no fast-path SKIP). |
|
|
66
|
+
| 5 (Bundle host) | Zero matches for the listed Supabase client/validator paths, `Dockerfile`, `reusable-release.yml`, or `verify-required-secrets.sh`. |
|
|
67
|
+
| 6 (Brand-survival) | Zero matches for the canonical sensitive-path regex. |
|
|
68
|
+
| 7 (Canary) | `apps/web-platform/infra/ci-deploy.sh` not in path-set. |
|
|
69
|
+
| 8 (SW cache bump) | No `fix(`/`fix:`/`hotfix` commit subject AND zero client-bundle surface matches. |
|
|
70
|
+
| 9 (Node-only encodings) | Always runs (uses `git ls-files`, full-universe scan — does NOT use the cached path-set; see Sharp Edges). |
|
|
71
|
+
| 10 (Discoverability test) | Zero matches for the canonical sensitive-path regex (re-use Check 6 SSOT). |
|
|
72
|
+
| 11 (Register drift) | Zero matches for `(^\|/)apps/web-platform/supabase/migrations/.*\.sql$`, `(^\|/)apps/web-platform/server/workspace-resolver\.ts$`, or `(^\|/)knowledge-base/engineering/architecture/domain-model\.md$` (empty/missing cache → run, never SKIP). |
|
|
73
|
+
| 12 (Encryption posture) | Zero matches for `\.tf$`, `supabase/migrations/.*\.sql$`, `cloud-init.*\.ya?ml$`, `docker-compose.*\.ya?ml$` (empty/missing cache → run, never SKIP). |
|
|
74
|
+
| 13 (Founder check) | Never SKIPs on an empty cache: runs whenever a plan for this branch carries a `founder_check` block or freeze evidence (the resolver, not the diff cache, decides), else prints the no-block banner. |
|
|
75
|
+
| Not-Bare-Repo | Always runs. |
|
|
76
|
+
|
|
77
|
+
For PR #3488-class diffs (lockfile bumps + orphan-cleanup deletions), Checks 1, 2, 5, 6, 7, 8 fast-skip → Checks 3 (lockfile fires), 4 (env isolation always), 9 (always), Not-Bare-Repo (always) execute. Of those four, only Check 3 and Check 9 do "real work" against the diff; Check 4 and Not-Bare-Repo are constant-cost.
|
|
78
|
+
|
|
79
|
+
## Phase 1: Run All Checks in Parallel
|
|
80
|
+
|
|
81
|
+
Run all checks below (plus the Not-Bare-Repo assertion) as parallel Bash tool calls. Each returns PASS, FAIL, or SKIP.
|
|
82
|
+
|
|
83
|
+
### Assertion: Not-Bare-Repo
|
|
84
|
+
|
|
85
|
+
This assertion runs first conceptually (fail-fast) but executes in parallel with the checks.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
git rev-parse --is-bare-repository
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- If the result is `true`: **FAIL** -- "Running from bare repo root. Create a worktree first."
|
|
92
|
+
- If the result is `false`: **PASS**
|
|
93
|
+
|
|
94
|
+
### Shared Plan-File Resolution
|
|
95
|
+
|
|
96
|
+
Both Check 6 (Brand-Survival Self-Review) and Check 10 (Discoverability Test Execution) need the same input: the PR body scrubbed of HTML comments + fenced code blocks, concatenated with the linked plan file (also scrubbed). This sub-section is the single source of truth for that resolution. **Mirrored consumers: Check 6 Step 6.4, Check 10 Step 10.3.** If a future PR changes the scrub/extract logic, edit here once — both consumers pick up the change automatically.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# Step S.1: fetch PR body to a private temp file (umask 077; mktemp).
|
|
100
|
+
PR_BODY_FILE=$(umask 077 && mktemp -p "$(mktemp -d -t soleur-run.$$.XXXXXXXX)" preflight-pr-body.XXXXXXXX.md)
|
|
101
|
+
gh pr view --json body --jq .body > "$PR_BODY_FILE"
|
|
102
|
+
trap 'rm -f "$PR_BODY_FILE" "$COMBINED"; rmdir "$(dirname "$PR_BODY_FILE")" 2>/dev/null' EXIT
|
|
103
|
+
|
|
104
|
+
# Step S.2: strip HTML comments and fenced code blocks from PR body.
|
|
105
|
+
SCRUBBED_BODY=$(awk 'BEGIN{f=0} /^[[:space:]]*```/{f=!f; next} !f{print}' "$PR_BODY_FILE" \
|
|
106
|
+
| perl -0777 -pe 's/<!--.*?-->//gs')
|
|
107
|
+
|
|
108
|
+
# Step S.3: extract any linked plan file path (knowledge-base/project/plans/*.md).
|
|
109
|
+
PLAN_PATH=$(printf '%s' "$SCRUBBED_BODY" | grep -Eo 'knowledge-base/project/plans/[^[:space:])"`'\'']+\.md' | head -n 1 || true)
|
|
110
|
+
|
|
111
|
+
# Step S.4: build the combined check input.
|
|
112
|
+
COMBINED=$(mktemp -p "$(dirname "$PR_BODY_FILE")" preflight-combined.XXXXXXXX.md)
|
|
113
|
+
printf '%s\n' "$SCRUBBED_BODY" > "$COMBINED"
|
|
114
|
+
if [[ -n "$PLAN_PATH" && -f "$PLAN_PATH" ]]; then
|
|
115
|
+
awk 'BEGIN{f=0} /^[[:space:]]*```/{f=!f; next} !f{print}' "$PLAN_PATH" \
|
|
116
|
+
| perl -0777 -pe 's/<!--.*?-->//gs' >> "$COMBINED"
|
|
117
|
+
fi
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
If `gh pr view` fails (no PR exists for the current branch), the caller returns **SKIP** — Shared Plan-File Resolution itself does not decide; it provides `$COMBINED` (and `$PLAN_PATH`) for the caller.
|
|
121
|
+
|
|
122
|
+
### Check 1: DB Migration Status
|
|
123
|
+
|
|
124
|
+
**Step 1.1: Detect new migration files in this branch.**
|
|
125
|
+
|
|
126
|
+
Re-use the cached path-set from Phase 0 Step 0.1 (`"$PREFLIGHT_TMP/preflight-diff-files.txt"`):
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
grep -E '(^|/)supabase/migrations/.*\.sql$' "$PREFLIGHT_TMP/preflight-diff-files.txt"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The regex anchors are intentional: `(^|/)` accepts both top-level (`supabase/migrations/X.sql`) and nested-under-app-dir paths (`apps/web-platform/supabase/migrations/X.sql`); `.*\.sql$` accepts files at any depth under the migrations directory (matching git pathspec's `*` which crosses `/`). Verify at edit time via `_v=$(mktemp -d -t soleur-run.$$.XXXXXXXX) && a=$(mktemp -p "$_v") && b=$(mktemp -p "$_v") && git diff --name-only origin/main...HEAD -- '*/supabase/migrations/*.sql' supabase/migrations/*.sql > "$a" && grep -E '(^|/)supabase/migrations/.*\.sql$' "$PREFLIGHT_TMP/preflight-diff-files.txt" > "$b" && diff -u "$a" "$b"`.
|
|
133
|
+
|
|
134
|
+
If no migration files are found (grep rc=1), return **SKIP**.
|
|
135
|
+
|
|
136
|
+
**Step 1.1b: Detect documented prd-apply deferral (early SKIP).**
|
|
137
|
+
|
|
138
|
+
If migration files are found, check whether the PR has explicitly
|
|
139
|
+
deferred prd-apply. Two valid signals:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
# (a) Migration checklist documents prd-apply as pending/deferred.
|
|
143
|
+
ckl=$(git ls-files 'knowledge-base/project/specs/feat-*/migration-checklist.md' | head -1)
|
|
144
|
+
if [[ -n "$ckl" ]] && grep -qiE '^## prd apply\s*[-—]+\s*(pending|deferred|done)' "$ckl"; then
|
|
145
|
+
defer_signal="checklist:$(basename $(dirname "$ckl"))/migration-checklist.md"
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
# (b) PR body has `Tracks #N` companion to a `prd apply`/`prd migration` line
|
|
149
|
+
# with a labeled deferred-automation issue (re-use the operator-step-gate shape).
|
|
150
|
+
pr_body=$(gh pr view --json body --jq .body 2>/dev/null || true)
|
|
151
|
+
if printf '%s' "$pr_body" | grep -qiE '(prd[-_ ]apply|prd[-_ ]migration).*(Tracks|Refs) #[0-9]+'; then
|
|
152
|
+
defer_signal="pr-body-tracks"
|
|
153
|
+
fi
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
If `$defer_signal` is non-empty AND the migration-checklist's
|
|
157
|
+
`## prd apply` heading reads **done** (post-apply record), Check 1
|
|
158
|
+
proceeds to Step 1.4 to verify columns actually exist.
|
|
159
|
+
|
|
160
|
+
If `$defer_signal` is non-empty AND the heading reads **pending** or
|
|
161
|
+
**deferred**, return **SKIP** with note: `"prd apply deferred by
|
|
162
|
+
plan/PR — $defer_signal. Check 1 will re-verify post-merge via the
|
|
163
|
+
release workflow's verify-migrations job."` This closes the
|
|
164
|
+
false-positive FAIL class where a plan explicitly sequences the
|
|
165
|
+
prd apply in lockstep with another in-flight PR (e.g., AC-LEGAL-FLIP
|
|
166
|
+
gate). The signal is documented + auditable — operator cannot
|
|
167
|
+
silently skip Check 1 without leaving a paper trail.
|
|
168
|
+
|
|
169
|
+
If `$defer_signal` is empty, fall through to Step 1.2 (the original
|
|
170
|
+
unapplied-migration FAIL path is the correct response).
|
|
171
|
+
|
|
172
|
+
**Why:** PR #4225 (feat-team-workspace-multi-user) — preflight FAIL
|
|
173
|
+
on Check 1 because prd migrations were deferred per
|
|
174
|
+
migration-checklist.md (legal-PR lockstep gate); the headless
|
|
175
|
+
`soleur:ship` halted the pipeline on a known-deferred state. This SKIP
|
|
176
|
+
path honors documented deferrals while keeping the gate active for
|
|
177
|
+
undocumented cases.
|
|
178
|
+
|
|
179
|
+
**Step 1.2: Parse migration SQL for table/column pairs.**
|
|
180
|
+
|
|
181
|
+
For each migration file found in Step 1.1, extract table and column names:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
grep -iE 'ADD COLUMN|CREATE TABLE' <migration_file>
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Parse the output to extract `<table> <column>` pairs. The project uses `ALTER TABLE public.<table> ADD COLUMN IF NOT EXISTS <column> <type>` patterns.
|
|
188
|
+
|
|
189
|
+
**Step 1.3: Check the Supabase credentials exist in Doppler (presence only).**
|
|
190
|
+
|
|
191
|
+
Run these as two separate Bash calls. They print nothing secret: the values are fetched again, inside the one Bash call of Step 1.4, because the Bash tool keeps no shell state between calls.
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
doppler secrets get NEXT_PUBLIC_SUPABASE_URL -p soleur -c prd --plain >/dev/null && echo "NEXT_PUBLIC_SUPABASE_URL present"
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
doppler secrets get SUPABASE_SERVICE_ROLE_KEY -p soleur -c prd --plain >/dev/null && echo "SUPABASE_SERVICE_ROLE_KEY present"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
If either credential is missing, return **SKIP** with note: "Supabase credentials not available in Doppler prd config."
|
|
202
|
+
|
|
203
|
+
**Step 1.4: Verify each column via Supabase REST API.**
|
|
204
|
+
|
|
205
|
+
For each table/column pair extracted in Step 1.2, issue a read-only GET request. Run the whole block as ONE Bash call (it fetches its own credentials; nothing carries over from Step 1.3):
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
SUPABASE_URL="$(doppler secrets get NEXT_PUBLIC_SUPABASE_URL -p soleur -c prd --plain)"
|
|
209
|
+
SERVICE_ROLE_KEY="$(doppler secrets get SUPABASE_SERVICE_ROLE_KEY -p soleur -c prd --plain)"
|
|
210
|
+
# argv shows in /proc/<pid>/cmdline, so the key rides curl's stdin config. Bash only; `bash -x` would print the key.
|
|
211
|
+
(LC_ALL=C; case "$SERVICE_ROLE_KEY" in ''|*[!A-Za-z0-9._~+/=-]*) exit 1;; esac) || { echo "SERVICE_ROLE_KEY unset or not [A-Za-z0-9._~+/=-] (refused, not sent)" >&2; exit 1; }
|
|
212
|
+
curl --disable --proto '=https' -m 30 -fsS --config - "${SUPABASE_URL}/rest/v1/<table>?select=<column>&limit=1" \
|
|
213
|
+
< <(printf 'header = "apikey: %s"\nheader = "Authorization: Bearer %s"\n' "$SERVICE_ROLE_KEY" "$SERVICE_ROLE_KEY")
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- A **200 response** (even empty `[]`) confirms the column exists -- migration is applied.
|
|
217
|
+
- A **400 response** with "column does not exist" confirms the migration was **NOT applied**.
|
|
218
|
+
|
|
219
|
+
**IMPORTANT:** Use only GET requests. The service role key has full write access -- never issue POST, PUT, PATCH, or DELETE.
|
|
220
|
+
|
|
221
|
+
**Result:**
|
|
222
|
+
|
|
223
|
+
- **PASS** -- No migrations in PR, or all migrations verified as applied
|
|
224
|
+
- **FAIL** -- Unapplied migration found (column query returned 400)
|
|
225
|
+
- **SKIP** -- No migration files in PR, or credentials unavailable
|
|
226
|
+
|
|
227
|
+
### Check 2: Security Headers and Parity
|
|
228
|
+
|
|
229
|
+
**Step 2.1: Detect relevant file changes.**
|
|
230
|
+
|
|
231
|
+
Re-use the cached path-set from Phase 0 Step 0.1:
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
cat "$PREFLIGHT_TMP/preflight-diff-files.txt"
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Check if any changed files match these patterns: `.tsx`, `.css`, `.html`, `middleware.ts`, `next.config.*`, `.tf`, `Dockerfile`, `nginx*`, `.github/workflows/*`.
|
|
238
|
+
|
|
239
|
+
If no relevant files changed, return **SKIP** (fast-path — no Doppler fetch needed when the diff has no client-bundle, infra, or workflow surface).
|
|
240
|
+
|
|
241
|
+
**Step 2.2: Get production URL from Doppler.**
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
doppler secrets get NEXT_PUBLIC_APP_URL -p soleur -c prd --plain
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
If no URL is available, return **SKIP** with note: "Production URL not available."
|
|
248
|
+
|
|
249
|
+
**Step 2.3: Fetch response headers.**
|
|
250
|
+
|
|
251
|
+
Fetch headers from the root page (not `/health` -- the health endpoint skips CSP):
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
curl -sI <PRODUCTION_URL>/
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
**Step 2.4: Validate mandatory security headers.**
|
|
258
|
+
|
|
259
|
+
Check the response against the project's security header policy derived from `apps/web-platform/lib/security-headers.ts`:
|
|
260
|
+
|
|
261
|
+
| Header | Expected Value | Severity if Missing |
|
|
262
|
+
|--------|---------------|-------------------|
|
|
263
|
+
| Content-Security-Policy | Contains `strict-dynamic` + nonce | FAIL |
|
|
264
|
+
| X-Frame-Options | `DENY` | FAIL |
|
|
265
|
+
| X-Content-Type-Options | `nosniff` | FAIL |
|
|
266
|
+
| Strict-Transport-Security | `max-age=63072000; includeSubDomains; preload` | FAIL |
|
|
267
|
+
| Referrer-Policy | `strict-origin-when-cross-origin` | PASS (non-critical) |
|
|
268
|
+
| Permissions-Policy | Contains `camera=(), microphone=()` | PASS (non-critical) |
|
|
269
|
+
| Cross-Origin-Opener-Policy | `same-origin` | PASS (non-critical) |
|
|
270
|
+
| Cross-Origin-Resource-Policy | `same-origin` | PASS (non-critical) |
|
|
271
|
+
| X-DNS-Prefetch-Control | `on` | PASS (non-critical) |
|
|
272
|
+
|
|
273
|
+
**Step 2.5: Validate CSP directive structure.**
|
|
274
|
+
|
|
275
|
+
CSP is generated per-request in middleware.ts with a per-request nonce via `crypto.randomUUID()`. Validate the CSP structure, not the specific nonce value:
|
|
276
|
+
|
|
277
|
+
- Verify `strict-dynamic` is present in `script-src`
|
|
278
|
+
- Verify no standalone `unsafe-inline` without `strict-dynamic` override
|
|
279
|
+
- Parse CSP as directive-level tokens
|
|
280
|
+
|
|
281
|
+
**Notes:**
|
|
282
|
+
|
|
283
|
+
- Cloudflare-injected headers (`cf-ray`, `server: cloudflare`) are expected in production -- ignore them.
|
|
284
|
+
- The `/health` endpoint explicitly skips CSP in middleware -- always fetch `/` for the full header set.
|
|
285
|
+
- **Limitation (v1):** This check validates the current production deployment, not the branch under review. It catches existing header regressions but cannot detect regressions introduced by the current PR until after deployment. Preview deployments would enable pre-merge header validation (deferred to v2).
|
|
286
|
+
|
|
287
|
+
**Result:**
|
|
288
|
+
|
|
289
|
+
- **PASS** -- All critical headers present and valid (CSP, X-Frame-Options, X-Content-Type-Options, HSTS)
|
|
290
|
+
- **FAIL** -- Any critical header missing or invalid
|
|
291
|
+
- **SKIP** -- No relevant file changes or no production URL available
|
|
292
|
+
|
|
293
|
+
### Check 3: Lockfile Consistency
|
|
294
|
+
|
|
295
|
+
Since ADR-191 this repository carries exactly ONE lockfile per directory
|
|
296
|
+
(`package-lock.json`); `bun.lock` is deleted and [lint-dual-lockfile.sh](../../../../scripts/lint-dual-lockfile.sh) fails if
|
|
297
|
+
one reappears. The sibling-consistency logic below therefore no longer fires here — it is
|
|
298
|
+
retained because this skill also runs against tenant repositories, which may legitimately
|
|
299
|
+
still carry dual lockfiles.
|
|
300
|
+
|
|
301
|
+
**Step 3.1: Detect lockfile modifications in this branch.**
|
|
302
|
+
|
|
303
|
+
```bash
|
|
304
|
+
git diff --name-status origin/main...HEAD -- '*/bun.lock' '*/package-lock.json' 'bun.lock' 'package-lock.json'
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
This returns status letters (M=modified, A=added, D=deleted) alongside file paths. If no output, return **SKIP**.
|
|
308
|
+
|
|
309
|
+
Only lockfiles with status **M** (modified) trigger the consistency check. Added (A) or deleted (D) lockfiles are one-time structural changes that do not require sibling updates.
|
|
310
|
+
|
|
311
|
+
**Step 3.2: For each modified lockfile, verify its sibling.**
|
|
312
|
+
|
|
313
|
+
For each lockfile with status M in the Step 3.1 output:
|
|
314
|
+
|
|
315
|
+
1. Extract the directory path (e.g., `apps/web-platform` from `apps/web-platform/bun.lock`). For root-level lockfiles (`bun.lock` or `package-lock.json` with no path prefix), use the repository root directory.
|
|
316
|
+
2. Determine the sibling: if the modified file is `bun.lock`, the sibling is `package-lock.json` (and vice versa).
|
|
317
|
+
3. Check if the sibling exists in the working tree (separate Bash call):
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
test -f <directory>/package-lock.json && echo "exists" || echo "missing"
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
4. If the sibling does NOT exist (single-lockfile directory), skip this file -- no consistency check needed.
|
|
324
|
+
5. If the sibling exists (dual-lockfile directory), check whether the sibling also appears in the Step 3.1 output (any status: M, A, or D). If the sibling is NOT in the diff at all, report **FAIL**: "`<directory>/` modified `bun.lock` but not `package-lock.json`. Both lockfiles must be updated together (see AGENTS.md dual-lockfile rule). Run `npm install` in `<directory>/` to regenerate `package-lock.json`." (If `package-lock.json` was modified without `bun.lock`, say: "Run `bun install` in `<directory>/` to regenerate `bun.lock`.")
|
|
325
|
+
|
|
326
|
+
If multiple files fail, report each one. Any single failure means the overall check result is FAIL.
|
|
327
|
+
|
|
328
|
+
**Result:**
|
|
329
|
+
|
|
330
|
+
- **PASS** -- All modified lockfiles in dual-lockfile directories have consistent sibling updates
|
|
331
|
+
- **FAIL** -- One or more dual-lockfile directories have a modified lockfile without its sibling updated (message names each directory and missing file)
|
|
332
|
+
- **SKIP** -- No lockfile changes in this branch
|
|
333
|
+
|
|
334
|
+
### Check 4: Environment Isolation
|
|
335
|
+
|
|
336
|
+
**Always runs (no path-pattern gate).** Enforces `hr-dev-prd-distinct-supabase-projects`: dev and prd Doppler configs must resolve to different Supabase project refs.
|
|
337
|
+
|
|
338
|
+
**Step 4.1: Fetch dev and prd Supabase URLs.**
|
|
339
|
+
|
|
340
|
+
Run as separate Bash calls (no command substitution per skill convention):
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
doppler secrets get NEXT_PUBLIC_SUPABASE_URL -p soleur -c dev --plain
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
doppler secrets get NEXT_PUBLIC_SUPABASE_URL -p soleur -c prd --plain
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
If either call fails or returns empty, return **SKIP** with note: "Doppler unavailable or NEXT_PUBLIC_SUPABASE_URL unset."
|
|
351
|
+
|
|
352
|
+
**Step 4.2: Resolve project ref for each URL.**
|
|
353
|
+
|
|
354
|
+
The single chokepoint is the canonical-hostname regex `^[a-z0-9]{20}\.supabase\.co$`. Both branches below MUST converge on a hostname matching that regex before Step 4.3 runs. The canonical resolver lives at `apps/web-platform/scripts/lib/supabase-ref-resolver.sh` (`resolve_supabase_ref`); the same shape is mirrored in `apps/web-platform/lib/supabase/resolve-ref.ts` for TS callers and consumed by `.github/workflows/reusable-release.yml` via `source`. The sub-bullets below describe Check 4's wrapper semantics on top of the resolver (e.g., the A-record-only FAIL path is a Check 4 policy, not a resolver behavior).
|
|
355
|
+
|
|
356
|
+
1. Strip the `https://` prefix and any trailing path; keep the bare host.
|
|
357
|
+
2. If the bare host already matches the canonical regex, use it directly.
|
|
358
|
+
3. Otherwise (custom domain), resolve via `dig`:
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
dig +short CNAME <host>
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Capture the exit code separately. Strict-mode resilience: do not pipe through `|| true` — that masks SERVFAIL/network errors as "no record" and silently disables the gate. Branch on the captured rc:
|
|
365
|
+
|
|
366
|
+
- `rc == 0` and output non-empty: candidate hostname is the CNAME target (strip trailing dot).
|
|
367
|
+
- `rc == 0` and output empty: no CNAME exists. Fall back to `dig +short A <host>`. If the A-record resolves to a Supabase IP range, **FAIL** with: "Custom domain `<host>` uses A-record-only Supabase routing. Check 4 cannot prove project ref. Configure CNAME-based custom domain or temporarily set Doppler `<config>.NEXT_PUBLIC_SUPABASE_URL` to the bare `<ref>.supabase.co` form for the isolation check." A-records are rare for Supabase custom domains; failing is correct because SKIPping fails-open the security gate.
|
|
368
|
+
- `rc != 0`: SERVFAIL, NXDOMAIN, network error, etc. Return **SKIP** with diagnostic: "dig exit `<rc>` for `<host>` — DNS resolution unavailable; isolation check inconclusive." (SKIP only when the diagnostic is genuinely undetermined; A-record-only is determined and FAILs.)
|
|
369
|
+
- `dig` not installed (the resolver script exits 1 with "'dig' is not installed"): do NOT SKIP — resolve the CNAME with `resolvectl query --type=CNAME <host>` (systemd hosts) and feed the target through the same canonical-regex check; a SKIP here fails the security gate open on a tooling gap, not a DNS answer. Measured on PR #8354 (`api.soleur.ai` → `<ref>.supabase.co` via resolvectl).
|
|
370
|
+
4. Verify the resulting hostname matches `^[a-z0-9]{20}\.supabase\.co$`. If it does not, **FAIL** with: "Resolved hostname `<host>` is not a canonical Supabase project endpoint. Refusing to compare on a non-canonical name (subdomain-bypass guard)." This catches inputs like `<ref>.supabase.co.evil.com` that pass step 1 but fail the anchored regex.
|
|
371
|
+
|
|
372
|
+
The 20-char first label of a canonical hostname IS the project ref — extract via the literal first label or by stripping `.supabase.co`.
|
|
373
|
+
|
|
374
|
+
**Step 4.3: Compare project refs.**
|
|
375
|
+
|
|
376
|
+
If `dev_ref == prd_ref`, **FAIL** with: "Environment isolation violation: dev and prd resolve to the same Supabase project ref `<ref>`. See issue #2887."
|
|
377
|
+
|
|
378
|
+
Otherwise **PASS**.
|
|
379
|
+
|
|
380
|
+
**Result:**
|
|
381
|
+
|
|
382
|
+
- **PASS** -- dev and prd resolve to distinct project refs
|
|
383
|
+
- **FAIL** -- refs match (single-DB blast radius), hostname is not a canonical Supabase endpoint, or custom domain uses A-record-only routing
|
|
384
|
+
- **SKIP** -- Doppler unavailable, NEXT_PUBLIC_SUPABASE_URL unset in either config, or DNS resolution failed (`dig` rc != 0)
|
|
385
|
+
|
|
386
|
+
### Check 5: Production Bundle Supabase Host
|
|
387
|
+
|
|
388
|
+
**Path-gated:** only runs when the cached path-set from Phase 0 Step 0.1 (`"$PREFLIGHT_TMP/preflight-diff-files.txt"`) contains any of `apps/web-platform/lib/supabase/client.ts`, `apps/web-platform/lib/supabase/validate-url.ts`, `apps/web-platform/lib/supabase/validate-anon-key.ts`, `apps/web-platform/Dockerfile`, `.github/workflows/reusable-release.yml`, or `apps/web-platform/scripts/verify-required-secrets.sh`. Otherwise return **SKIP** with note: "No build-arg surface changes detected." The path predicate is identical to the original `git diff --name-only origin/main...HEAD` form — only the diff source is changed.
|
|
389
|
+
|
|
390
|
+
**Note:** Complements Check 4. Check 4 enforces Doppler dev/prd isolation; Check 5 covers the GitHub-repo-secrets surface that feeds the prod Docker build (`secrets.NEXT_PUBLIC_SUPABASE_URL` and `secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY` in `reusable-release.yml`). The two sources can drift — see `knowledge-base/project/learnings/bug-fixes/2026-04-28-oauth-supabase-url-test-fixture-leaked-into-prod-build.md` (URL class) and `knowledge-base/project/learnings/bug-fixes/2026-04-28-anon-key-test-fixture-leaked-into-prod-build.md` (anon-key class).
|
|
391
|
+
|
|
392
|
+
**Step 5.1: Discover the candidate chunk set.**
|
|
393
|
+
|
|
394
|
+
Webpack chunking is not stable across releases — the inlined Supabase init may live in the login page chunk, in a numeric shared chunk (`/_next/static/chunks/8237-*.js`), or in a layout chunk. Hardcoding a single path produces SKIP-on-chunking-change, which silently disables the gate (issue #3010). Discover the candidate set dynamically by enumerating every chunk URL the login HTML references — that is the authoritative "what /login loads" surface.
|
|
395
|
+
|
|
396
|
+
Run as separate Bash calls (no command substitution per skill convention). Each block
|
|
397
|
+
re-derives `PREFLIGHT_TMP` — it is `$(git rev-parse --git-dir)`, so it is stable across
|
|
398
|
+
separate Bash calls (which do NOT inherit env) AND distinct per worktree, which is what a
|
|
399
|
+
later call needs to find these artifacts by name without colliding with a sibling session:
|
|
400
|
+
|
|
401
|
+
```bash
|
|
402
|
+
PREFLIGHT_TMP="$(git rev-parse --git-dir)"
|
|
403
|
+
curl -fsSL --max-time 10 -A "Mozilla/5.0" https://app.soleur.ai/login -o "$PREFLIGHT_TMP/preflight-login.html"
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
```bash
|
|
407
|
+
PREFLIGHT_TMP="$(git rev-parse --git-dir)"
|
|
408
|
+
grep -oE '/_next/static/chunks/[^"]+\.js' "$PREFLIGHT_TMP/preflight-login.html" | awk '!seen[$0]++' | head -20 > "$PREFLIGHT_TMP/preflight-candidates.txt"
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
The cap of 20 is generous (current prod loads 13 chunks); if ever hit on a future release, prefer raising the cap over reverting to the hardcoded login-chunk path. The grep matches both `<script src=...>` and `<link rel=preload href=...>` references — both are valid candidates.
|
|
412
|
+
|
|
413
|
+
If the `curl` fails (rc != 0) or `"$PREFLIGHT_TMP/preflight-candidates.txt"` is empty, return **SKIP** with note: "Could not fetch /login HTML or could not locate any /_next/static/chunks references."
|
|
414
|
+
|
|
415
|
+
**Step 5.2: Probe each candidate chunk for Supabase shapes.**
|
|
416
|
+
|
|
417
|
+
The Supabase host string and the inlined anon-key JWT may live in DIFFERENT chunks (verified 2026-04-29 against current prod: `8237-*.js` carries the JWT but contains zero `supabase.co` host strings). Track `host_union` (every chunk's supabase-host hits) and `jwt_chunk` (the first chunk with a JWT) independently. Always traverse the full candidate list — bail-early would skip chunks that may carry a placeholder-host leak (matrix row 6).
|
|
418
|
+
|
|
419
|
+
This block is operator-executed under the skill's `set -euo pipefail` convention. Failed `curl` per chunk and `grep` rc=1 on no-match must NOT abort the loop — the gate-level SKIP/FAIL decision is made from the accumulated state at end-of-loop, not from per-iteration rc.
|
|
420
|
+
|
|
421
|
+
```bash
|
|
422
|
+
PREFLIGHT_TMP="$(git rev-parse --git-dir)"
|
|
423
|
+
mkdir -p "$PREFLIGHT_TMP/preflight-chunks"
|
|
424
|
+
host_union=""
|
|
425
|
+
jwt_chunk=""
|
|
426
|
+
while IFS= read -r chunk_path; do
|
|
427
|
+
# Defense-in-depth: validate chunk_path is a clean Next.js static-chunks subpath
|
|
428
|
+
# before interpolating into the curl URL (no `..`, no `@`, no `?`, no whitespace).
|
|
429
|
+
[[ "$chunk_path" =~ ^/_next/static/chunks/[A-Za-z0-9_/().-]+\.js$ ]] || continue
|
|
430
|
+
base=$(basename "$chunk_path")
|
|
431
|
+
curl -fsSL --max-time 10 --max-filesize 5242880 "https://app.soleur.ai${chunk_path}" -o "$PREFLIGHT_TMP/preflight-chunks/${base}" || continue
|
|
432
|
+
hosts=$(grep -oE 'https?://([a-z0-9.-]*supabase\.co|api\.soleur\.ai)' "$PREFLIGHT_TMP/preflight-chunks/${base}" | sort -u || true)
|
|
433
|
+
if [[ -n "$hosts" ]]; then
|
|
434
|
+
host_union="${host_union}${hosts}"$'\n'
|
|
435
|
+
fi
|
|
436
|
+
if [[ -z "$jwt_chunk" ]]; then
|
|
437
|
+
jwt=$(grep -oE 'eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' "$PREFLIGHT_TMP/preflight-chunks/${base}" | head -1 || true)
|
|
438
|
+
if [[ -n "$jwt" ]]; then
|
|
439
|
+
jwt_chunk="$PREFLIGHT_TMP/preflight-chunks/${base}"
|
|
440
|
+
fi
|
|
441
|
+
fi
|
|
442
|
+
done < "$PREFLIGHT_TMP/preflight-candidates.txt"
|
|
443
|
+
|
|
444
|
+
printf '%s' "$host_union" | sort -u
|
|
445
|
+
printf 'jwt_chunk=%s\n' "${jwt_chunk:-<none>}"
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
The redirected-stdin form (`< "$PREFLIGHT_TMP/preflight-candidates.txt"`) is required — piping (`cat ... | while read`) scopes loop variables to a subshell and loses `host_union` / `jwt_chunk` at loop exit. The `--max-filesize 5242880` (5 MB) cap defends against a misbehaving CDN response filling tmpfs across 20 fetches. The strict path regex rejects `..`, `@`, `?`, and whitespace before any string interpolation into the curl URL — a defense-in-depth gate even though the source HTML is served by our own CDN. Full traversal (no early-break) ensures placeholder-host leaks in late-candidate chunks are still detected (matrix row 6).
|
|
449
|
+
|
|
450
|
+
**Step 5.3: Assert canonical shape.**
|
|
451
|
+
|
|
452
|
+
The `host_union` from Step 5.2 must contain at least one host matching `^https://([a-z0-9]{20}\.supabase\.co|api\.soleur\.ai)$` and zero placeholder hosts (`test.supabase.co`, `placeholder.supabase.co`, `example.supabase.co`, `localhost`, `0.0.0.0`).
|
|
453
|
+
|
|
454
|
+
**Step 5.4: Decode and assert JWT claims from `jwt_chunk`.**
|
|
455
|
+
|
|
456
|
+
Pre-condition: `jwt_chunk` is non-empty after Step 5.2's traversal. If `jwt_chunk` is empty AND `host_union` is non-empty (canonical Supabase host found but no JWT in any of the 20 candidate chunks), return **FAIL** with note: "Supabase host found but no JWT in any of 20 candidate chunks — bundle is structurally inconsistent (host without key) and indicates a build regression." If `jwt_chunk` is empty AND `host_union` is empty (the full traversal yielded nothing), return **SKIP** with note: "Supabase init not present in any of the 20 candidate chunks loaded by /login — possible deeper Webpack restructure or app-shell split. Investigate manually (probe /dashboard or other authed routes)."
|
|
457
|
+
|
|
458
|
+
```bash
|
|
459
|
+
JWT=$(grep -oE 'eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' "$jwt_chunk" | head -1)
|
|
460
|
+
PAYLOAD=$(printf '%s' "$JWT" | cut -d. -f2)
|
|
461
|
+
PAD=$(( (4 - ${#PAYLOAD} % 4) % 4 ))
|
|
462
|
+
if [[ $PAD -gt 0 ]]; then PADDED="$PAYLOAD$(printf '=%.0s' $(seq 1 $PAD))"; else PADDED="$PAYLOAD"; fi
|
|
463
|
+
JSON=$(printf '%s' "$PADDED" | tr '_-' '/+' | base64 -d 2>/dev/null)
|
|
464
|
+
iss=$(printf '%s' "$JSON" | jq -er '.iss // ""') || { echo "FAIL: JWT payload not parseable as JSON (.iss missing or invalid)"; exit 1; }
|
|
465
|
+
role=$(printf '%s' "$JSON" | jq -er '.role // ""') || { echo "FAIL: JWT payload missing .role (security-gate fail-closed)"; exit 1; }
|
|
466
|
+
ref=$(printf '%s' "$JSON" | jq -er '.ref // ""') || { echo "FAIL: JWT payload missing .ref (security-gate fail-closed)"; exit 1; }
|
|
467
|
+
# Log-injection guard: strip C0 controls (NUL..US), DEL, and Unicode line separators
|
|
468
|
+
# (U+2028, U+2029) before any echo. Defends against ANSI escape sequences, terminal
|
|
469
|
+
# control chars, and GitHub Actions `::cmd::` smuggling via newlines or U+2028.
|
|
470
|
+
sanitize() { printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177' | LC_ALL=C sed $'s/\xe2\x80\xa8//g; s/\xe2\x80\xa9//g'; }
|
|
471
|
+
iss_safe=$(sanitize "$iss")
|
|
472
|
+
role_safe=$(sanitize "$role")
|
|
473
|
+
ref_safe=$(sanitize "$ref")
|
|
474
|
+
printf 'iss=%s role=%s ref=%s\n' "$iss_safe" "$role_safe" "$ref_safe"
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`jq -er` (raise on null/error) plus the explicit FAIL-on-non-zero-rc enforces fail-closed semantics on every claim (`iss`, `role`, `ref`) if the JWT regex matches a `eyJ...` literal that is not a valid base64-encoded JSON or is missing a required claim. The `sanitize()` helper strips C0 controls (`\x00`–`\x1f`), DEL (`\x7f`), and Unicode line separators (U+2028, U+2029) before any echo — `jq -r` does not escape control characters, so a crafted JWT could otherwise smuggle ANSI escapes (e.g., `\x1b[2J` to clear the operator's terminal), `::notice::PASS` GitHub Actions annotations via `\n`, or U+2028/2029 line breaks invisible to `${var//$'\n'/}` (precedent: `2026-04-28-anon-key-test-fixture-leaked-into-prod-build` Session Error #6).
|
|
478
|
+
|
|
479
|
+
The decoded claims MUST satisfy: `iss == "supabase"`, `role == "anon"`, `ref` matches `^[a-z0-9]{20}$`, and `ref` is not in the placeholder set (`test*`, `placeholder*`, `example*`, `service*`, `local*`, `dev*`, `stub*`).
|
|
480
|
+
|
|
481
|
+
**Result (eight-row decision matrix):**
|
|
482
|
+
|
|
483
|
+
Per `knowledge-base/project/learnings/2026-04-27-preflight-security-gates-skip-vs-fail-defaults.md` — Check 5 is an invariant gate, so SKIP only when truly indeterminate; FAIL when partial-observation contradicts the invariant.
|
|
484
|
+
|
|
485
|
+
| Host union | JWT discovered | Result | Rationale |
|
|
486
|
+
| --- | --- | --- | --- |
|
|
487
|
+
| Login HTML fetch failed (Step 5.1) | n/a | **SKIP** | Truly indeterminate — operator cannot run Check 5 against an unreachable origin. |
|
|
488
|
+
| Login HTML fetched but zero `<script src>` matches | n/a | **SKIP** | Truly indeterminate — bundle structure unrecognizable. |
|
|
489
|
+
| ≥1 canonical Supabase host AND JWT discovered AND JWT claims canonical | yes (canonical) | **PASS** | Invariant proven across (possibly different) chunks. |
|
|
490
|
+
| ≥1 canonical Supabase host AND JWT discovered AND JWT claims non-canonical (placeholder ref, role≠anon, iss≠supabase) | yes (broken) | **FAIL** | Invariant DISPROVEN — leak detected; rotate the corresponding GitHub repo secret. |
|
|
491
|
+
| ≥1 canonical Supabase host AND no JWT after full 20-traversal | no | **FAIL** | Bundle is structurally inconsistent (host without key); invariant cannot hold. |
|
|
492
|
+
| ≥1 placeholder host (`test.supabase.co`, `placeholder.supabase.co`, etc.) anywhere in any examined chunk | any | **FAIL** | Placeholder URL leaked into the bundle (the original PR #2975 class). |
|
|
493
|
+
| Zero Supabase host references AND zero JWTs after full 20-traversal | no | **SKIP** | Truly indeterminate — Supabase init not reachable from `/login`. Investigate manually (probe `/dashboard` or other authed routes). |
|
|
494
|
+
| JWT regex matches but base64 decode / `jq` parse fails | invalid | **FAIL** | Discovered structure looks like a JWT but cannot be parsed — fail-closed. |
|
|
495
|
+
|
|
496
|
+
SKIP-on-chunking-change (the issue #3010 failure mode) is gone: a chunking change that moves the JWT to a different chunk now PASSes via Step 5.2's traversal. A future "simplify the result block" commit that collapses row 5 ("host without JWT after traversal") back to SKIP would silently re-introduce the fail-open class that #2887/#2903 already paid for — see the 2026-04-27 learning.
|
|
497
|
+
|
|
498
|
+
### Check 6: Brand-Survival Self-Review
|
|
499
|
+
|
|
500
|
+
Enforces `hr-weigh-every-decision-against-target-user-impact`: PRs that touch credentials, auth, data, payments, or user-owned resources must declare a `## User-Brand Impact` section (with a valid threshold) in the PR body. The section is the ship-time signal that the framing question was answered before the change reached production.
|
|
501
|
+
|
|
502
|
+
**Step 6.1: Detect sensitive-path diff.**
|
|
503
|
+
|
|
504
|
+
The canonical sensitive-path regex (single source of truth, mirrored verbatim in Check 10 Step 10.1, `plugins/soleur/skills/deepen-plan/SKILL.md` Phase 4.6 Step 2, and `plugins/soleur/skills/review/scripts/fix-round-seats.sh` `SENSITIVE_PATH_RE`):
|
|
505
|
+
|
|
506
|
+
```bash
|
|
507
|
+
SENSITIVE_PATH_RE='^(apps/web-platform/(server|supabase|app/api|middleware\.ts$)|apps/web-platform/lib/(stripe|auth|byok|security-headers|csp|log-sanitize|safe-session|safe-return-to|supabase)|apps/web-platform/lib/(legal|auth)/|apps/[^/]+/infra/|.+/doppler[^/]*\.(yml|yaml|sh)$|\.github/workflows/.*(doppler|secret|token|deploy|release|version-bump|web-platform|infra-validation|cla|cf-token|linkedin-token).*\.ya?ml$)'
|
|
508
|
+
|
|
509
|
+
set -uo pipefail
|
|
510
|
+
grep -E "$SENSITIVE_PATH_RE" "$PREFLIGHT_TMP/preflight-diff-files.txt"
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Run as two separate Bash calls (assignment, then the `grep`). The path-set comes from the cached file written by Phase 0 Step 0.1 — the regex itself is byte-identical to the form previously consumed by `git diff --name-only origin/main...HEAD | grep -E "$SENSITIVE_PATH_RE"`. The regex covers every Next.js attack surface (`middleware.ts`, every `app/api/**` route, security/CSP/auth/session/legal libraries, infra Terraform under `apps/*/infra/`, all Doppler-aware shell scripts, and every credential-handling workflow even when `doppler` is not in the filename).
|
|
514
|
+
|
|
515
|
+
If `grep` exits non-zero (no match), return **SKIP** with note: "No sensitive paths touched."
|
|
516
|
+
|
|
517
|
+
**Step 6.2: Resolve the canonical compliance source (Shared Plan-File Resolution).**
|
|
518
|
+
|
|
519
|
+
Call **Shared Plan-File Resolution** (above Check 1). It sets `$PR_BODY_FILE`, `$SCRUBBED_BODY`, `$PLAN_PATH`, and `$COMBINED` for this check to consume. If `gh pr view` fails (no PR exists for the current branch), return **SKIP** with note: "No PR available — section validation deferred to next preflight run after PR creation."
|
|
520
|
+
|
|
521
|
+
The `## User-Brand Impact` section may live in the PR body itself (typical for short PRs) OR in a plan file referenced from the PR body (typical for plans authored via `soleur:plan`). Both signals are valid per `plugins/soleur/skills/review/SKILL.md` `<conditional_agents>` block. Shared Plan-File Resolution produces a `$COMBINED` input that contains both — scrubbed of HTML comments and fenced code blocks so a markdown example inside ` ``` ` cannot fool a substring match.
|
|
522
|
+
|
|
523
|
+
**Step 6.4: Check for the section heading.**
|
|
524
|
+
|
|
525
|
+
```bash
|
|
526
|
+
grep -q '^## User-Brand Impact' "$COMBINED"
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
If absent, return **FAIL** with: "Sensitive-path diff detected but neither PR body nor linked plan file contains a `## User-Brand Impact` section. Add the section per `${CLAUDE_PLUGIN_ROOT}/skills/plan/references/plan-issue-templates.md`."
|
|
530
|
+
|
|
531
|
+
**Step 6.5: Validate threshold and scope-out — anchored, not substring.**
|
|
532
|
+
|
|
533
|
+
Extract the threshold line. Require canonical bullet form (`- **Brand-survival threshold:** …`) so a free-text sentence containing the words "single-user incident" cannot pass:
|
|
534
|
+
|
|
535
|
+
```bash
|
|
536
|
+
THRESHOLD_LINE=$(grep -E '^[[:space:]]*[-*][[:space:]]+\*\*Brand-survival threshold:\*\*' "$COMBINED" | head -n 1)
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
If `$THRESHOLD_LINE` is empty, return **FAIL** with: "User-Brand Impact section present but the canonical bullet `- **Brand-survival threshold:** <label>` is missing. Use exactly the bullet form from `plan-issue-templates.md`."
|
|
540
|
+
|
|
541
|
+
Match the label as a discrete token. The value must follow the `**Brand-survival threshold:**` marker as an isolated word (optionally backticked) terminated by a value-boundary character (end-of-line, period, comma, semicolon, em-dash, or hyphen surrounded by spaces). This permits trailing commentary like `` `single-user incident` — explanation… `` while rejecting embedded substrings like "this is not a single-user incident":
|
|
542
|
+
|
|
543
|
+
```bash
|
|
544
|
+
# Boundary regex: end-of-line OR punctuation/dash that indicates "value ends here, commentary follows"
|
|
545
|
+
BOUNDARY='($|[[:space:]]*[.,;]|[[:space:]]+[—–-][[:space:]])'
|
|
546
|
+
if [[ "$THRESHOLD_LINE" =~ \*\*Brand-survival[[:space:]]+threshold:\*\*[[:space:]]+\`?single-user[[:space:]]+incident\`?$BOUNDARY ]]; then
|
|
547
|
+
RESULT=PASS_INCIDENT
|
|
548
|
+
elif [[ "$THRESHOLD_LINE" =~ \*\*Brand-survival[[:space:]]+threshold:\*\*[[:space:]]+\`?aggregate[[:space:]]+pattern\`?$BOUNDARY ]]; then
|
|
549
|
+
RESULT=PASS_AGGREGATE
|
|
550
|
+
elif [[ "$THRESHOLD_LINE" =~ \*\*Brand-survival[[:space:]]+threshold:\*\*[[:space:]]+\`?none\`?$BOUNDARY ]]; then
|
|
551
|
+
RESULT=NEEDS_SCOPEOUT
|
|
552
|
+
else
|
|
553
|
+
echo "FAIL: threshold value not recognized. The value must immediately follow \`**Brand-survival threshold:**\` and be one of: \`single-user incident\` | \`aggregate pattern\` | \`none\` (terminated by end-of-line, punctuation, or em-dash + space)."
|
|
554
|
+
exit 1
|
|
555
|
+
fi
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
If `RESULT=NEEDS_SCOPEOUT`, look for the scope-out bullet — require a non-empty `reason:` (the `\S` after `reason:` is what enforces "explain yourself"):
|
|
559
|
+
|
|
560
|
+
```bash
|
|
561
|
+
grep -Eq 'threshold:[[:space:]]*none,[[:space:]]*reason:[[:space:]]*\S' "$COMBINED"
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
- Match (rc 0): **PASS** — operator has justified why the touched sensitive path is not user-impacting.
|
|
565
|
+
- No match: **FAIL** with: "Sensitive-path diff with `threshold: none` requires a `threshold: none, reason: <one-sentence>` scope-out bullet (with a non-empty reason) inside the User-Brand Impact section."
|
|
566
|
+
|
|
567
|
+
If `RESULT=PASS_INCIDENT` or `RESULT=PASS_AGGREGATE`: **PASS**.
|
|
568
|
+
|
|
569
|
+
**Headless mode behaviour:** On **FAIL**, abort with the error details (no prompt). On **PASS** or **SKIP**, continue silently.
|
|
570
|
+
|
|
571
|
+
**Interactive mode behaviour:** On **FAIL**, present the failure reason and offer **AskUserQuestion** with options:
|
|
572
|
+
|
|
573
|
+
1. "Fill in the section now" — prompt the operator for the three required lines (artifact / vector / threshold), append to the PR body via `gh pr edit --body-file -`, re-run Check 6.
|
|
574
|
+
2. "Add scope-out note" — if the threshold should defensibly be `none`, prompt for the one-sentence reason, append `threshold: none, reason: <reason>` to the section, re-run Check 6.
|
|
575
|
+
3. "Abort — fix elsewhere" — stop the pipeline; operator handles in another tool.
|
|
576
|
+
|
|
577
|
+
**Result:**
|
|
578
|
+
|
|
579
|
+
- **PASS** — No sensitive paths touched, OR section present with `single-user incident`/`aggregate pattern` threshold, OR section present with `none` threshold AND a valid scope-out note.
|
|
580
|
+
- **FAIL** — Sensitive-path diff with missing or empty User-Brand Impact section; OR `none` threshold without scope-out; OR missing/invalid threshold line.
|
|
581
|
+
- **SKIP** — No sensitive paths touched, OR no PR exists yet (defer to post-PR run).
|
|
582
|
+
|
|
583
|
+
### Check 8: Service Worker Cache Bump on Client-Bundle Regression Fix
|
|
584
|
+
|
|
585
|
+
**Path-gated:** only runs when the cached path-set from Phase 0 Step 0.1 (`"$PREFLIGHT_TMP/preflight-diff-files.txt"`) AND the commit log together satisfy BOTH a regression-fix marker (a commit subject starting with `fix(`, `fix:`, or `hotfix`) AND any file under `apps/web-platform/lib/supabase/`, `apps/web-platform/sentry.client.config.ts`, `apps/web-platform/lib/auth/`, `apps/web-platform/lib/byok/`, or `apps/web-platform/components/error-boundary-view.tsx`. Otherwise return **SKIP** with note: "No client-bundle regression-fix surface detected." The path predicate is identical to the original `git diff --name-only origin/main...HEAD` form — only the diff source is changed.
|
|
586
|
+
|
|
587
|
+
**Rationale:** Content-hashed Next.js chunks normally invalidate cache automatically — new content → new filename → SW cache miss → fresh fetch. But the SW at `apps/web-platform/public/sw.js` uses a cache-first strategy under a single `CACHE_NAME` for `/_next/static/**`, and old broken chunks remain cached under their old filenames until either (a) browser eviction, or (b) the activate handler purges them via a `CACHE_NAME` bump. After PR #3014 deployed v0.58.0 with a corrected validator, users still saw the dashboard error.tsx because their SW was serving cached PR #3007 chunks. Without bumping `CACHE_NAME`, a regression fix to the inlined client bundle relies on every user manually clearing site data — unacceptable for an auth-tree outage.
|
|
588
|
+
|
|
589
|
+
**Step 8.1: Detect regression-fix commit subject.**
|
|
590
|
+
|
|
591
|
+
```bash
|
|
592
|
+
git log origin/main..HEAD --pretty=%s | grep -iE '^(fix\(|fix:|hotfix)' | head -1
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
If empty, return **SKIP** with note: "No fix(...) commit on branch."
|
|
596
|
+
|
|
597
|
+
**Step 8.2: Detect client-bundle surface in diff.**
|
|
598
|
+
|
|
599
|
+
Re-use the cached path-set from Phase 0 Step 0.1:
|
|
600
|
+
|
|
601
|
+
```bash
|
|
602
|
+
grep -E '^apps/web-platform/(lib/(supabase|auth|byok)/|sentry\.client\.config\.ts|components/error-boundary-view\.tsx)' "$PREFLIGHT_TMP/preflight-diff-files.txt" | head -1
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
The regex is byte-identical to the form previously piped from `git diff --name-only origin/main...HEAD`. If empty, return **SKIP** with note: "No client-bundle surface touched."
|
|
606
|
+
|
|
607
|
+
**Step 8.3: Compare `CACHE_NAME` against `origin/main`.**
|
|
608
|
+
|
|
609
|
+
Run as separate Bash calls (no command substitution per skill convention):
|
|
610
|
+
|
|
611
|
+
```bash
|
|
612
|
+
git show origin/main:apps/web-platform/public/sw.js 2>/dev/null | grep -oE 'CACHE_NAME[[:space:]]*=[[:space:]]*"[^"]+"' | head -1
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
```bash
|
|
616
|
+
grep -oE 'CACHE_NAME[[:space:]]*=[[:space:]]*"[^"]+"' apps/web-platform/public/sw.js | head -1
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
If the two values are byte-equal, **FAIL** with: "Client-bundle regression fix detected on branch but `CACHE_NAME` in `apps/web-platform/public/sw.js` was not bumped. Old chunks remain cached for users with an active SW registration. Bump the suffix (e.g., `v2` → `v3`) so the activate handler purges stale caches on next page load."
|
|
620
|
+
|
|
621
|
+
If the values differ (suffix bumped), **PASS**.
|
|
622
|
+
|
|
623
|
+
**Result:**
|
|
624
|
+
|
|
625
|
+
- **PASS** — `CACHE_NAME` bumped relative to `origin/main`, OR no client-bundle regression-fix surface detected.
|
|
626
|
+
- **FAIL** — client-bundle regression fix on branch with unchanged `CACHE_NAME`. Bump the suffix to force-purge the stale-cache class.
|
|
627
|
+
- **SKIP** — no `fix(...)` commit, OR no client-bundle surface in the diff.
|
|
628
|
+
|
|
629
|
+
### Check 9: Node-Only Encodings Banned in Client-Bundle Paths
|
|
630
|
+
|
|
631
|
+
**Always runs (no path-pattern gate).** Enforces the rule that any module imported into the Next.js client bundle must use browser-safe APIs at module load. Node's `Buffer.from(s, "base64url")` (added in Node 16) is the canonical example: it works in vitest's default Node env AND in SSR, but the `buffer@5.x` polyfill webpack ships throws `TypeError: Unknown encoding: base64url` in browsers. A test that runs the validator only in Node passes; production hydration crashes on the first authenticated visit.
|
|
632
|
+
|
|
633
|
+
**Rationale:** PR #3007's `assertProdSupabaseAnonKey` introduced `Buffer.from(middle, "base64url")` inside `validate-anon-key.ts`, which `lib/supabase/client.ts` imports at module load. PR #3014's added tests also ran in Node env and missed it. The deployed bundle threw on every page load — dashboard AND login. Layer 1 canary missed it (HTML-only). Layer 3 missed it (bash `base64 -d` is unrelated to webpack's Buffer polyfill). The only true source-level gate is to ban the encoding token.
|
|
634
|
+
|
|
635
|
+
**Step 9.1: Build the candidate file list.**
|
|
636
|
+
|
|
637
|
+
```bash
|
|
638
|
+
git ls-files \
|
|
639
|
+
'apps/web-platform/lib/**/*.ts' \
|
|
640
|
+
'apps/web-platform/lib/**/*.tsx' \
|
|
641
|
+
'apps/web-platform/components/**/*.ts' \
|
|
642
|
+
'apps/web-platform/components/**/*.tsx' \
|
|
643
|
+
'apps/web-platform/app/**/*.ts' \
|
|
644
|
+
'apps/web-platform/app/**/*.tsx' \
|
|
645
|
+
'apps/web-platform/hooks/**/*.ts' \
|
|
646
|
+
'apps/web-platform/hooks/**/*.tsx' \
|
|
647
|
+
| grep -v -E '/(server|api)/' \
|
|
648
|
+
| grep -v -E '\.test\.(ts|tsx)$' \
|
|
649
|
+
| grep -v -E '\.spec\.(ts|tsx)$'
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
**Step 9.2: Grep candidates for banned tokens (excluding comment lines).**
|
|
653
|
+
|
|
654
|
+
```bash
|
|
655
|
+
grep -nE 'Buffer\.from\([^)]*"base64url"' <files> \
|
|
656
|
+
| grep -vE ':[[:space:]]*(//|\*)' \
|
|
657
|
+
| grep -vE '`[^`]*Buffer\.from\([^`]*"base64url"[^`]*`'
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
The first filter drops single-line `// ...` comments and JSDoc `* ...` lines. The second drops backtick-quoted references inside markdown-style code spans (which can occur in TSDoc/JSDoc bodies that don't start with `* `). The remaining matches are real call sites. <!-- markdownlint-disable-line MD038 -->
|
|
661
|
+
|
|
662
|
+
If output is non-empty, **FAIL** with a per-file listing: "Node-only encoding `base64url` in client-bundle path `<file>:<line>`. Replace with browser-safe `atob` + `base64.padEnd(...)` per `apps/web-platform/lib/supabase/validate-anon-key.ts` post-fix pattern, OR move the file behind a `lib/server/` boundary if it does not need the client bundle."
|
|
663
|
+
|
|
664
|
+
**Step 9.3: Ban-list extension policy.**
|
|
665
|
+
|
|
666
|
+
The current ban-list (extend as new classes are discovered):
|
|
667
|
+
|
|
668
|
+
- `Buffer.from(_, "base64url")` — covered above
|
|
669
|
+
- Future additions go here with a **Why:** pointer to the learning file
|
|
670
|
+
|
|
671
|
+
**Result:**
|
|
672
|
+
|
|
673
|
+
- **PASS** — no banned token found in any client-bundle path.
|
|
674
|
+
- **FAIL** — at least one banned token found (file + line listed).
|
|
675
|
+
- **SKIP** — never; this check has no path gate (it always scans the canonical candidate list).
|
|
676
|
+
|
|
677
|
+
### Check 10: Discoverability Test Execution
|
|
678
|
+
|
|
679
|
+
**Path-gated** on the canonical sensitive-path regex (single source of truth; re-use Check 6 Step 6.1's `SENSITIVE_PATH_RE`). The path predicate runs against `"$PREFLIGHT_TMP/preflight-diff-files.txt"` (cached in Phase 0 Step 0.1). Otherwise return **SKIP** with note: "No sensitive paths touched — no Observability block required."
|
|
680
|
+
|
|
681
|
+
**Rationale:** `hr-observability-as-plan-quality-gate` mandates a `discoverability_test.command` that runs WITHOUT SSH. Plan Phase 2.9 and deepen-plan Phase 4.7 enforce field presence; neither runs the command. PR #4148 shipped with `curl https://web-platform.soleur.ai/api/inngest` — a typo'd hostname that fails DNS resolution. Five gates passed; the operator caught it. This check closes the "declared-verifiable but unverified" gap.
|
|
682
|
+
|
|
683
|
+
Invariant gate per `knowledge-base/project/learnings/2026-04-27-preflight-security-gates-skip-vs-fail-defaults.md`: SKIP only when truly indeterminate; FAIL when the invariant ("the documented command actually works against the live world") is contradicted.
|
|
684
|
+
|
|
685
|
+
**Reference implementation.** `plugins/soleur/test/lib/discoverability-test-parser.ts` mirrors the parser + classifier in TypeScript so the decision states can be unit-tested without subshells. The bash below IS the production runtime — the TS file is for tests. If they drift, the bash wins.
|
|
686
|
+
|
|
687
|
+
**Step 10.1: Sensitive-path gate (re-use Check 6 SSOT).**
|
|
688
|
+
|
|
689
|
+
```bash
|
|
690
|
+
set -uo pipefail
|
|
691
|
+
# SSOT: see Check 6 Step 6.1; this literal MUST stay byte-identical.
|
|
692
|
+
# Mirrored consumers: Check 6 Step 6.1, deepen-plan/SKILL.md Phase 4.6 Step 2, review/scripts/fix-round-seats.sh.
|
|
693
|
+
SENSITIVE_PATH_RE='^(apps/web-platform/(server|supabase|app/api|middleware\.ts$)|apps/web-platform/lib/(stripe|auth|byok|security-headers|csp|log-sanitize|safe-session|safe-return-to|supabase)|apps/web-platform/lib/(legal|auth)/|apps/[^/]+/infra/|.+/doppler[^/]*\.(yml|yaml|sh)$|\.github/workflows/.*(doppler|secret|token|deploy|release|version-bump|web-platform|infra-validation|cla|cf-token|linkedin-token).*\.ya?ml$)'
|
|
694
|
+
grep -E "$SENSITIVE_PATH_RE" "$PREFLIGHT_TMP/preflight-diff-files.txt"
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
If `grep` exits non-zero, return **SKIP** with note: "No sensitive paths touched."
|
|
698
|
+
|
|
699
|
+
**Step 10.2: Resolve the plan file (Shared Plan-File Resolution).**
|
|
700
|
+
|
|
701
|
+
Call **Shared Plan-File Resolution** (above Check 1). The output `$COMBINED` contains the scrubbed PR body concatenated with the scrubbed plan file (when a `knowledge-base/project/plans/*.md` link is present). `$PLAN_PATH` is the resolved path or empty.
|
|
702
|
+
|
|
703
|
+
If `$COMBINED` is empty (no PR available — `gh pr view` failed), return **SKIP** with note: "No PR available — Check 10 deferred to next preflight run after PR creation."
|
|
704
|
+
|
|
705
|
+
If `$PLAN_PATH` is empty (sensitive-path diff but no plan link in PR body), return **SKIP** with note: "Sensitive-path diff but no plan file referenced from PR body. Cannot extract discoverability_test.command. (If the PR uses inline Observability in the PR body, copy the plan file into the body via a `knowledge-base/project/plans/` link.)"
|
|
706
|
+
|
|
707
|
+
**Path-traversal hardening (defense-in-depth).** A malicious PR body could link to `knowledge-base/project/plans/../../../etc/passwd.md` (or a symlink) and trick the awk reader into following arbitrary paths whose content is then parsed for a `discoverability_test.command` — turning any `.md`-suffixed file the operator can read into an execution oracle. Refuse anything that resolves outside the canonical plans directory:
|
|
708
|
+
|
|
709
|
+
```bash
|
|
710
|
+
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
711
|
+
RESOLVED_PLAN_PATH="$(realpath -e "$PLAN_PATH" 2>/dev/null || true)"
|
|
712
|
+
case "$RESOLVED_PLAN_PATH" in
|
|
713
|
+
"$REPO_ROOT/knowledge-base/project/plans/"*) ;;
|
|
714
|
+
*)
|
|
715
|
+
echo "FAIL: plan path '$PLAN_PATH' does not resolve under $REPO_ROOT/knowledge-base/project/plans/. Refusing to read."
|
|
716
|
+
exit 1
|
|
717
|
+
;;
|
|
718
|
+
esac
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
**Step 10.3: Extract the `## Observability` block from the plan file.**
|
|
722
|
+
|
|
723
|
+
```bash
|
|
724
|
+
PREFLIGHT_TMP="$(git rev-parse --git-dir)"
|
|
725
|
+
# ‼️ ANCHOR the heading match. `/^## Observability/` is a PREFIX match, so it
|
|
726
|
+
# also matches `## Observability layer citation` — a section name that
|
|
727
|
+
# `hr-observability-layer-citation` actively encourages, making the collision
|
|
728
|
+
# systemic rather than incidental. When both sections exist, the unanchored form
|
|
729
|
+
# extracts the FIRST (the prose citation), finds no `discoverability_test`, and
|
|
730
|
+
# FAILs with "no command could be parsed" — a false FAIL on a plan that is
|
|
731
|
+
# perfectly well-formed. Verified against #6698's plan: unanchored extracted 47
|
|
732
|
+
# lines of the wrong section; anchored reaches the real block.
|
|
733
|
+
# Separate Bash calls do not share variables, so re-assert the plan path here: awk
|
|
734
|
+
# given an EMPTY filename skips it and reads stdin, which hangs the call (#8705).
|
|
735
|
+
[[ -n "${PLAN_PATH:-}" && -f "$PLAN_PATH" ]] || { echo "SKIP: no readable plan file (PLAN_PATH='${PLAN_PATH:-}') — re-run Shared Plan-File Resolution in this call."; exit 0; }
|
|
736
|
+
awk '/^## Observability$/{ino=1; next} /^## /{if (ino) exit} ino' "$PLAN_PATH" > "$PREFLIGHT_TMP/preflight-observability.txt"
|
|
737
|
+
test -s "$PREFLIGHT_TMP/preflight-observability.txt" || { echo "FAIL: Plan touches sensitive paths but '## Observability' block is missing. See hr-observability-as-plan-quality-gate."; exit 1; }
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
If the block is missing, return **FAIL** with: "Sensitive-path diff but plan file `<PLAN_PATH>` is missing the `## Observability` block. See `hr-observability-as-plan-quality-gate`. Add the section per `${CLAUDE_PLUGIN_ROOT}/skills/plan/references/plan-issue-templates.md`."
|
|
741
|
+
|
|
742
|
+
**Step 10.4: Extract `discoverability_test.command` and `expected_output`.**
|
|
743
|
+
|
|
744
|
+
The plan-template schema (`plan-issue-templates.md` §Observability →
|
|
745
|
+
`discoverability_test`) defines:
|
|
746
|
+
|
|
747
|
+
```yaml
|
|
748
|
+
discoverability_test:
|
|
749
|
+
command: # one command an operator can run LOCALLY (no ssh)
|
|
750
|
+
expected_output: # canonical "everything OK" output
|
|
751
|
+
credentials_required: # OPTIONAL — see Step 10.4; declares a probe with no
|
|
752
|
+
# unauthenticated substitute, which Check 10 then SKIPs
|
|
753
|
+
# without executing
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
Plans use TWO shapes — strict YAML (**Form A**, canonical) AND looser prose with a fenced code block followed by `Expected output: …` (**Form B**, PR #4148 shape). The parser MUST accept both forms.
|
|
757
|
+
|
|
758
|
+
**Form A — strict YAML:**
|
|
759
|
+
|
|
760
|
+
```yaml
|
|
761
|
+
discoverability_test:
|
|
762
|
+
command: curl -fsS ... https://app.soleur.ai/api/inngest
|
|
763
|
+
expected_output: "200"
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
Form A accepts all three YAML scalar shapes for `command:`:
|
|
767
|
+
|
|
768
|
+
| Shape | Header | Continuations joined with |
|
|
769
|
+
| --- | --- | --- |
|
|
770
|
+
| **inline** | `command: curl …` | — (value is on the key line) |
|
|
771
|
+
| **inline quoted** | `command: "…"` / `'…'` | — (value is on the key line; decoded, see below) |
|
|
772
|
+
| **block** | `command: \|`, `\|-`, `\|+` | newline |
|
|
773
|
+
| **folded** | `command: >`, `>-`, `>+` | space |
|
|
774
|
+
|
|
775
|
+
An **inline quoted** scalar is decoded once, by the parser, and nowhere else: `"…"` decodes
|
|
776
|
+
`\"` and `\\` only, and every other backslash sequence (`\n`, `\t`, …) passes through
|
|
777
|
+
byte-for-byte, so a shell `\n` never becomes a newline that Step 10.5 rejects. `'…'` decodes
|
|
778
|
+
`''` only. The closing quote is the first unescaped one, and only space, tab or CR
|
|
779
|
+
(optionally then a whitespace-led `# comment`) may follow it. An empty pair (`""`, `''`), an
|
|
780
|
+
unterminated or mismatched pair, or other trailing text stays unchanged. Block and folded
|
|
781
|
+
content is never decoded. `expected_output` and `credentials_required` are not YAML-decoded:
|
|
782
|
+
their reads below strip one symmetric quote pair and decode no escapes.
|
|
783
|
+
|
|
784
|
+
Block and folded headers may carry a trailing `# comment`. Scalar extent follows YAML
|
|
785
|
+
indent semantics: a continuation is any non-empty line indented **more** than the
|
|
786
|
+
`command:` key, and the first line indented **≤** the key ends the scalar. The parser is
|
|
787
|
+
[`./scripts/parse-form-a.awk`](./scripts/parse-form-a.awk) — a real file rather than an
|
|
788
|
+
inlined program, so the awk/TS parity harness executes the production runtime directly
|
|
789
|
+
instead of regex-scraping this prose. That file is authoritative; the TypeScript mirror in
|
|
790
|
+
`plugins/soleur/test/lib/discoverability-test-parser.ts` is non-authoritative.
|
|
791
|
+
|
|
792
|
+
**Form B — prose + fenced block:**
|
|
793
|
+
|
|
794
|
+
```markdown
|
|
795
|
+
- **discoverability_test.command:**
|
|
796
|
+
```bash
|
|
797
|
+
curl -fsS -o /dev/null -w "%{http_code}\n" --max-time 10 https://app.soleur.ai/api/inngest
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
Expected output: `200` (or `401` with HMAC challenge). Anything else = absent.
|
|
801
|
+
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
Detection: find the first `discoverability_test` line in the Observability block; from that point, locate the first fenced code block — its contents are the command. Then locate the first line matching `^[[:space:]]*Expected output:` (case-insensitive) — its value is the expected.
|
|
805
|
+
|
|
806
|
+
```bash
|
|
807
|
+
PREFLIGHT_TMP="$(git rev-parse --git-dir)"
|
|
808
|
+
# Form A first (anchored YAML key — strongest signal).
|
|
809
|
+
#
|
|
810
|
+
# The parser lives in a real file so the parity harness can execute it.
|
|
811
|
+
#
|
|
812
|
+
# Both Check 10 operands resolve through the loader token (ADR-179, #7453): the loader
|
|
813
|
+
# substitutes it with the INSTALLED plugin root at delivery. Never the git root — after a
|
|
814
|
+
# `gh pr checkout` that is the REVIEWED PARTY's tree (ADR-179's rejected option (d)). Under
|
|
815
|
+
# this check's `set -u` an unset token aborts at the assignment ("unbound variable"); an empty one
|
|
816
|
+
# expands to a root-anchored `/skills/...` path that the `test -r` below refuses.
|
|
817
|
+
#
|
|
818
|
+
# Hard-fail on a load error. `awk -f <missing>` exits 2 with EMPTY stdout, and
|
|
819
|
+
# `set -uo pipefail` does NOT abort on it (command-substitution rc is discarded), so a
|
|
820
|
+
# missing parser would leave $CMD empty and Form B would silently parse a DIFFERENT
|
|
821
|
+
# command. Never fall through.
|
|
822
|
+
#
|
|
823
|
+
# LC_ALL=C pins the parse to bytes: under a UTF-8 gawk `[[:space:]]` matches U+2028, so the
|
|
824
|
+
# same plan would parse differently on different operator hosts.
|
|
825
|
+
FORM_A_AWK="${CLAUDE_PLUGIN_ROOT}/skills/preflight/scripts/parse-form-a.awk"
|
|
826
|
+
test -r "$FORM_A_AWK" || { echo "FAIL: Check 10 parser missing at $FORM_A_AWK (plugin root unresolved? export CLAUDE_PLUGIN_ROOT=<the installed soleur plugin root>, never a path inside this repository)"; exit 1; }
|
|
827
|
+
CMD=$(LC_ALL=C awk -f "$FORM_A_AWK" "$PREFLIGHT_TMP/preflight-observability.txt")
|
|
828
|
+
AWK_RC=$?
|
|
829
|
+
if [[ "$AWK_RC" -ne 0 ]]; then
|
|
830
|
+
# `$?` here would report the `!`-inverted status (always 0) — capture the real
|
|
831
|
+
# rc from the command substitution before emitting it.
|
|
832
|
+
echo "FAIL: Check 10 Form A parser errored (awk rc=${AWK_RC:-unknown}); refusing to fall through to Form B."
|
|
833
|
+
exit 1
|
|
834
|
+
fi
|
|
835
|
+
|
|
836
|
+
EXPECTED=$(awk '
|
|
837
|
+
/^[[:space:]]*expected_output:/ { sub(/^[[:space:]]*expected_output:[[:space:]]*/, ""); print; exit }
|
|
838
|
+
' "$PREFLIGHT_TMP/preflight-observability.txt")
|
|
839
|
+
|
|
840
|
+
# Fallback to Form B (fenced block under `discoverability_test.command:` prose).
|
|
841
|
+
# Skip leading `#` comment lines inside the fence — operator prose comments
|
|
842
|
+
# are NOT part of the command (mirrors the TS reference impl). Without this
|
|
843
|
+
# strip, the FIRST executable bash line is treated as the command and any
|
|
844
|
+
# leading `# comment` line silently runs as bash via `bash -c`.
|
|
845
|
+
if [[ -z "$CMD" ]]; then
|
|
846
|
+
CMD=$(awk '
|
|
847
|
+
/discoverability_test/ { found=1 }
|
|
848
|
+
found && /^[[:space:]]*```/ { fence=!fence; if (!fence && lines>0) exit; next }
|
|
849
|
+
found && fence && /^[[:space:]]*#/ { next }
|
|
850
|
+
found && fence { print; lines++ }
|
|
851
|
+
' "$PREFLIGHT_TMP/preflight-observability.txt")
|
|
852
|
+
fi
|
|
853
|
+
|
|
854
|
+
if [[ -z "$EXPECTED" ]]; then
|
|
855
|
+
EXPECTED=$(grep -iE '^[[:space:]]*(\*\*)?Expected output:(\*\*)?' "$PREFLIGHT_TMP/preflight-observability.txt" | head -1 | sed -E 's/^[[:space:]]*(\*\*)?Expected output:(\*\*)?[[:space:]]*//I')
|
|
856
|
+
fi
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
**Normalize `$CMD` ONCE, here, before anything else reads it.**
|
|
860
|
+
|
|
861
|
+
Every downstream consumer — the probe-verb gate, the shell-active reject, and the sandboxed
|
|
862
|
+
exec — must see the SAME string. Gating one form while executing another is a laundering
|
|
863
|
+
gap, and normalizing inside the gate alone produced exactly that: the gate judged the
|
|
864
|
+
normalized command while Step 10.5 rejected the raw one on its embedded newline, so a Form A
|
|
865
|
+
block scalar carrying a leading `#` comment failed the runtime even though the gate (and the
|
|
866
|
+
TypeScript mirror) accepted it. The mirror's `normalizeCommand()` is this. The parity
|
|
867
|
+
harness once compared only the GATE, so this divergence was invisible to it. The test suite
|
|
868
|
+
now also runs the parse fence above and this fence under bash and compares the `$CMD` they
|
|
869
|
+
leave, the string the gate, the reject and the exec all receive (#7548). It does not run
|
|
870
|
+
the gate, the reject or the exec themselves.
|
|
871
|
+
|
|
872
|
+
```bash
|
|
873
|
+
# Drop full-line `#` comments and blank lines, then trim. A `#` inside a quoted
|
|
874
|
+
# argument is untouched — only whole comment LINES match. Verified across the
|
|
875
|
+
# 642-command corpus: 6 commands change (all block scalars with leading comment
|
|
876
|
+
# lines, the shape this fixes) and no quoted `#` is altered.
|
|
877
|
+
CMD="$(printf '%s' "$CMD" | sed -e '/^[[:space:]]*#/d' -e '/^[[:space:]]*$/d')"
|
|
878
|
+
CMD="${CMD#"${CMD%%[![:space:]]*}"}"
|
|
879
|
+
CMD="${CMD%"${CMD##*[![:space:]]}"}"
|
|
880
|
+
# Quoted inline scalars are decoded ONCE, by parse-form-a.awk; a strip here would double-decode `command: "'x'"` into `x`.
|
|
881
|
+
# preflight-discoverability-test.test.ts slices this fence and the FORM_A_AWK= fence out of this file and runs them (E rows, #7453): keep both anchors unique and no other bash fence between the two.
|
|
882
|
+
# Version skew: this fence and parse-form-a.awk must come from ONE plugin root. New SKILL.md + old awk leaves the quotes in, so the exec fails rc=127; old SKILL.md + new awk decodes twice.
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
If `$CMD` is empty after both attempts, return **FAIL** with: "Plan `<PLAN_PATH>` declares an Observability block but no `discoverability_test.command` could be parsed. See `${CLAUDE_PLUGIN_ROOT}/skills/plan/references/plan-issue-templates.md` §Observability."
|
|
886
|
+
|
|
887
|
+
**Reject SSH commands** (defense-in-depth):
|
|
888
|
+
|
|
889
|
+
```bash
|
|
890
|
+
# Match on a DEQUOTED COPY, as probe-verb-gate.sh does for the verb: bash resolves `\ssh`,
|
|
891
|
+
# `'s''sh'` and `"ssh"` to the same binary. Never strip from the string that is executed.
|
|
892
|
+
CMD_NOQ="${CMD//[\"\'\\]/}"
|
|
893
|
+
if [[ "$CMD_NOQ" =~ (^|[[:space:]]|/)ssh([[:space:]]|$) ]]; then
|
|
894
|
+
echo "FAIL: discoverability_test.command contains ssh; rule violation per hr-observability-as-plan-quality-gate."
|
|
895
|
+
exit 1
|
|
896
|
+
fi
|
|
897
|
+
```
|
|
898
|
+
|
|
899
|
+
**Read `credentials_required` and honour a declared-credentialed probe** (ADR-175 Layer 3):
|
|
900
|
+
|
|
901
|
+
Some probes verify a property that has **no unauthenticated substitute** — a warehouse
|
|
902
|
+
query API with no anonymous form, for instance. Before #7393 those had only two outcomes:
|
|
903
|
+
FAIL (verify nothing) or rewrite to a weaker property (verify the wrong thing). The
|
|
904
|
+
optional `discoverability_test.credentials_required` sub-field is the sanctioned third:
|
|
905
|
+
declare the scope and the justification, and Check 10 **skips without executing**.
|
|
906
|
+
|
|
907
|
+
Read it with a flat sub-field `awk`, exactly as `expected_output` already is — do **not**
|
|
908
|
+
extend [`./scripts/parse-form-a.awk`](./scripts/parse-form-a.awk), which is pinned
|
|
909
|
+
byte-exactly against its TypeScript mirror by the P1/P2/P3 parity harness. The flat read
|
|
910
|
+
inherits `expected_output`'s pre-existing limitation (two `discoverability_test`
|
|
911
|
+
sub-blocks could confuse it); that is unchanged here.
|
|
912
|
+
|
|
913
|
+
The read is SCOPED TO THE `discoverability_test:` SUB-BLOCK, and that scoping is
|
|
914
|
+
load-bearing rather than tidiness. It NARROWS the bypass class without closing
|
|
915
|
+
it — a `credentials_required:` line inside the command's own block scalar is
|
|
916
|
+
still honoured, so a multi-line command can waive itself. Closing that needs a
|
|
917
|
+
YAML-aware parse this flat `awk` deliberately avoids. `expected_output` is read with a flat
|
|
918
|
+
whole-block scan and survives it, because a stray `expected_output:` only changes
|
|
919
|
+
what a probe is compared against. This field **skips execution entirely**, so the
|
|
920
|
+
same flat read is a bypass: measured, one `credentials_required:` line under
|
|
921
|
+
`liveness_signal:` — or in prose anywhere in the section — waived a perfectly
|
|
922
|
+
runnable unauthenticated `curl` probe. Three further shapes declare nothing and
|
|
923
|
+
are rejected rather than honoured: a `#`-comment-only value (the documented
|
|
924
|
+
template ships one, so an agent copying the canonical schema produced a
|
|
925
|
+
valid-looking declaration whose "scope" was the template's own comment), a bare
|
|
926
|
+
`>`/`|` block indicator, and an empty value.
|
|
927
|
+
|
|
928
|
+
```bash
|
|
929
|
+
# Log-injection guard, defined HERE rather than in Step 10.5. Every value echoed
|
|
930
|
+
# below ($CREDS_REQ, $PROBE_VERB) is derived from PR-authored plan
|
|
931
|
+
# text, which this check's own design treats as adversarial — and Step 10.5's
|
|
932
|
+
# copy is ~250 lines further down, so these paths could not call it even in
|
|
933
|
+
# principle. U+2028/U+2029 are NOT in [[:space:]] under LC_ALL=C, so they survive
|
|
934
|
+
# tokenization into $PROBE_VERB and reach the operator's terminal; ESC bytes do
|
|
935
|
+
# too. Same helper and same reasoning as Check 5 (which cites the precedent).
|
|
936
|
+
# Step 10.5 redefines it identically; the redefinition is harmless.
|
|
937
|
+
sanitize() { printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177' | LC_ALL=C sed $'s/\xe2\x80\xa8//g; s/\xe2\x80\xa9//g'; }
|
|
938
|
+
|
|
939
|
+
# awk walks only the indented children of `discoverability_test:` — never the
|
|
940
|
+
# whole Observability block. `tolower()` rather than bash's ${var,,} because the
|
|
941
|
+
# latter is bash 4.0+ and this block runs ~200 lines BEFORE the bwrap probe, so
|
|
942
|
+
# on a bash 3.2 host (stock macOS) ${var,,} is a parse error long before Check 10
|
|
943
|
+
# could reach its graceful SKIP.
|
|
944
|
+
CREDS_REQ=$(awk '
|
|
945
|
+
/^[[:space:]]*discoverability_test:/ { in_dt=1; parent=match($0, /[^ \t]/); next }
|
|
946
|
+
in_dt && /^[[:space:]]*$/ { next }
|
|
947
|
+
in_dt {
|
|
948
|
+
ind = match($0, /[^ \t]/)
|
|
949
|
+
if (ind <= parent) { exit }
|
|
950
|
+
if ($0 ~ /^[[:space:]]*credentials_required:/) {
|
|
951
|
+
sub(/^[[:space:]]*credentials_required:[[:space:]]*/, ""); print; exit
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
' "$PREFLIGHT_TMP/preflight-observability.txt")
|
|
955
|
+
# Trim, THEN strip a symmetric quote pair. Order and completeness both matter:
|
|
956
|
+
# stripping only `"` and never trimming meant `credentials_required: 'placeholder'`
|
|
957
|
+
# and `credentials_required: TODO␠␠` both reached the SKIP-DECLARED branch while
|
|
958
|
+
# the TypeScript mirror correctly FAILed them — so the placeholder superset fix
|
|
959
|
+
# was undone in the runtime of record by one apostrophe, invisibly to the suite.
|
|
960
|
+
CREDS_REQ="${CREDS_REQ#"${CREDS_REQ%%[![:space:]]*}"}"
|
|
961
|
+
CREDS_REQ="${CREDS_REQ%"${CREDS_REQ##*[![:space:]]}"}"
|
|
962
|
+
# Drop a YAML trailing comment (` #…` after whitespace) BEFORE the quote strip, or
|
|
963
|
+
# `"TODO" # fill later` keeps its comment, fails the pair match and waives the check.
|
|
964
|
+
# A value OPENING with a quote keeps everything through its first matching close quote.
|
|
965
|
+
case "$CREDS_REQ" in
|
|
966
|
+
\"*|\'*)
|
|
967
|
+
CR_Q="${CREDS_REQ:0:1}"; CR_BODY="${CREDS_REQ:1}"; CR_BODY="${CR_BODY%%"$CR_Q"*}"
|
|
968
|
+
CR_TAIL="${CREDS_REQ:$(( ${#CR_BODY} + 2 ))}"
|
|
969
|
+
if [[ "$CREDS_REQ" == "$CR_Q$CR_BODY$CR_Q"* && "$CR_TAIL" =~ ^[[:space:]]+# ]]; then
|
|
970
|
+
CREDS_REQ="$CR_Q$CR_BODY$CR_Q"
|
|
971
|
+
fi ;;
|
|
972
|
+
*) CREDS_REQ="${CREDS_REQ%%[[:space:]]#*}"
|
|
973
|
+
CREDS_REQ="${CREDS_REQ%"${CREDS_REQ##*[![:space:]]}"}" ;;
|
|
974
|
+
esac
|
|
975
|
+
case "$CREDS_REQ" in
|
|
976
|
+
\"*\") CREDS_REQ="${CREDS_REQ#\"}"; CREDS_REQ="${CREDS_REQ%\"}" ;;
|
|
977
|
+
\'*\') CREDS_REQ="${CREDS_REQ#\'}"; CREDS_REQ="${CREDS_REQ%\'}" ;;
|
|
978
|
+
esac
|
|
979
|
+
# A comment-only value, a bare block/fold indicator, or an empty value declares
|
|
980
|
+
# nothing. Treat all as absent so the normal path runs. The CHOMPED indicators
|
|
981
|
+
# (`>-` `>+` `|-` `|+`) are included because #6772 — the change cited as the
|
|
982
|
+
# reason authors reach for indicators at all — is exactly what made those forms
|
|
983
|
+
# first-class for `command:`, so they are the likelier typo, not the rarer one.
|
|
984
|
+
case "$CREDS_REQ" in
|
|
985
|
+
"#"*|">"|">-"|">+"|"|"|"|-"|"|+") CREDS_REQ="" ;;
|
|
986
|
+
esac
|
|
987
|
+
|
|
988
|
+
if [[ -n "$CREDS_REQ" ]]; then
|
|
989
|
+
# Placeholder text is a FAIL. This set is a SUPERSET of deepen-plan §4.7's —
|
|
990
|
+
# they were disjoint on exactly the wrong members, so `placeholder` and
|
|
991
|
+
# `manual operator check` (the repo's canonical "says nothing" markers) were
|
|
992
|
+
# GRANTING the waiver.
|
|
993
|
+
CREDS_REQ_LC=$(printf '%s' "$CREDS_REQ" | tr '[:upper:]' '[:lower:]')
|
|
994
|
+
if [[ "$CREDS_REQ_LC" =~ ^(todo|tbd|n/a|na|none|tktk|placeholder|manual\ operator\ check|\.\.\.|\<.*\>)$ ]]; then
|
|
995
|
+
echo "FAIL: discoverability_test.credentials_required is a placeholder (\"$(sanitize "$CREDS_REQ")\"). State the credential scope and why no unauthenticated probe verifies the same property, or remove the field."
|
|
996
|
+
exit 1
|
|
997
|
+
fi
|
|
998
|
+
echo "SKIP-DECLARED: discoverability_test declares credentials_required — $(sanitize "$CREDS_REQ"). Check 10 did NOT execute the command: running it inside the Step 10.5 sandbox would fail for lack of credentials and prove nothing about the property under test."
|
|
999
|
+
exit 0
|
|
1000
|
+
fi
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
`SKIP-DECLARED` is its **own** terminal, never folded into ordinary `SKIP`, and it carries
|
|
1004
|
+
the declared scope verbatim into the Phase 2 aggregate row. That is deliberate: this is the
|
|
1005
|
+
cheapest path to a non-FAIL for any probe whose verb Check 10 cannot run — though
|
|
1006
|
+
not the cheapest overall: a tautological probe (`printf 200` against
|
|
1007
|
+
`expected_output: "200"`) reaches PASS and is counted by none of the three
|
|
1008
|
+
counterweights below. That gap is pre-existing, not introduced here, but the
|
|
1009
|
+
superlative was wrong as written. For the declared path specifically, this is and in `soleur:one-shot` the
|
|
1010
|
+
same agent authors the declaration and runs the gate. Left invisible it would convert
|
|
1011
|
+
Check 10 from a verification gate into self-certification. The three mechanical
|
|
1012
|
+
counterweights are the distinct terminal, the committed corpus baseline count in
|
|
1013
|
+
`plugins/soleur/test/preflight-discoverability-test.test.ts` (so each new adoption is a
|
|
1014
|
+
reviewable diff line rather than silent drift), and the checklist entry in
|
|
1015
|
+
`soleur:engineering:review:observability-coverage-reviewer` §Step 6.
|
|
1016
|
+
|
|
1017
|
+
Note what the waiver is and is not: it is a **verification waiver**, not an execution
|
|
1018
|
+
bypass. The declared path never executes, so no verb reaches the sandbox. The waiver does
|
|
1019
|
+
genuinely span *any* verb — that is why it is counted rather than reordered.
|
|
1020
|
+
|
|
1021
|
+
**Probe-verb gate** (ADR-175 Layer 2, revised — schema validation, not a security control):
|
|
1022
|
+
|
|
1023
|
+
The gate lives in [`./scripts/probe-verb-gate.sh`](./scripts/probe-verb-gate.sh) rather than
|
|
1024
|
+
inline here, for the same reason [`./scripts/parse-form-a.awk`](./scripts/parse-form-a.awk)
|
|
1025
|
+
is a real file: a parity harness must be able to **execute the production runtime** instead
|
|
1026
|
+
of regex-scraping this prose. That is not hypothetical — while this gate was inlined with a
|
|
1027
|
+
hand-maintained TypeScript mirror, two parser asymmetries shipped green inside the very PR
|
|
1028
|
+
that created the mirror (a Form B command kept its markdown indentation, so the effective
|
|
1029
|
+
verb was `""`; a Form A block scalar kept a leading `#`, so it was `"#"`). Content pins on
|
|
1030
|
+
the allowlist literal cannot detect behavioural drift.
|
|
1031
|
+
|
|
1032
|
+
```bash
|
|
1033
|
+
PROBE_GATE="${CLAUDE_PLUGIN_ROOT}/skills/preflight/scripts/probe-verb-gate.sh"
|
|
1034
|
+
test -r "$PROBE_GATE" || { echo "FAIL: Check 10 probe-verb gate missing at $PROBE_GATE"; exit 1; }
|
|
1035
|
+
if ! PROBE_REJECT="$(bash "$PROBE_GATE" "$CMD")"; then
|
|
1036
|
+
echo "FAIL: $(sanitize "$PROBE_REJECT")"
|
|
1037
|
+
exit 1
|
|
1038
|
+
fi
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
**What this gate is, stated honestly.** It answers *"is this an executable command, and one
|
|
1042
|
+
Check 10's PATH can run?"* — schema validation on the `command:` field. It is **not** a
|
|
1043
|
+
security control and **not** a legibility control, and the earlier revision of this section
|
|
1044
|
+
claimed both.
|
|
1045
|
+
|
|
1046
|
+
It cannot be a security control, and the reason is structural rather than incidental: the
|
|
1047
|
+
gate's own sanctioned remedy is *"wrap it in a repo-relative script"* — i.e. `bash
|
|
1048
|
+
scripts/x.sh`, which is arbitrary in-sandbox code execution. Every shape it could reject is
|
|
1049
|
+
strictly **weaker** than the remedy it prescribes. Step 10.5 additionally runs
|
|
1050
|
+
`bash -c "$CMD"`, so every probe already *is* an inline program; a rule rejecting
|
|
1051
|
+
`python3 -c` while printing instructions to use `bash scripts/x.sh` rejects a spelling, not
|
|
1052
|
+
a capability. Measured on the plan corpus, the deleted arg rules were 50% false-positive
|
|
1053
|
+
and the deleted path rule fired once across the corpus, while false-rejecting
|
|
1054
|
+
`bun test … -p`, `python3 … --print json` and `node … -e prod`.
|
|
1055
|
+
|
|
1056
|
+
It is not a legibility control either: the full command sits in the PR diff regardless, and
|
|
1057
|
+
`git -c alias.…`, `bash scripts/x.sh` and `./anything` all read as innocuous while doing
|
|
1058
|
+
arbitrary things.
|
|
1059
|
+
|
|
1060
|
+
**What it measurably buys**: 34 entries in the plan corpus are prose in a `command:` field
|
|
1061
|
+
(*"Sentry issue search feature:…"*, *"Open Sentry → Issues → …"*). Those become a named FAIL
|
|
1062
|
+
at authoring time instead of an opaque `rc=127` at ship time. That value comes entirely
|
|
1063
|
+
from the bare verb list, which is why the arg and path rules were deleted rather than
|
|
1064
|
+
repaired.
|
|
1065
|
+
|
|
1066
|
+
**The allowlist is a corpus-frequency list, not a capability list.** `sh`, `dig` and
|
|
1067
|
+
`getent` are absent because they have **zero** corpus uses; `awk`, `sed` and `find` are
|
|
1068
|
+
absent for the same reason, **not** because they are uniquely dangerous. `git` is on the
|
|
1069
|
+
list and is a full execution vector — `git -c alias.x='!cmd' x` runs arbitrary commands —
|
|
1070
|
+
and so is `bash <script>`. Both are recorded in ADR-175 as worked examples that an
|
|
1071
|
+
allowlist entry is an authority grant which this gate does not bound.
|
|
1072
|
+
|
|
1073
|
+
**Every allowlist entry is an authority grant — do not describe either layer as though it
|
|
1074
|
+
closed the class.** Layer 1 (the Step 10.5 sandbox) bounds what a verb can *reach*, but
|
|
1075
|
+
`curl` retains network egress and `bash <script>` retains arbitrary in-sandbox code
|
|
1076
|
+
execution. Adding a verb therefore warrants the same scrutiny as an infra change, not a
|
|
1077
|
+
"legibility decision". Conversely the allowlist bounds *legibility*, not authority: it is
|
|
1078
|
+
the sandbox, not the verb list, that makes a script which self-wraps `doppler run -c prd`
|
|
1079
|
+
fail loudly instead of succeeding silently.
|
|
1080
|
+
<!-- SECURITY (at-mention auto-attach footgun): never write an at-sign immediately
|
|
1081
|
+
followed by a real home/absolute path (tilde-slash, dollar-HOME-slash, or a
|
|
1082
|
+
`/home` `/Users` `/root` `/etc` absolute) in ANY skill/agent/doc that loads into
|
|
1083
|
+
agent context: Claude Code's @-mention auto-attach resolves such a token to the
|
|
1084
|
+
real on-disk file and attaches its CONTENTS to the transcript. Observed
|
|
1085
|
+
2026-07-22 during PR #6830's ship — a literal here resolved to the operator's
|
|
1086
|
+
live Doppler root token and auto-attached it. The guard
|
|
1087
|
+
.github/scripts/test/test-no-at-mention-credfile-footgun.sh enforces this repo-wide. -->
|
|
1088
|
+
|
|
1089
|
+
**What the retired denylist was, and why counting verbs was never enough.** Until #7393
|
|
1090
|
+
this gate was a denylist of ten credentialed CLIs (`doppler`, `gh`, `aws`, `supabase`,
|
|
1091
|
+
`stripe`, `hcloud`, `wrangler`, `terraform`, `flyctl`, `vercel`). It could not be complete:
|
|
1092
|
+
it never caught indirect invocation (`bash scripts/foo.sh` whose body self-wraps
|
|
1093
|
+
`doppler run -c prd`), nor any future vendor CLI, nor an absolute-path read of a credential
|
|
1094
|
+
file by a permitted verb. Measured during #7393's planning: with the whole filesystem
|
|
1095
|
+
readable, `awk 'BEGIN{system("…")}'` executed arbitrary commands past the shell-active
|
|
1096
|
+
reject, and a `curl --data-binary` against a home-rooted credential path carried no
|
|
1097
|
+
shell-active token at all. **The durable fix was never a longer list — it was moving the
|
|
1098
|
+
control into the filesystem layer (Step 10.5).**
|
|
1099
|
+
|
|
1100
|
+
**Step 10.5: Sanitize and run inside a filesystem-isolated sandbox, with a tight timeout.**
|
|
1101
|
+
|
|
1102
|
+
The block below uses a `text` fence (not `bash`) so the skill-security-scan
|
|
1103
|
+
calibration suite does not flag it as `shell-spawn-c-flag`. The runtime IS a
|
|
1104
|
+
shell-spawn — the **load-bearing** mitigation is the bubblewrap sandbox below
|
|
1105
|
+
(ADR-175 Layer 1); the Step 10.4 verb allowlist, the `ssh`/`$()`/backtick
|
|
1106
|
+
rejects and the 15s outer timeout are defense-in-depth. The plan-file source
|
|
1107
|
+
is trust-on-PR-review. See [`2026-05-20-preflight-check-10-discoverability-test-execution.md`](../../../../knowledge-base/project/learnings/best-practices/2026-05-20-preflight-check-10-discoverability-test-execution.md).
|
|
1108
|
+
|
|
1109
|
+
**Why a filesystem boundary and not an environment tweak.** `env -i` scrubs environment
|
|
1110
|
+
*variables*; it does not remove *files or sockets*. An ephemeral `$HOME` changes where a
|
|
1111
|
+
CLI **looks**, not what is **readable** — measured during #7393's planning,
|
|
1112
|
+
`wc -c < <the operator's home Doppler config>` returned a live 294-byte service token
|
|
1113
|
+
under `HOME=$(mktemp -d)`. The sandbox removes the credential stores as files:
|
|
1114
|
+
|
|
1115
|
+
| Probe, run inside the sandbox | Result |
|
|
1116
|
+
| --- | --- |
|
|
1117
|
+
| absolute-path read of the home Doppler config | `No such file or directory` |
|
|
1118
|
+
| glob read across `/home/*/…` | `No such file or directory` |
|
|
1119
|
+
| home SSH private key | `No such file or directory` |
|
|
1120
|
+
| `ls /run/user/<uid>/bus` (D-Bus → Secret Service keyring) | `No such file or directory` |
|
|
1121
|
+
| `awk 'BEGIN{system("<absolute-path read>")}'` | `cannot open` |
|
|
1122
|
+
| append to a tracked repo file | `Read-only file system` |
|
|
1123
|
+
| `curl https://soleur.ai/` | `200` |
|
|
1124
|
+
| `dig +short soleur.ai` | resolves |
|
|
1125
|
+
| `grep -c . AGENTS.md` | matches the host value |
|
|
1126
|
+
|
|
1127
|
+
The read-only repo bind is what closes the **write-back escalation**: without it a probe
|
|
1128
|
+
can install `.git/hooks/pre-commit`, which `soleur:ship` then executes seconds later with
|
|
1129
|
+
the operator's real `$HOME` — turning a few-second credential window into a full
|
|
1130
|
+
compromise.
|
|
1131
|
+
|
|
1132
|
+
```text
|
|
1133
|
+
# Defense-in-depth: reject every shell-active token before run. The plan file
|
|
1134
|
+
# is trust-on-PR-review. NOTE: this regex blocks ONE SPELLING of chaining. Its
|
|
1135
|
+
# complement — the inline-program arg rules — was deleted in #7393, so
|
|
1136
|
+
# `python3 -c "__import__('os').system('curl …')"` now passes both this reject
|
|
1137
|
+
# and the verb gate with no rejected token present. Do NOT cite it as the control
|
|
1138
|
+
# against a malicious plan author; it is a typo-catcher. The Step 10.5 sandbox is
|
|
1139
|
+
# the control.
|
|
1140
|
+
# Rejects: ;, &&, ||, |, >, <, &, NEWLINE, parameter expansion ($VAR or ${VAR}),
|
|
1141
|
+
# command substitution $(), backticks, process substitution <(/>().
|
|
1142
|
+
#
|
|
1143
|
+
# SCOPE OF THE NEWLINE REJECT: it closes BLOCK-mode command chaining only. A block
|
|
1144
|
+
# scalar joins continuations with \n, which `bash -c` runs as separate statements —
|
|
1145
|
+
# verified before this was added: a second `touch` line in a block scalar executed.
|
|
1146
|
+
# It contributes ZERO coverage to folded scalars, which join with a SPACE and carry no
|
|
1147
|
+
# shell-active token; those are covered by Step 10.4's probe-verb gate. Do not
|
|
1148
|
+
# cite this reject as a mitigation for the folded-command class.
|
|
1149
|
+
if [[ "$CMD" =~ (\$\(|\`|\<\(|\>\(|\;|\&\&|\|\||\||\>|\<|\&|$'\n'|\$\{?[A-Za-z_]) ]]; then
|
|
1150
|
+
echo "FAIL: discoverability_test.command contains shell-active token; refusing to run."
|
|
1151
|
+
exit 1
|
|
1152
|
+
fi
|
|
1153
|
+
|
|
1154
|
+
# --- Sandbox construction (ADR-175 Layer 1) -------------------------------
|
|
1155
|
+
# The flags live in ONE array so the establishment probe below and the real run
|
|
1156
|
+
# cannot drift apart. MOUNT ORDER IS LOAD-BEARING: bwrap applies mounts in the
|
|
1157
|
+
# order given and this repo lives under /home, so `--tmpfs /home` MUST precede
|
|
1158
|
+
# `--ro-bind "$REPO_ROOT"`. Reversed, the tmpfs silently clobbers the repo bind
|
|
1159
|
+
# and every probe dies with `Can't chdir` — a failure that reads as a bwrap
|
|
1160
|
+
# incompatibility rather than an ordering bug. The repo's own agent sandbox
|
|
1161
|
+
# documents the same semantics (apps/web-platform/server/agent-runner-sandbox-config.ts,
|
|
1162
|
+
# #5733 / PR #5848): broad `--tmpfs` shadows an earlier bind.
|
|
1163
|
+
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
1164
|
+
# `--tmpfs /run` removes /run/systemd/resolve/stub-resolv.conf, which
|
|
1165
|
+
# /etc/resolv.conf symlinks to on systemd-resolved hosts. Without rebinding the
|
|
1166
|
+
# resolved target, EVERY curl probe returns rc=6 — indistinguishable from the
|
|
1167
|
+
# #4148 DNS-typo regression this check exists to catch.
|
|
1168
|
+
RESOLV="$(readlink -f /etc/resolv.conf)"
|
|
1169
|
+
# IN A WORKTREE, `.git` IS A FILE, NOT A DIRECTORY — it points at the git common
|
|
1170
|
+
# dir under the bare repo root, which is OUTSIDE $REPO_ROOT. Binding only
|
|
1171
|
+
# $REPO_ROOT therefore breaks every `git` probe with
|
|
1172
|
+
# `fatal: not a git repository`, and `git` is an allowlisted verb. Soleur's whole
|
|
1173
|
+
# workflow runs in worktrees, so this is the common case, not the edge case. The
|
|
1174
|
+
# bind stays READ-ONLY, so the `.git/hooks/pre-commit` write-back escalation is
|
|
1175
|
+
# still closed. Found by the #7393 execution replay — a static verb tally
|
|
1176
|
+
# structurally could not have detected it.
|
|
1177
|
+
# `--tmpfs /var/tmp` is a scratch dir, not a convenience: `/var` is not bound, so
|
|
1178
|
+
# without it `/var/tmp` does not exist inside the sandbox and every probe using
|
|
1179
|
+
# this repo's own documented scratch convention (`TMPDIR=/var/tmp`, per
|
|
1180
|
+
# scripts/test-all.sh) dies on `mktemp: No such file or directory`. bwrap creates
|
|
1181
|
+
# the `/var` parent implicitly, so nothing else from the host `/var` is exposed.
|
|
1182
|
+
GIT_COMMON_DIR="$(git rev-parse --path-format=absolute --git-common-dir)"
|
|
1183
|
+
GIT_BIND=()
|
|
1184
|
+
if [[ "$GIT_COMMON_DIR" != "$REPO_ROOT"/* ]]; then
|
|
1185
|
+
GIT_BIND=(--ro-bind "$GIT_COMMON_DIR" "$GIT_COMMON_DIR")
|
|
1186
|
+
fi
|
|
1187
|
+
BWRAP_ARGS=(
|
|
1188
|
+
--ro-bind /usr /usr --ro-bind /etc /etc
|
|
1189
|
+
--symlink usr/bin /bin --symlink usr/lib /lib
|
|
1190
|
+
--symlink usr/lib64 /lib64 --symlink usr/sbin /sbin
|
|
1191
|
+
--tmpfs /home --tmpfs /root --tmpfs /run --tmpfs /tmp --tmpfs /var/tmp
|
|
1192
|
+
--ro-bind "$RESOLV" "$RESOLV"
|
|
1193
|
+
"${GIT_BIND[@]}"
|
|
1194
|
+
--ro-bind "$REPO_ROOT" "$REPO_ROOT" --chdir "$REPO_ROOT"
|
|
1195
|
+
--dev /dev
|
|
1196
|
+
--unshare-all --share-net --die-with-parent --new-session
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
# FAIL-CLOSED, with no unsandboxed fallback. A degraded sandbox that silently
|
|
1200
|
+
# reverted to the status quo would be worse than no sandbox at all, because this
|
|
1201
|
+
# skill would then claim a boundary that is not there.
|
|
1202
|
+
if ! command -v bwrap >/dev/null 2>&1; then
|
|
1203
|
+
# SOLEUR_* sentinel so this is COUNTABLE across the fleet, not one line of
|
|
1204
|
+
# local stdout. The PostToolUse Bash marker extractor
|
|
1205
|
+
# (apps/web-platform/server/git-lock-marker-telemetry.ts) re-emits SOLEUR_*
|
|
1206
|
+
# markers as structured pino -> journald -> Vector -> Better Stack, so a
|
|
1207
|
+
# silently-disabled Check 10 becomes queryable instead of invisible.
|
|
1208
|
+
# Deliberately NOT in WEDGE_RE: queryable, not paged.
|
|
1209
|
+
echo "SOLEUR_PREFLIGHT_CHECK10_NOSANDBOX reason=bwrap-absent uname=$(uname -s)"
|
|
1210
|
+
case "$(uname -s)" in
|
|
1211
|
+
Darwin) echo "SKIP-NOSANDBOX: Check 10 executes plan-declared commands only inside a bubblewrap (bwrap) sandbox. bubblewrap is Linux-only and has no macOS port, so Check 10 is PERMANENTLY disabled on this host — this is the steady state, not a transient condition. Refusing to run the probe unsandboxed. Tracked for a Darwin sandbox implementation; see ADR-175 Consequences." ;;
|
|
1212
|
+
*) echo "SKIP-NOSANDBOX: Check 10 executes plan-declared commands only inside a bubblewrap (bwrap) sandbox, and bwrap is not installed on this host. Refusing to run the probe unsandboxed. Install it with: sudo apt-get install -y bubblewrap (Debian/Ubuntu) or sudo dnf install -y bubblewrap (Fedora/RHEL)." ;;
|
|
1213
|
+
esac
|
|
1214
|
+
exit 0
|
|
1215
|
+
fi
|
|
1216
|
+
|
|
1217
|
+
# `--proc /proc` must DEGRADE, not fail. /proc cannot be mounted inside a nested
|
|
1218
|
+
# user namespace (a kernel restriction), and preflight runs inside exactly such a
|
|
1219
|
+
# context on the containerized one-shot pipeline path. The repo already pays for
|
|
1220
|
+
# this lesson: `enableWeakerNestedSandbox` in agent-runner-sandbox-config.ts
|
|
1221
|
+
# (#1557) skips `--proc /proc` for the same reason. A hard `--proc` here would
|
|
1222
|
+
# fail establishment in every containerized run and — because the design is
|
|
1223
|
+
# fail-closed — SKIP Check 10 for all of them, which reads exactly like the
|
|
1224
|
+
# fail-closed design working correctly. /proc is not part of what the boundary
|
|
1225
|
+
# protects, so dropping it does not weaken the control: measured, both forms keep
|
|
1226
|
+
# every credential store unreachable and both run curl/node/python3/grep.
|
|
1227
|
+
BWRAP_PROC=(--proc /proc)
|
|
1228
|
+
if ! bwrap "${BWRAP_PROC[@]}" "${BWRAP_ARGS[@]}" true 2>/dev/null; then
|
|
1229
|
+
BWRAP_PROC=()
|
|
1230
|
+
if ! bwrap "${BWRAP_ARGS[@]}" true 2>/dev/null; then
|
|
1231
|
+
# Report what was MEASURED, not a hypothesis (AP-021). The earlier message
|
|
1232
|
+
# named kernel.apparmor_restrict_unprivileged_userns without ever reading it,
|
|
1233
|
+
# while `2>/dev/null` discarded bwrap's own stderr — the only real evidence,
|
|
1234
|
+
# on a surface where nobody can re-run it by hand.
|
|
1235
|
+
BWRAP_ERR="$(bwrap "${BWRAP_ARGS[@]}" true 2>&1 >/dev/null | tail -c 200)"
|
|
1236
|
+
USERNS_MAX="$(cat /proc/sys/user/max_user_namespaces 2>/dev/null || echo unknown)"
|
|
1237
|
+
APPARMOR_RESTRICT="$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null || echo unset)"
|
|
1238
|
+
echo "SOLEUR_PREFLIGHT_CHECK10_NOSANDBOX reason=establish-failed userns_max=${USERNS_MAX} apparmor_restrict=${APPARMOR_RESTRICT}"
|
|
1239
|
+
echo "SKIP-NOSANDBOX: Check 10 could not establish the bwrap sandbox on this host. Measured: max_user_namespaces=${USERNS_MAX}, apparmor_restrict_unprivileged_userns=${APPARMOR_RESTRICT}. bwrap said: $(sanitize "$BWRAP_ERR"). Refusing to run the probe unsandboxed."
|
|
1240
|
+
exit 0
|
|
1241
|
+
fi
|
|
1242
|
+
fi
|
|
1243
|
+
|
|
1244
|
+
# Run with 15s wall-clock cap, capture stdout + exit code separately.
|
|
1245
|
+
# `env -i` SCRUBS Doppler/Supabase secrets from the child env. It is LOAD-BEARING,
|
|
1246
|
+
# not redundant — an earlier revision of this comment called it
|
|
1247
|
+
# "redundant-but-harmless" on the grounds that the on-disk stores are unmounted
|
|
1248
|
+
# anyway, and that reasoning is WRONG: bwrap does not scrub the environment
|
|
1249
|
+
# (measured — a variable set here survives into the sandbox verbatim), and
|
|
1250
|
+
# DOPPLER_TOKEN / GH_TOKEN / ANTHROPIC_API_KEY live in the environment ONLY, with
|
|
1251
|
+
# no on-disk store to unmount. With `curl` allowlisted and --share-net retaining
|
|
1252
|
+
# egress, dropping `-i` exfiltrates every credential in preflight's environment.
|
|
1253
|
+
# Pinned by an anchored assertion in the F2 test; do not weaken either.
|
|
1254
|
+
# PATH is restored explicitly so `curl`/`timeout` are still resolvable; HOME
|
|
1255
|
+
# points at the sandbox tmpfs.
|
|
1256
|
+
#
|
|
1257
|
+
# `</dev/null` is load-bearing: without it the probe inherits preflight's stdin,
|
|
1258
|
+
# so a command that reads stdin either consumes input the caller was still using
|
|
1259
|
+
# or blocks for the full 15s and reports as a timeout. Measured while building
|
|
1260
|
+
# #7393's execution replay, whose own read-loop the probes silently ate.
|
|
1261
|
+
DT_OUT=$(bwrap "${BWRAP_PROC[@]}" "${BWRAP_ARGS[@]}" /usr/bin/env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/tmp timeout 15s bash -c "$CMD" </dev/null 2>/dev/null; printf 'RC:%d' "$?")
|
|
1262
|
+
DT_RC="${DT_OUT##*RC:}"
|
|
1263
|
+
DT_STDOUT="${DT_OUT%RC:*}"
|
|
1264
|
+
|
|
1265
|
+
# Log-injection guard (re-use Check 5's sanitize() pattern).
|
|
1266
|
+
sanitize() { printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177' | LC_ALL=C sed $'s/\xe2\x80\xa8//g; s/\xe2\x80\xa9//g'; }
|
|
1267
|
+
DT_STDOUT_SAFE=$(sanitize "$DT_STDOUT")
|
|
1268
|
+
|
|
1269
|
+
# Trailing-newline normalization. `bash -c "echo 200"` returns "200\n"; the
|
|
1270
|
+
# matcher must compare without the trailing newline or "200" never matches.
|
|
1271
|
+
# sanitize() above strips C0 controls 0x00-0x1f including \n, so DT_STDOUT_SAFE
|
|
1272
|
+
# is already newline-free — but document the dependency explicitly so a future
|
|
1273
|
+
# sanitize() refactor that preserves \n does not silently break matching.
|
|
1274
|
+
```
|
|
1275
|
+
|
|
1276
|
+
The 15-second cap is a hard ceiling. Plans typically prescribe `curl --max-time 10`; the 15s outer cap accommodates 10s curl + 5s DNS + handshake without giving the curl invocation infinite headroom if it lacks `--max-time`.
|
|
1277
|
+
|
|
1278
|
+
**What the sandbox does NOT close, named so no one cites it as closure.**
|
|
1279
|
+
|
|
1280
|
+
`--share-net` **retains the host network namespace** — it does not create a fresh one. So
|
|
1281
|
+
`127.0.0.1` inside the sandbox is the operator's real loopback. Measured on a development
|
|
1282
|
+
workstation from inside the shipped `BWRAP_ARGS`: the sandbox's netns inode is identical to
|
|
1283
|
+
PID 1's, and local Supabase Kong/PostgREST (54321), Postgres (54322) and Studio (54323) are
|
|
1284
|
+
all reachable. A plain `curl http://127.0.0.1:54321/…` passes all three layers.
|
|
1285
|
+
|
|
1286
|
+
The class is **host-loopback pivot**, and it is materially worse than the egress case the
|
|
1287
|
+
earlier revision of this paragraph named: it reaches services that are unauthenticated
|
|
1288
|
+
*precisely because* they are loopback-bound, so the sandbox's "removes the credential
|
|
1289
|
+
stores as files" claim is true for files and false for anything reachable over loopback.
|
|
1290
|
+
|
|
1291
|
+
Outbound egress remains too (`curl` is the dominant probe verb), so repo contents — and,
|
|
1292
|
+
via the read-only git common-dir bind, whole-history and local session state — can be sent
|
|
1293
|
+
to an arbitrary host. The repository is public, which bounds that half; it does not bound
|
|
1294
|
+
the loopback half.
|
|
1295
|
+
|
|
1296
|
+
Closing it needs `--unshare-net` plus a userspace network stack
|
|
1297
|
+
(`slirp4netns --disable-host-loopback` or `pasta`): `--unshare-net` alone kills all egress,
|
|
1298
|
+
which surfaces as `curl` rc=6 — **indistinguishable from the #4148 DNS-typo signal this
|
|
1299
|
+
check exists to catch**. Tracked as #7412, recorded as open in ADR-175 `## Consequences`.
|
|
1300
|
+
|
|
1301
|
+
A `127.0.0.1|localhost|::1` string reject is deliberately NOT used: it is bypassable in one
|
|
1302
|
+
token (`127.1`, `2130706433`, `0x7f000001`, a DNS A-record pointing at loopback, or any
|
|
1303
|
+
wrapped script), and adding it would let this skill claim a boundary that is not there —
|
|
1304
|
+
the denylist mistake ADR-175 exists to retire.
|
|
1305
|
+
|
|
1306
|
+
**Step 10.6: Decision matrix (14 post-parse rows, 1 PASS terminal).**
|
|
1307
|
+
|
|
1308
|
+
| # | State | Detection | Result | Rationale |
|
|
1309
|
+
| --- | --- | --- | --- | --- |
|
|
1310
|
+
| 1 | No PR linked plan file | `$PLAN_PATH` empty after Shared Plan-File Resolution | **SKIP** | Indeterminate — Check 6 will fire if a section is required; Check 10 cannot run without a plan file. |
|
|
1311
|
+
| 2 | Plan exists, no `## Observability` block | `awk` returns empty in Step 10.3 | **FAIL** | Sensitive-path diff requires an Observability block per `hr-observability-as-plan-quality-gate`. |
|
|
1312
|
+
| 3 | Block exists, no `discoverability_test.command` parsed | `$CMD` empty after both Form A + B attempts | **FAIL** | Rule violation — the load-bearing field of the schema is missing. |
|
|
1313
|
+
| 4 | Probe declares `credentials_required` (non-placeholder) | `$CREDS_REQ` non-empty and not a placeholder | **SKIP-DECLARED** | The property has no unauthenticated substitute. Executing it in the sandbox would fail for lack of credentials and prove nothing. Waives verification, not execution — the command never runs. |
|
|
1314
|
+
| 5 | `credentials_required` is placeholder text | `$CREDS_REQ` matches `TODO`/`TBD`/`N/A`/… | **FAIL** | A declaration that says nothing waives nothing (deepen-plan §4.7 placeholder machinery). |
|
|
1315
|
+
| 6 | Verb not on `PROBE_VERB_ALLOWLIST` | [probe-verb-gate.sh](./scripts/probe-verb-gate.sh) exits 1 | **FAIL** | Deny-by-default, with no path-shaped exemption. Schema validation, not a security control — see Step 10.4. The reason names both remedies (repo-relative script; `credentials_required`) and the allowlist-extension route. |
|
|
1316
|
+
| 7 | Sandbox unavailable or cannot be established | `bwrap` absent, or both `--proc` and no-`--proc` establishment attempts fail | **SKIP-NOSANDBOX** | Fail-closed. Never falls back to unsandboxed execution. Its OWN terminal: folded into ordinary SKIP, "the gate verified nothing" was byte-identical to "the gate had nothing to do". bwrap is Linux-only, so on macOS this is the steady state. Emits `SOLEUR_PREFLIGHT_CHECK10_NOSANDBOX` so it is countable across the fleet. |
|
|
1317
|
+
| 8 | Command DNS-fails | `$DT_RC == 6` (curl: "Could not resolve host") | **FAIL** | The hostname-typo class — the exact #4148 regression. |
|
|
1318
|
+
| 9 | Command times out | `$DT_RC == 28` (curl) OR `$DT_RC == 124` (timeout(1)) | **FAIL** | Endpoint unreachable; DNS resolved but no response in 15s. |
|
|
1319
|
+
| 10 | Command requires creds not in Doppler (auth-gated probe) | `$DT_RC == 22` AND HTTP 401/403 AND `$EXPECTED` does NOT explicitly list 401/403 | **SKIP** | Auth-gated probe with no operator creds; surface diagnostic suggesting a `credentials_required` declaration or a Doppler-fetched probe variant. |
|
|
1320
|
+
| 10b | Command not found in the sandbox | `$DT_RC == 127` | **FAIL** | The probe's program is not on the sandbox `PATH` (`/usr/local/bin:/usr/bin:/bin`), which is deliberately NOT widened — that would be an authority grant inside a change that narrows authority. Wrap the probe in a repo-relative script, or use an allowlisted verb. Reported as its own state because falling through to row 11 blamed "expectation drift" for a cause the code measured exactly. |
|
|
1321
|
+
| 11 | Command returns a code/output the plan's `expected_output` does NOT include | NO token of `$EXPECTED` is present in `$DT_STDOUT_SAFE` (see the semantics paragraph below; this cell read `$DT_STDOUT_SAFE` not present in `$EXPECTED` until #8412 — backwards, and contradicted by both the paragraph and the shipped `matchExpected` in `plugins/soleur/test/lib/discoverability-test-parser.ts`) | **FAIL** | Plan's expectation drifted from production reality. |
|
|
1322
|
+
| 12 | Command returns expected output | All other paths — `$DT_RC == 0` AND stdout matches `$EXPECTED` | **PASS** | Invariant proven by live execution inside the sandbox. |
|
|
1323
|
+
|
|
1324
|
+
**Expected-output matching semantics.** When `$EXPECTED` is a comma-separated or "or"-joined list (e.g., `200 or 401`, `200, 401`, `["200","401"]`), tokenize on `,|\s+or\s+|\bor\b|[\`"\[\]/]+` and treat as a list. Match if any token is a non-empty substring of `$DT_STDOUT_SAFE`. When `$EXPECTED` is a single value, substring-match. The tokenizer accepts both `200` and `"200"`.
|
|
1325
|
+
|
|
1326
|
+
**Step 10.7: Headless mode behaviour.**
|
|
1327
|
+
|
|
1328
|
+
On **FAIL**, abort with the diagnostic table (command, exit code, sanitized stdout, expected). On **PASS** or **SKIP**, continue silently. On **SKIP-DECLARED**, continue but **emit one line** naming the declared scope verbatim — this terminal exists to be seen, and silencing it would defeat the drift control it was built for. On **SKIP-NOSANDBOX**, continue but **emit one line** plus the `SOLEUR_PREFLIGHT_CHECK10_NOSANDBOX` sentinel: the check did not run, and that fact must reach both the operator and the fleet-level marker pipeline.
|
|
1329
|
+
|
|
1330
|
+
**Step 10.8: Interactive mode behaviour.**
|
|
1331
|
+
|
|
1332
|
+
On **FAIL**, present the failure reason + sanitized command + diagnostic and offer **AskUserQuestion**:
|
|
1333
|
+
|
|
1334
|
+
1. "Fix the plan's `discoverability_test.command` now" — open the plan file at the line of the `discoverability_test:` key. Re-run Check 10.
|
|
1335
|
+
2. "Skip — temporarily defer (logs a trim-tracker issue)" — `gh issue create --label 'priority/p3-low,chore'` with the command, the exit code and the `expected_output` as the body. **Never include `$DT_STDOUT_SAFE` in the issue body.** `sanitize()` strips only C0 controls, not secrets or PII: a probe's stdout routinely carries log rows with user emails, client IPs, and bearer/`dp.ct.*` tokens, and this repository is PUBLIC. The captured stdout stays in the local run output for the operator; it does not get published. Continue the preflight run with this check noted as DEFERRED.
|
|
1336
|
+
3. "Abort — fix elsewhere" — stop the pipeline.
|
|
1337
|
+
|
|
1338
|
+
**Result:**
|
|
1339
|
+
|
|
1340
|
+
- **PASS** — Sensitive-path diff with valid plan-linked Observability block AND command ran inside the sandbox AND output matches `expected_output`.
|
|
1341
|
+
- **FAIL** — Sensitive-path diff with any of: missing Observability block, missing `discoverability_test.command`, command requires SSH, verb not on `PROBE_VERB_ALLOWLIST` (no path-shaped exemption), placeholder `credentials_required`, command contains shell substitution, DNS failure, timeout, or output mismatch.
|
|
1342
|
+
- **SKIP** — No sensitive paths touched, OR no PR available, OR no plan file linked from PR body, OR command is auth-gated with no operator creds.
|
|
1343
|
+
- **SKIP-NOSANDBOX** — The bwrap sandbox is unavailable or could not be established (fail-closed — never an unsandboxed fallback). Reported distinctly from **SKIP** because "the check could not run" and "the check had nothing to do" are different facts, and only one of them means a security gate is dark. Emits a `SOLEUR_*` sentinel so it is countable across the fleet rather than one line of local stdout.
|
|
1344
|
+
- **SKIP-DECLARED** — The plan declares a non-placeholder `discoverability_test.credentials_required`. The command was **not executed**; the declared scope is surfaced verbatim. Reported distinctly from **SKIP** everywhere (headless line, Phase 2 aggregate row) because it is a verification waiver a reviewer must be able to see.
|
|
1345
|
+
|
|
1346
|
+
### Check 7: Canary Probe Set Covers Authenticated Surface
|
|
1347
|
+
|
|
1348
|
+
**Path-gated:** only runs when the cached path-set from Phase 0 Step 0.1 (`"$PREFLIGHT_TMP/preflight-diff-files.txt"`) contains `apps/web-platform/infra/ci-deploy.sh`. Otherwise return **SKIP** with note: "ci-deploy.sh untouched." The path predicate is identical to the original `git diff --name-only origin/main...HEAD` form — only the diff source is changed.
|
|
1349
|
+
|
|
1350
|
+
**Rationale:** The legacy canary probed only `/health`, which is middleware-bypassed and never imports `lib/supabase/client.ts`. A broken inlined `NEXT_PUBLIC_SUPABASE_*` value would pass canary and ship to prod (PR #3014 incident class). The canary contract — documented in `knowledge-base/engineering/operations/runbooks/canary-probe-set.md` — requires probes for every public route (`/login`) AND auth-gated entry (`/dashboard`) PLUS a body-content sentinel rejection.
|
|
1351
|
+
|
|
1352
|
+
**Step 7.1: Assert /dashboard probe presence.**
|
|
1353
|
+
|
|
1354
|
+
```bash
|
|
1355
|
+
grep -c '/dashboard' apps/web-platform/infra/ci-deploy.sh
|
|
1356
|
+
```
|
|
1357
|
+
|
|
1358
|
+
The count MUST be ≥ 1.
|
|
1359
|
+
|
|
1360
|
+
**Step 7.2: Assert /login probe presence.**
|
|
1361
|
+
|
|
1362
|
+
```bash
|
|
1363
|
+
grep -c '/login' apps/web-platform/infra/ci-deploy.sh
|
|
1364
|
+
```
|
|
1365
|
+
|
|
1366
|
+
The count MUST be ≥ 1.
|
|
1367
|
+
|
|
1368
|
+
**Step 7.3: Assert structured-marker body-content rejection.**
|
|
1369
|
+
|
|
1370
|
+
```bash
|
|
1371
|
+
grep -F 'data-error-boundary=' apps/web-platform/infra/ci-deploy.sh
|
|
1372
|
+
```
|
|
1373
|
+
|
|
1374
|
+
The grep MUST exit 0 — the canary must reject any rendered HTML containing the `data-error-boundary` attribute emitted by `components/error-boundary-view.tsx`. The structured marker survives copy edits; without it, a copy change would silently disable the rollback gate (PR #3014 lesson).
|
|
1375
|
+
|
|
1376
|
+
**Result:**
|
|
1377
|
+
|
|
1378
|
+
- **PASS** — all three greps satisfy their conditions.
|
|
1379
|
+
- **FAIL** — any of: `/dashboard` missing from canary, `/login` missing from canary, or the error-sentinel body check absent. Operator must restore the layered probe before re-running preflight.
|
|
1380
|
+
- **SKIP** — `apps/web-platform/infra/ci-deploy.sh` was not modified in this branch.
|
|
1381
|
+
|
|
1382
|
+
### Check 11: Domain-Model Register Drift
|
|
1383
|
+
|
|
1384
|
+
Enforces the domain-model register's maintenance contract (#5871): a PR that changes a
|
|
1385
|
+
business rule must not leave the register with a **stale citation** (a cited migration/symbol
|
|
1386
|
+
that no longer resolves). Consumes the deterministic analyzer `domain-model-drift.sh`
|
|
1387
|
+
(#5754, ADR-076). The register (`knowledge-base/engineering/architecture/domain-model.md`) is a
|
|
1388
|
+
**curated subset**, so "undocumented source facts" is ~every un-curated table by design — it is
|
|
1389
|
+
**advisory-only, never a FAIL input**. This check gates on **stale citations only**.
|
|
1390
|
+
|
|
1391
|
+
**Step 11.1 — diff-scope (fast-path SKIP).** SKIP unless the cached path-set
|
|
1392
|
+
(`"$PREFLIGHT_TMP/preflight-diff-files.txt"`) matches a business-rule surface OR the register itself:
|
|
1393
|
+
|
|
1394
|
+
```bash
|
|
1395
|
+
DIFF="$PREFLIGHT_TMP/preflight-diff-files.txt"
|
|
1396
|
+
# Fail-safe: never SKIP on a missing/empty cache — recompute inline, then run if still empty.
|
|
1397
|
+
if [[ ! -s "$DIFF" ]]; then git diff --name-only origin/main...HEAD > "$DIFF" 2>/dev/null || true; fi
|
|
1398
|
+
if [[ -s "$DIFF" ]] && ! grep -qE '(^|/)apps/web-platform/supabase/migrations/.*\.sql$|(^|/)apps/web-platform/server/workspace-resolver\.ts$|(^|/)knowledge-base/engineering/architecture/domain-model\.md$' "$DIFF"; then
|
|
1399
|
+
echo "SKIP — no business-rule surface (migration/workspace-resolver/register) in diff"; exit 0
|
|
1400
|
+
fi
|
|
1401
|
+
```
|
|
1402
|
+
|
|
1403
|
+
The register-file literal is load-bearing: a register-only edit can introduce a stale citation.
|
|
1404
|
+
Check 11 reads `origin/main...HEAD` (PR-independent), so it **RUNS pre-PR** — do NOT "fix" it to
|
|
1405
|
+
SKIP-on-no-PR (that would fail it open).
|
|
1406
|
+
|
|
1407
|
+
**Step 11.2 — run + parse the stale sub-count** (line-anchored; the raw exit code is NOT the signal):
|
|
1408
|
+
|
|
1409
|
+
```bash
|
|
1410
|
+
bash "${CLAUDE_PLUGIN_ROOT}/scripts/domain-model-drift.sh" drift --repo . \
|
|
1411
|
+
--register knowledge-base/engineering/architecture/domain-model.md > "$PREFLIGHT_TMP/register-drift.txt" 2>&1; rc=$?
|
|
1412
|
+
stale=$(grep -oE '^## Stale register citations \([0-9]+\)' "$PREFLIGHT_TMP/register-drift.txt" | head -1 | grep -oE '[0-9]+')
|
|
1413
|
+
stale=${stale:-0}
|
|
1414
|
+
```
|
|
1415
|
+
|
|
1416
|
+
The `^` anchor + `head -1` guarantee a single integer from the canonical column-0 header — an
|
|
1417
|
+
unanchored grep could match a verbatim-SQL predicate line echoing the substring (multiline capture
|
|
1418
|
+
breaks the numeric test).
|
|
1419
|
+
|
|
1420
|
+
**Result:**
|
|
1421
|
+
|
|
1422
|
+
- **PASS** — `rc == 0` (register clean). The "Undocumented source facts (M)" count is surfaced by the
|
|
1423
|
+
advisory review note, never here.
|
|
1424
|
+
- **FAIL** — `stale > 0`: "domain-model register has $stale stale citation(s) — the register cites a
|
|
1425
|
+
file/symbol that no longer resolves. Fix the cited row(s), or run `soleur:sync domain-model`. If a
|
|
1426
|
+
citation backticks a *filename*, unbacktick it (known citation-parser false-positive — see
|
|
1427
|
+
`knowledge-base/project/learnings/best-practices/2026-07-01-domain-model-register-curation-citation-parser-and-grep-validation.md`)."
|
|
1428
|
+
- **FAIL** — `rc == 2` (analyzer error / unanalyzable source): "register-drift check could not run
|
|
1429
|
+
(analyzer exit 2) — inspect `--repo`/jq/migrations dir; NOT a drift finding."
|
|
1430
|
+
- **FAIL** — `rc == 3` (secret-refuse): "domain-model analyzer refused to emit — a secret-shaped
|
|
1431
|
+
substring was found in extracted structural text. Likely a recently-changed migration column/value
|
|
1432
|
+
matching `sk_test`/`ghp_`/`AKIA…`/`-----BEGIN` (a benign column name can false-positive). Inspect the
|
|
1433
|
+
newest `apps/web-platform/supabase/migrations/*.sql`."
|
|
1434
|
+
- **SKIP** — no business-rule surface (or the register) in the diff.
|
|
1435
|
+
|
|
1436
|
+
### Check 12: Encryption Posture
|
|
1437
|
+
|
|
1438
|
+
Enforces the encryption-posture ledger's maintenance contract (ADR-140): every persistent
|
|
1439
|
+
store and cross-component connection Soleur operates must carry a ledger row whose cited
|
|
1440
|
+
evidence resolves to real code, and any accepted plaintext/unverified-TLS exception must carry
|
|
1441
|
+
an open, unexpired tracking issue. Consumes the deterministic analyzer
|
|
1442
|
+
`lint-encryption-posture.py` (repo-root `scripts/`) — this check does NOT reimplement its PASS/FAIL logic in
|
|
1443
|
+
prose; it shells out and relays the exit code.
|
|
1444
|
+
|
|
1445
|
+
**Step 12.1 — diff-scope (fast-path SKIP).** SKIP unless the cached path-set
|
|
1446
|
+
(`"$PREFLIGHT_TMP/preflight-diff-files.txt"`) matches a store-class or connection-class surface:
|
|
1447
|
+
|
|
1448
|
+
```bash
|
|
1449
|
+
DIFF="$PREFLIGHT_TMP/preflight-diff-files.txt"
|
|
1450
|
+
# Fail-safe: never SKIP on a missing/empty cache — recompute inline, then run if still empty.
|
|
1451
|
+
if [[ ! -s "$DIFF" ]]; then git diff --name-only origin/main...HEAD > "$DIFF" 2>/dev/null || true; fi
|
|
1452
|
+
if [[ -s "$DIFF" ]] && ! grep -qE '\.tf$|supabase/migrations/.*\.sql$|cloud-init.*\.ya?ml$|docker-compose.*\.ya?ml$' "$DIFF"; then
|
|
1453
|
+
echo "SKIP — no store-class or connection-class surface (*.tf / migration / cloud-init / docker-compose) in diff"; exit 0
|
|
1454
|
+
fi
|
|
1455
|
+
```
|
|
1456
|
+
|
|
1457
|
+
This mirrors "I do not apply", never "I cannot prove it" (`2026-04-27-preflight-security-gates-skip-vs-fail-defaults.md`) — an empty/missing diff cache falls through to run, it never SKIPs by default.
|
|
1458
|
+
|
|
1459
|
+
**Step 12.2 — run the Layer A sweep:**
|
|
1460
|
+
|
|
1461
|
+
```bash
|
|
1462
|
+
python3 scripts/lint-encryption-posture.py --repo-sweep > "$PREFLIGHT_TMP/encryption-posture.txt" 2>&1; rc=$?
|
|
1463
|
+
```
|
|
1464
|
+
|
|
1465
|
+
**Result:**
|
|
1466
|
+
|
|
1467
|
+
- **PASS** — `rc == 0`. Every store/connection in the repo has a ledger row whose cited evidence
|
|
1468
|
+
resolves to real code, and every accepted exception carries an open, unexpired tracking issue.
|
|
1469
|
+
- **FAIL** — `rc != 0`: relay the script's own output (`cat "$PREFLIGHT_TMP/encryption-posture.txt"`)
|
|
1470
|
+
verbatim — the classes it can report include an unledgered store, an unresolvable citation, an
|
|
1471
|
+
expired or untracked exception, or a store/connection disclosed as encrypted while its ledger row
|
|
1472
|
+
says otherwise. Do not re-derive the verdict from this SKILL.md — the script is the single source
|
|
1473
|
+
of truth for the pass/fail decision.
|
|
1474
|
+
- **SKIP** — the cached path-set contains zero `.tf` / store-class / connection-class paths.
|
|
1475
|
+
|
|
1476
|
+
### Check 13: Founder-Stated Check
|
|
1477
|
+
|
|
1478
|
+
Runs the check a founder wrote in their own words, approved as an exact command, and froze before work started (ADR-275). The decisions live in [founder-check.py](./scripts/founder-check.py); this check relays them and never re-derives a verdict in prose. The wrapper, the outcome tables and the prompts are in [check-13-founder-check.md](${CLAUDE_PLUGIN_ROOT}/skills/preflight/references/check-13-founder-check.md).
|
|
1479
|
+
|
|
1480
|
+
1. **Resolve.** Run `founder-check.py verify` (reference section 1). `NO-BLOCK` is SKIP with the pinned banner; a missing block with freeze evidence is FAIL, never SKIP; a `FAIL` outcome is `BLOCK-REJECTED`; `UNTRUSTED` is a FAIL that never runs (the command and its author are shown); `CHANGED-SINCE-APPROVAL` stops and is decided in Phase 2.
|
|
1481
|
+
2. **Run only through Step 10.5.** The approved command runs in the Step 10.5 sandbox via the reference's wrapper, which reads it from a file `verify` wrote and runs `true` first as the sandbox-health control. Check 13 declares no sandbox of its own and never executes the command any other way. Output the wrapper does not map is INVALID, never a pass.
|
|
1482
|
+
3. **Classify and record.** `founder-check.py classify` is the one chokepoint and reads the verify record, not argv; `founder-check.py log` is the only writer of outcomes and prints the metadata-only `SOLEUR_FOUNDER_CHECK_RESULT` marker. After the last row, `founder-check.py commit-log` stages and commits only the log as `founder-check: log`.
|
|
1483
|
+
4. **Say only what is true.** A pass reads `founder-check.py text aggregate-pass` in the aggregate row, beside the full sentence from `founder-check.py text pass`. Every founder-facing sentence is printed from `founder-check.py text <key>`, never typed from this file.
|
|
1484
|
+
|
|
1485
|
+
A block present with no sandbox (`founder-check.py text no-sandbox`) is a FAIL in every mode: a gate that went dark must not read as green.
|
|
1486
|
+
|
|
1487
|
+
## Phase 2: Aggregate Go/No-Go Report
|
|
1488
|
+
|
|
1489
|
+
After all checks complete, aggregate results into a structured report:
|
|
1490
|
+
|
|
1491
|
+
```markdown
|
|
1492
|
+
## Preflight Results
|
|
1493
|
+
|
|
1494
|
+
| Check | Result | Details |
|
|
1495
|
+
|-------|--------|---------|
|
|
1496
|
+
| Not-Bare-Repo | PASS/FAIL | <details> |
|
|
1497
|
+
| DB Migration Status | PASS/FAIL/SKIP | <details> |
|
|
1498
|
+
| Security Headers | PASS/FAIL/SKIP | <details> |
|
|
1499
|
+
| Lockfile Consistency | PASS/FAIL/SKIP | <details> |
|
|
1500
|
+
| Environment Isolation | PASS/FAIL/SKIP | <details> |
|
|
1501
|
+
| Production Bundle Supabase Host | PASS/FAIL/SKIP | <details> |
|
|
1502
|
+
| Brand-Survival Self-Review | PASS/FAIL/SKIP | <details> |
|
|
1503
|
+
| Canary Probe Set Covers Auth Surface | PASS/FAIL/SKIP | <details> |
|
|
1504
|
+
| SW Cache Bump on Client-Bundle Fix | PASS/FAIL/SKIP | <details> |
|
|
1505
|
+
| Node-Only Encodings Banned in Client-Bundle | PASS/FAIL | <details> |
|
|
1506
|
+
| Discoverability Test Execution | PASS/FAIL/SKIP/SKIP-DECLARED/SKIP-NOSANDBOX | <details> — on SKIP-DECLARED, quote the declared `credentials_required` scope verbatim so the waiver is visible in the aggregate; on SKIP-NOSANDBOX, state plainly that Check 10 did not execute on this host and why |
|
|
1507
|
+
| Founder-Stated Check | ran-returned-success/FOUNDER-CONFIRMED/FAIL/SKIP/OVERRIDDEN | `founder-check.py text aggregate-pass` (never a bare PASS) or `text aggregate-judgement`, the exact command and expected string beside it; roll-up per reference section 4 |
|
|
1508
|
+
|
|
1509
|
+
**Overall: PASS / FAIL**
|
|
1510
|
+
```
|
|
1511
|
+
|
|
1512
|
+
On OVERRIDDEN add the matching `founder-check.py text overridden-failed` / `overridden-invalid` / `overridden-changed` / `overridden-rejected` line. With no block add nothing.
|
|
1513
|
+
|
|
1514
|
+
### If any FAIL
|
|
1515
|
+
|
|
1516
|
+
**Headless mode:** Abort with: "Preflight FAILED. See results above. Fix the issues and re-run `soleur:ship`."
|
|
1517
|
+
|
|
1518
|
+
**Interactive mode:** Present findings table, then use **AskUserQuestion tool**:
|
|
1519
|
+
|
|
1520
|
+
- Question: "Preflight found issues. How to proceed?"
|
|
1521
|
+
- Options:
|
|
1522
|
+
1. "Fix and retry" -- fix the issues, then re-run preflight from Phase 1
|
|
1523
|
+
2. "Abort" -- stop the pipeline
|
|
1524
|
+
|
|
1525
|
+
**Founder check (Check 13) prompts run here**, after the parallel checks finish: retry, restore or change the check, or continue anyway (recorded as `OVERRIDDEN`, with a reason and the cause it overrides) — reference section 5.
|
|
1526
|
+
|
|
1527
|
+
### If all PASS or SKIP
|
|
1528
|
+
|
|
1529
|
+
Print the summary table and continue.
|
|
1530
|
+
|
|
1531
|
+
## Preflight Complete
|
|
1532
|
+
|
|
1533
|
+
Preflight validation passed. Return control to the calling orchestrator.
|
|
1534
|
+
|
|
1535
|
+
## Sharp Edges
|
|
1536
|
+
|
|
1537
|
+
- **Check 13 reuses Step 10.5 by wrapper, never by copy.** Step 10.5 is a fence an agent follows, so reuse means running it inside a command substitution with `CMD` set. A second `BWRAP_ARGS` array would fork the sandbox; `preflight-check10-suite-integrity.test.sh` asserts the Check 13 section declares none. Check 13 is not path-gated like Check 10, so it widens what runs on a checked-out PR head: the authorship anchor in `founder-check.py verify` (`UNTRUSTED`, a FAIL that never runs) is what keeps a contributor's self-consistent plan from executing on the operator's machine. It makes an override legible and attributable; it does not authenticate the founder. Pins fix the named script only, not what it loads.
|
|
1538
|
+
- **Quad-SSOT for `SENSITIVE_PATH_RE`.** The literal lives at Check 6 Step 6.1, Check 10 Step 10.1, `plugins/soleur/skills/deepen-plan/SKILL.md` Phase 4.6 Step 2, AND `plugins/soleur/skills/review/scripts/fix-round-seats.sh` (ADR-267). All four MUST stay byte-identical — `plugins/soleur/test/review-tier-parity.test.ts` pins the script copy against this file. The Check 10 regression test (`plugins/soleur/test/preflight-discoverability-test.test.ts`) asserts ≥2 matches in `preflight/SKILL.md`; the canonical grep is `grep -cF "SENSITIVE_PATH_RE='^(apps/web-platform" plugins/soleur/skills/preflight/SKILL.md plugins/soleur/skills/deepen-plan/SKILL.md`. `grep -cF` is substring-based and tolerates the 2-space indentation difference between top-level (preflight) and markdown-bullet (deepen-plan) contexts — keep AC2's grep un-anchored.
|
|
1539
|
+
- **Shared Plan-File Resolution is a SSOT.** Both Check 6 Step 6.2 and Check 10 Step 10.2 call it. A future PR that changes the scrub/extract logic must edit the shared sub-section once — both consumers pick it up. Do NOT copy-paste the logic back into a caller block.
|
|
1540
|
+
- **Check 10 parser duality (Form A YAML vs Form B prose+fence).** PR #4148 used Form B; the canonical template uses Form A. Both must be accepted OR Check 10 silently SKIPs on currently-valid plans. The TS reference impl at `plugins/soleur/test/lib/discoverability-test-parser.ts` exercises both forms across every fixture in `plugins/soleur/test/fixtures/preflight-check-10/`.
|
|
1541
|
+
- **Rule order in `parse-form-a.awk` IS the bug (#6772).** The inline rule `/^[[:space:]]*command:/` matches EVERY `command:` line, including `command: >-` and `command: |`. If it is ever moved ahead of the fold/block header rules it returns the literal indicator, which then self-rejects against Step 10.5's shell-active `>` branch — a check that cannot parse its input, reporting a shell injection the plan does not have. AC1 and fixtures F1–F3 are the pins; do not drop them when refactoring.
|
|
1542
|
+
- **Anchoring the header regex is a bug generator.** A bare `$` anchor made `command: >- # note` fall through to inline and reproduce #6772 exactly. Any future tightening of the header must keep the comment-tolerant `(#.*)?$` tail and re-run the F1–F3 comment column.
|
|
1543
|
+
- **`indent()` returns 0 on a blank line**, which is `<= key` for every scalar. The blank-line skip rule MUST stay above the indent terminator or every scalar ends at its first blank line (fixture N6).
|
|
1544
|
+
- **`env -i` scrubs environment *variables*, never *files or sockets* — and an ephemeral `$HOME` is the same mistake wearing a better name.** `env -i` cannot remove an on-disk credential store, and setting `HOME` to a temp dir only changes where a CLI *looks*: measured during #7393, an absolute-path read of the operator's home Doppler config returned a live 294-byte service token under `HOME=$(mktemp -d)`, and a glob read across `/home/*/…` did too. Never cite either as a mitigation for a credential-bearing command. What covers that class is the Step 10.5 **filesystem boundary** (bwrap: `/home`, `/root`, `/run` replaced with tmpfs; repo bound read-only) — the verb allowlist in Step 10.4 bounds *legibility and maintenance*, not authority.
|
|
1545
|
+
- **Probe the invariant, not the proxy — this gate's own history is the cautionary tale.** #7393's first design measured `doppler configure get token`'s **exit code** (0 even with no token) and read it as "credential still reachable". Corrected to token length plus a live read, it then measured **credential discovery** and reported it as **credential reachability** — and shipped a Sharp Edge congratulating itself for escaping exactly that trap, one section above the place it committed it. Any claim about credential reachability must be tested by reading the file **at its absolute path, from inside the real execution environment**.
|
|
1546
|
+
- **A fail-closed gate can fail closed for the wrong reason, and it looks identical to working.** `--proc /proc` cannot be mounted inside a nested user namespace (the repo already knows this — `enableWeakerNestedSandbox`, #1557). A hard `--proc` would fail sandbox establishment in every containerized run, and the fail-closed design would then SKIP Check 10 everywhere — a blanket disablement whose telemetry is indistinguishable from correct operation. Whenever a gate's failure mode is "skip", enumerate the environments where establishment fails *for reasons unrelated to the threat*, and **degrade** rather than skip in those.
|
|
1547
|
+
- **`--tmpfs /home` must precede `--ro-bind "$REPO_ROOT"`.** bwrap applies mounts in order and this repo lives under `/home`. Reversed, the tmpfs silently clobbers the repo bind and every probe dies with `Can't chdir` — a failure that reads as a bwrap incompatibility rather than an ordering bug. The agent sandbox's module header documents the same tmpfs-shadows-earlier-binds semantics (#5733 / PR #5848); two independent discoveries of one rule.
|
|
1548
|
+
- **`--tmpfs /run` breaks DNS on systemd-resolved hosts.** The resolver config at /etc/resolv.conf is a symlink into the systemd-resolved stub under /run; tmpfs'ing `/run` removes the target, and every `curl` probe then returns rc=6 — **indistinguishable from the #4148 DNS-typo regression Check 10 exists to catch**. Bind the resolved target (`readlink -f` the resolver config) after the tmpfs. Written unquoted deliberately: the skill-security-scan `path-traversal-absolute` rule matches a quote or backtick immediately followed by a system path, so presenting these as code literals turns explanatory prose into a REVIEW finding.
|
|
1549
|
+
- **`awk`, `sed` and `find` are `bash -c` in disguise.** Measured: `awk 'BEGIN{system("…")}'` executes arbitrary commands and passes the shell-active reject (no `;`, `&&`, `|`, `$(`). Rejecting `bash -c` while allowlisting `awk` would be incoherent, so all three are excluded — as are `python3 -c`, `node -e`, `bun -e`, GNU `sed`'s `e` flag, and `find -exec`.
|
|
1550
|
+
- **`git ls-files --error-unmatch` is not a review oracle.** It interrogates the **PR-head index** — the attacker's own branch — and preflight runs *before* merge. "Tracked" and "reviewed" are different properties. That is why the program-path rule here is a pure string rule and the sandbox, not a tracking check, is what contains the script.
|
|
1551
|
+
- **The shell-active reject does not bound a folded command.** Folding joins with a space, so a folded scalar has no `;`/`|`/`$()` by construction and passes the reject automatically — it can append *arguments* but never chain a command. That makes fold safer than block for injection, but it also means no token in that reject set constrains what a folded command *is*. Reasoning "the reject will catch it" about a folded command is reasoning about the wrong gate; the verb allowlist and the sandbox are the gates that apply.
|
|
1552
|
+
- **`bash -c "$CMD"` stdout always ends in `\n`.** The matcher MUST normalize trailing newlines (via `sanitize()` or `${var%$'\n'}`) before substring comparison, or `expected_output: 200` fails when production correctly emits `200\n`.
|
|
1553
|
+
- **The `\b` word-boundary trap.** Bash `[[ $x =~ \bssh \b ]]` matches `ssh ` only when whitespace is on BOTH sides; trailing-EOF or trailing-newline `ssh ` does NOT match. Always use `(^|[[:space:]])ssh([[:space:]]|$)` — the canonical Check 10 reject form — when checking for `ssh ` in operator-facing prose. <!-- markdownlint-disable-line MD038 -->
|