chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
# Why Hard Rails Matter
|
||||
|
||||
## The Problem We Discovered
|
||||
|
||||
In AI-assisted development, we found:
|
||||
|
||||
1. **Process adherence fails** - Agents claim to do code review but miss critical issues
|
||||
2. **Manual review insufficient** - Even AI-assisted review missed hardcoded passwords, SQL injection
|
||||
3. **Scale breaks quality** - 50 issues in a single patch release despite explicit QA processes
|
||||
|
||||
### Real-World Case Study
|
||||
|
||||
**Production patch validation:**
|
||||
|
||||
After explicit code review and QA processes, we discovered **50 issues**:
|
||||
|
||||
**Security Issues (9):**
|
||||
|
||||
- 4 hardcoded passwords committed to repository
|
||||
- 1 SQL injection vulnerability
|
||||
- World-readable .env files
|
||||
- XSS vulnerabilities (CSP unsafe-inline)
|
||||
|
||||
**Type Safety Issues (11):**
|
||||
|
||||
- TypeScript strict mode DISABLED (`"strict": false`)
|
||||
- ESLint explicitly ALLOWING any types (`no-explicit-any: 'off'`)
|
||||
- Missing return types
|
||||
- Type assertion overuse
|
||||
|
||||
**Silent Failures (9):**
|
||||
|
||||
- Errors swallowed in try/catch blocks
|
||||
- Functions returning wrong types on error
|
||||
- No error logging
|
||||
- Network failures treated as false instead of errors
|
||||
|
||||
**Test Coverage Gaps (10):**
|
||||
|
||||
- No test coverage requirements
|
||||
- No testing framework setup
|
||||
- Code shipped with 0% coverage
|
||||
|
||||
**Build Failures (2):**
|
||||
|
||||
- Code committed that doesn't compile
|
||||
- Tests committed that fail
|
||||
|
||||
**Dependency Issues (6):**
|
||||
|
||||
- Critical CVEs not caught
|
||||
- Version conflicts between packages
|
||||
|
||||
## The Solution: Mechanical Enforcement
|
||||
|
||||
Don't **ask** agents to:
|
||||
|
||||
- "Please do code review"
|
||||
- "Make sure to run tests"
|
||||
- "Check for security issues"
|
||||
|
||||
Instead, **BLOCK** commits that:
|
||||
|
||||
- Have type errors
|
||||
- Contain hardcoded secrets
|
||||
- Don't pass tests
|
||||
- Have security vulnerabilities
|
||||
|
||||
### Why This Works
|
||||
|
||||
**Example: Type Safety**
|
||||
|
||||
❌ **Process-based (fails):**
|
||||
|
||||
```
|
||||
Human: "Please avoid using 'any' types"
|
||||
Agent: "I'll make sure to use proper types"
|
||||
*Agent uses any types anyway*
|
||||
```
|
||||
|
||||
✅ **Mechanically enforced (works):**
|
||||
|
||||
```
|
||||
Agent writes: const x: any = 123;
|
||||
Git hook runs: ❌ Error: no-explicit-any
|
||||
Commit blocked
|
||||
Agent must fix to proceed
|
||||
```
|
||||
|
||||
The agent doesn't get to **claim** it followed the process. The automated gate **determines** if code is acceptable.
|
||||
|
||||
## Design Principles
|
||||
|
||||
### 1. Fail Fast
|
||||
|
||||
Detect issues at commit time, not in CI, not in code review, not in production.
|
||||
|
||||
**Timeline:**
|
||||
|
||||
- ⚡ Commit time: Type errors, lint errors, secrets → **BLOCKED**
|
||||
- 🔄 CI time: Build failures, test failures, CVEs → **BLOCKED**
|
||||
- 👀 Code review: Architecture, design, business logic
|
||||
- 🚀 Production: (Issues should never reach here)
|
||||
|
||||
### 2. Non-Negotiable
|
||||
|
||||
No agent can bypass enforcement. No "skip hooks" flag. No emergency override.
|
||||
|
||||
If the code doesn't pass gates, it doesn't get committed. Period.
|
||||
|
||||
### 3. Portable
|
||||
|
||||
Same enforcement across:
|
||||
|
||||
- All projects
|
||||
- All developers (human + AI)
|
||||
- All environments (local, CI, production)
|
||||
|
||||
### 4. Minimal Friction
|
||||
|
||||
Auto-fix where possible:
|
||||
|
||||
- Prettier formats code automatically
|
||||
- ESLint --fix corrects simple issues
|
||||
- Only block when can't auto-fix
|
||||
|
||||
### 5. Clear Feedback
|
||||
|
||||
When enforcement blocks a commit, tell the agent:
|
||||
|
||||
- ❌ What's wrong (type error, lint violation, etc.)
|
||||
- 📍 Where it is (file:line)
|
||||
- ✅ How to fix it (expected type, remove 'any', etc.)
|
||||
|
||||
## Impact Prediction
|
||||
|
||||
Based on a 50-issue production analysis:
|
||||
|
||||
| Phase | Enforcement | Issues Prevented |
|
||||
| ----------- | --------------------------------- | ---------------- |
|
||||
| **Phase 1** | Pre-commit + strict mode + ESLint | 25 of 50 (50%) |
|
||||
| **Phase 2** | + CI expansion + npm audit | 35 of 50 (70%) |
|
||||
| **Phase 3** | + OWASP + coverage gates | 45 of 50 (90%) |
|
||||
|
||||
**The remaining 10%** require human judgment:
|
||||
|
||||
- Architecture decisions
|
||||
- Business logic correctness
|
||||
- User experience
|
||||
- Performance optimization
|
||||
|
||||
## Agent Behavior Evolution
|
||||
|
||||
### Before Quality Rails
|
||||
|
||||
```
|
||||
Agent: "I've completed the feature and run all tests"
|
||||
Reality: Code has type errors, no tests written, hardcoded password
|
||||
Result: 50 issues discovered in code review
|
||||
```
|
||||
|
||||
### After Quality Rails
|
||||
|
||||
```
|
||||
Agent writes code with 'any' type
|
||||
Git hook: ❌ no-explicit-any
|
||||
Agent rewrites with proper type
|
||||
Git hook: ✅ Pass
|
||||
|
||||
Agent writes code with hardcoded password
|
||||
Git hook: ❌ Secret detected
|
||||
Agent moves to environment variable
|
||||
Git hook: ✅ Pass
|
||||
|
||||
Agent commits without tests
|
||||
CI: ❌ Coverage below 80%
|
||||
Agent writes tests
|
||||
CI: ✅ Pass
|
||||
```
|
||||
|
||||
**The agent learns:** Good code passes gates, bad code is rejected.
|
||||
|
||||
## Why This Matters for AI Development
|
||||
|
||||
AI agents are **deterministically bad** at self-enforcement:
|
||||
|
||||
- They claim to follow processes
|
||||
- They **believe** they're following processes
|
||||
- Output proves otherwise
|
||||
|
||||
But AI agents are **good** at responding to mechanical feedback:
|
||||
|
||||
- Clear error messages
|
||||
- Specific line numbers
|
||||
- Concrete fix requirements
|
||||
|
||||
Quality Rails exploits this strength and avoids the weakness.
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Process compliance:** Agents claim → Output fails
|
||||
**Mechanical enforcement:** Gates determine → Output succeeds
|
||||
|
||||
This is not philosophical. This is pragmatic. Based on 50 real issues from production code.
|
||||
|
||||
Quality Rails exists because **process-based quality doesn't work at scale with AI agents.**
|
||||
|
||||
Mechanical enforcement does.
|
||||
@@ -0,0 +1,173 @@
|
||||
# Quality Rails
|
||||
|
||||
Portable quality enforcement for TypeScript, Python, and Node.js projects.
|
||||
|
||||
## 🎯 What This Prevents
|
||||
|
||||
Based on real-world validation of 50 issues in a production codebase:
|
||||
|
||||
- ❌ Hardcoded passwords
|
||||
- ❌ SQL injection vulnerabilities
|
||||
- ❌ Type safety violations (`any` types)
|
||||
- ❌ Missing test coverage
|
||||
- ❌ Build failures
|
||||
- ❌ Dependency vulnerabilities
|
||||
|
||||
**70% of these issues are prevented mechanically with quality-rails.**
|
||||
|
||||
## ⚡ Quick Start (Mosaic)
|
||||
|
||||
### New Project
|
||||
|
||||
```bash
|
||||
# Apply template from Mosaic
|
||||
~/.config/mosaic/bin/mosaic-quality-apply --template typescript-node --target /path/to/project
|
||||
|
||||
# Install dependencies
|
||||
cd /path/to/project
|
||||
npm install
|
||||
|
||||
# Initialize git hooks
|
||||
npx husky install
|
||||
|
||||
# Verify enforcement is working
|
||||
~/.config/mosaic/bin/mosaic-quality-verify --target /path/to/project
|
||||
```
|
||||
|
||||
### Existing Project
|
||||
|
||||
```bash
|
||||
# Same as above - works for new or existing projects
|
||||
~/.config/mosaic/bin/mosaic-quality-apply --template typescript-node --target /path/to/existing-project
|
||||
```
|
||||
|
||||
## 🛡️ What You Get
|
||||
|
||||
✅ **TypeScript strict mode** - All type checks enabled
|
||||
✅ **ESLint blocking `any` types** - no-explicit-any: error
|
||||
✅ **Pre-commit hooks** - Type check + lint + format before commit
|
||||
✅ **Secret scanning (gitleaks)** - Block hardcoded passwords/API keys (pre-commit + CI)
|
||||
✅ **CI/CD templates** - Woodpecker, GitHub Actions, GitLab
|
||||
✅ **Test coverage enforcement** - 80% threshold
|
||||
✅ **Security scanning** - npm audit, OWASP checks
|
||||
|
||||
## 📦 Available Templates
|
||||
|
||||
| Template | Language | Framework | Status |
|
||||
| ------------------- | ---------- | ---------------- | -------------- |
|
||||
| `typescript-node` | TypeScript | Node.js | ✅ Ready |
|
||||
| `typescript-nextjs` | TypeScript | Next.js | ✅ Ready |
|
||||
| `monorepo` | TypeScript | TurboRepo + pnpm | ✅ Ready |
|
||||
| `python` | Python | - | 🚧 Coming Soon |
|
||||
|
||||
### Monorepo Template
|
||||
|
||||
Perfect for projects combining **Next.js frontend** + **NestJS backend** in one repository.
|
||||
|
||||
Features:
|
||||
|
||||
- 🎯 **Multi-package aware** - lint-staged only checks changed packages
|
||||
- ⚡ **TurboRepo caching** - Faster builds and tests
|
||||
- 🔀 **Parallel dev servers** - Run web + API simultaneously
|
||||
- 📦 **pnpm workspaces** - Efficient dependency management
|
||||
- 🛡️ **Package-specific rules** - Next.js and NestJS get appropriate ESLint configs
|
||||
|
||||
Example structure:
|
||||
|
||||
```
|
||||
monorepo/
|
||||
├── apps/
|
||||
│ ├── web/ # Next.js frontend
|
||||
│ └── api/ # NestJS backend
|
||||
└── packages/
|
||||
├── shared-types/
|
||||
├── ui/
|
||||
└── config/
|
||||
```
|
||||
|
||||
## 🧪 How It Works
|
||||
|
||||
### Pre-Commit (Local Enforcement)
|
||||
|
||||
```bash
|
||||
# You try to commit code with a type error
|
||||
git commit -m "Add feature"
|
||||
|
||||
# Quality rails blocks it:
|
||||
❌ Type error: Type 'number' is not assignable to type 'string'
|
||||
❌ ESLint: Unexpected any. Specify a different type.
|
||||
✋ Commit blocked - fix errors and try again
|
||||
```
|
||||
|
||||
### CI/CD (Remote Enforcement)
|
||||
|
||||
```yaml
|
||||
# Woodpecker pipeline runs:
|
||||
✓ gitleaks (secret scanning — parallel, no deps)
|
||||
✓ npm audit (dependency security)
|
||||
✓ eslint (code quality)
|
||||
✓ tsc --noEmit (type checking)
|
||||
✓ jest --coverage (tests + coverage)
|
||||
✓ npm run build (compilation — gates on all above)
|
||||
# If any step fails, merge is blocked
|
||||
```
|
||||
|
||||
## 🎓 Philosophy
|
||||
|
||||
**Process compliance doesn't work.**
|
||||
|
||||
Instructing AI agents to "do code review" or "run tests" fails. They claim to follow processes but output quality doesn't match claims.
|
||||
|
||||
**Mechanical enforcement works.**
|
||||
|
||||
Quality rails don't ask agents to follow processes. They **block commits** that don't pass automated checks.
|
||||
|
||||
- Type errors? → **Commit blocked**
|
||||
- Hardcoded secrets? → **Commit blocked**
|
||||
- Test failures? → **Commit blocked**
|
||||
- Missing coverage? → **Commit blocked**
|
||||
|
||||
This works for **any agent runtime** (Codex, Claude, OpenCode, Gemini, etc.) because enforcement is mechanical, not instructional.
|
||||
|
||||
[Read more: PHILOSOPHY.md](./PHILOSOPHY.md)
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- [TypeScript Setup Guide](./docs/TYPESCRIPT-SETUP.md)
|
||||
- [CI/CD Configuration](./docs/CI-SETUP.md)
|
||||
|
||||
## 🔧 Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
| --------------------- | ----------------------------------------- |
|
||||
| `scripts/install.sh` | Install template to project (Linux/Mac) |
|
||||
| `scripts/install.ps1` | Install template to project (Windows) |
|
||||
| `scripts/verify.sh` | Verify enforcement is working (Linux/Mac) |
|
||||
| `scripts/verify.ps1` | Verify enforcement is working (Windows) |
|
||||
|
||||
## 🚀 Roadmap
|
||||
|
||||
- [x] TypeScript/Node template
|
||||
- [x] Pre-commit enforcement (husky + lint-staged)
|
||||
- [x] CI/CD templates (Woodpecker, GitHub Actions)
|
||||
- [x] Installation scripts
|
||||
- [x] Verification testing
|
||||
- [x] Next.js template
|
||||
- [x] Monorepo template
|
||||
- [ ] Python template
|
||||
- [ ] Coverage visualization
|
||||
- [ ] IDE integration (VSCode extension)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Quality Rails is based on lessons learned from real production codebases. Contributions welcome!
|
||||
|
||||
## 📝 License
|
||||
|
||||
MIT License - See LICENSE file for details
|
||||
|
||||
## 🙏 Credits
|
||||
|
||||
Built to solve real problems discovered in AI-assisted development workflows.
|
||||
|
||||
Based on validation findings from a production patch milestone.
|
||||
@@ -0,0 +1,180 @@
|
||||
# CI/CD Configuration Guide
|
||||
|
||||
Configure Woodpecker CI, GitHub Actions, or GitLab CI for quality enforcement.
|
||||
|
||||
## Woodpecker CI
|
||||
|
||||
Quality Rails includes `.woodpecker.yml` template.
|
||||
|
||||
### Pipeline Stages
|
||||
|
||||
1. **Secret Scan** - gitleaks scans latest commit for hardcoded secrets (runs in parallel, no deps)
|
||||
2. **Install** - Dependencies
|
||||
3. **Security Audit** - npm audit for CVEs
|
||||
4. **Lint** - ESLint checks
|
||||
5. **Type Check** - TypeScript compilation
|
||||
6. **Test** - Jest with coverage thresholds
|
||||
7. **Build** - Production build (gates on all above)
|
||||
|
||||
### Configuration
|
||||
|
||||
No additional configuration needed. Push to repository and Woodpecker runs automatically.
|
||||
|
||||
### Blocking Merges
|
||||
|
||||
Configure Woodpecker to block merges on pipeline failure:
|
||||
|
||||
1. Repository Settings → Protected Branches
|
||||
2. Require Woodpecker pipeline to pass
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
Copy from `templates/typescript-node/.github/workflows/quality.yml`:
|
||||
|
||||
```yaml
|
||||
name: Quality Enforcement
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- run: npm ci
|
||||
- run: npm audit --audit-level=high
|
||||
- run: npm run lint
|
||||
- run: npm run type-check
|
||||
- run: npm run test -- --coverage
|
||||
- run: npm run build
|
||||
```
|
||||
|
||||
### Blocking Merges
|
||||
|
||||
1. Repository Settings → Branches → Branch protection rules
|
||||
2. Require status checks to pass: `quality`
|
||||
|
||||
## GitLab CI
|
||||
|
||||
Copy from `templates/typescript-node/.gitlab-ci.yml`:
|
||||
|
||||
```yaml
|
||||
stages:
|
||||
- install
|
||||
- audit
|
||||
- quality
|
||||
- build
|
||||
|
||||
install:
|
||||
stage: install
|
||||
script:
|
||||
- npm ci
|
||||
|
||||
audit:
|
||||
stage: audit
|
||||
script:
|
||||
- npm audit --audit-level=high
|
||||
|
||||
lint:
|
||||
stage: quality
|
||||
script:
|
||||
- npm run lint
|
||||
|
||||
typecheck:
|
||||
stage: quality
|
||||
script:
|
||||
- npm run type-check
|
||||
|
||||
test:
|
||||
stage: quality
|
||||
script:
|
||||
- npm run test -- --coverage
|
||||
|
||||
build:
|
||||
stage: build
|
||||
script:
|
||||
- npm run build
|
||||
```
|
||||
|
||||
## Coverage Enforcement
|
||||
|
||||
Configure Jest coverage thresholds in `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"jest": {
|
||||
"coverageThreshold": {
|
||||
"global": {
|
||||
"branches": 80,
|
||||
"functions": 80,
|
||||
"lines": 80,
|
||||
"statements": 80
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
CI will fail if coverage drops below threshold.
|
||||
|
||||
## Security Scanning
|
||||
|
||||
### npm audit
|
||||
|
||||
Runs automatically in CI. Adjust sensitivity:
|
||||
|
||||
```bash
|
||||
npm audit --audit-level=moderate # Block moderate+
|
||||
npm audit --audit-level=high # Block high+critical only
|
||||
npm audit --audit-level=critical # Block critical only
|
||||
```
|
||||
|
||||
### Snyk Integration
|
||||
|
||||
Add to CI for additional security:
|
||||
|
||||
```yaml
|
||||
- run: npx snyk test
|
||||
```
|
||||
|
||||
Requires `SNYK_TOKEN` environment variable.
|
||||
|
||||
## Notification Setup
|
||||
|
||||
### Woodpecker
|
||||
|
||||
Configure in Woodpecker UI:
|
||||
|
||||
- Slack/Discord webhooks
|
||||
- Email notifications
|
||||
- Status badges
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
Add notification step:
|
||||
|
||||
```yaml
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
run: |
|
||||
curl -X POST $WEBHOOK_URL -d "Build failed"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Pipeline fails but pre-commit passed:**
|
||||
|
||||
- CI runs all packages, pre-commit only checks changed files
|
||||
- Fix issues in all packages, not just changed files
|
||||
|
||||
**npm audit blocks on low-severity:**
|
||||
|
||||
- Adjust `--audit-level` to `moderate` or `high`
|
||||
|
||||
**Coverage threshold too strict:**
|
||||
|
||||
- Lower thresholds in package.json
|
||||
- Add coverage exceptions for specific files
|
||||
@@ -0,0 +1,173 @@
|
||||
# TypeScript Project Setup Guide
|
||||
|
||||
Step-by-step guide to add Quality Rails to a TypeScript project.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+ and npm/pnpm
|
||||
- Git repository initialized
|
||||
- TypeScript project (or create with `npm init` + `tsc --init`)
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Clone Quality Rails
|
||||
|
||||
```bash
|
||||
git clone [email protected]:mosaic/quality-rails.git
|
||||
```
|
||||
|
||||
### 2. Run Installation Script
|
||||
|
||||
```bash
|
||||
# From your project directory
|
||||
../quality-rails/scripts/install.sh --template typescript-node --target .
|
||||
```
|
||||
|
||||
This copies:
|
||||
|
||||
- `.husky/pre-commit` - Git hooks (lint-staged + gitleaks)
|
||||
- `.lintstagedrc.js` - Pre-commit checks
|
||||
- `.eslintrc.js` - Strict ESLint rules
|
||||
- `tsconfig.json` - TypeScript strict mode
|
||||
- `.woodpecker.yml` - CI pipeline
|
||||
- `.gitleaks.toml` - Secret scanning config
|
||||
|
||||
### 3. Install Dependencies
|
||||
|
||||
Add to your `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"lint": "eslint 'src/**/*.{ts,tsx}' --max-warnings=0",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"build": "tsc",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-security": "^3.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.0.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx husky install
|
||||
```
|
||||
|
||||
### 4. Verify Enforcement
|
||||
|
||||
```bash
|
||||
../quality-rails/scripts/verify.sh
|
||||
```
|
||||
|
||||
Should output:
|
||||
|
||||
```
|
||||
✅ PASS: Type errors blocked
|
||||
✅ PASS: 'any' types blocked
|
||||
✅ PASS: gitleaks found (8.24.0)
|
||||
✅ PASS: gitleaks detected planted secret
|
||||
✅ PASS: Lint errors blocked
|
||||
```
|
||||
|
||||
## What Gets Enforced
|
||||
|
||||
### TypeScript Strict Mode
|
||||
|
||||
All strict checks enabled in `tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ESLint Rules
|
||||
|
||||
Key rules in `.eslintrc.js`:
|
||||
|
||||
```javascript
|
||||
{
|
||||
'@typescript-eslint/no-explicit-any': 'error', // Block 'any' types
|
||||
'@typescript-eslint/explicit-function-return-type': 'warn', // Require return types
|
||||
'@typescript-eslint/no-floating-promises': 'error', // Catch unhandled promises
|
||||
'@typescript-eslint/no-misused-promises': 'error', // Prevent promise misuse
|
||||
}
|
||||
```
|
||||
|
||||
### Pre-Commit Checks
|
||||
|
||||
On every `git commit`, runs:
|
||||
|
||||
1. ESLint with --max-warnings=0
|
||||
2. TypeScript type check
|
||||
3. Prettier formatting
|
||||
4. Secret scanning via gitleaks (required)
|
||||
|
||||
If any fail → **commit blocked**.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "husky - pre-commit hook exited with code 1"
|
||||
|
||||
This means pre-commit checks failed. Read the error output:
|
||||
|
||||
```
|
||||
src/example.ts:5:14 - error TS2322: Type 'number' is not assignable to type 'string'
|
||||
```
|
||||
|
||||
Fix the error and commit again.
|
||||
|
||||
### "Cannot find module '@typescript-eslint/parser'"
|
||||
|
||||
Dependencies not installed:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Pre-commit hooks not running
|
||||
|
||||
Husky not initialized:
|
||||
|
||||
```bash
|
||||
npx husky install
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
See [CUSTOMIZATION.md](./CUSTOMIZATION.md) for adjusting strictness levels.
|
||||
|
||||
## CI/CD Setup
|
||||
|
||||
See [CI-SETUP.md](./CI-SETUP.md) for Woodpecker/GitHub Actions configuration.
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-resident-budget.sh — resident line-count ceiling (R9 / DESIGN §7).
|
||||
#
|
||||
# Budgets the *container* (line count) of the framework-owned files that are
|
||||
# injected into every agent's context by value — the Constitution (L0), the
|
||||
# AGENTS dispatcher, and each runtime RUNTIME.md slice. Gate *wording* is never
|
||||
# capped (a word cap forces paraphrasing law — the exact drift vector P3 killed);
|
||||
# only the file's line count is bounded, so prose creep is caught in review.
|
||||
#
|
||||
# This is the CI-enforceable half of the budget. The per-harness *total* resident
|
||||
# prompt (which also includes user-generated SOUL.md/USER.md and the per-tier
|
||||
# slice) is summed by `mosaic doctor` as a runtime advisory — CI cannot see user
|
||||
# files, so it is deliberately out of scope here (DESIGN §7).
|
||||
#
|
||||
# Usage: check-resident-budget.sh [--self-test]
|
||||
# Exit: 0 = all within budget · 1 = a file exceeds its ceiling · 2 = self-test failed
|
||||
set -uo pipefail
|
||||
|
||||
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
|
||||
|
||||
# Per-file ceilings (lines). Headroom above current counts; tighten as files settle.
|
||||
# Format: "<relative-path>:<max-lines>"
|
||||
CEILINGS=(
|
||||
"defaults/CONSTITUTION.md:120"
|
||||
"defaults/AGENTS.md:120"
|
||||
"runtime/claude/RUNTIME.md:90"
|
||||
"runtime/codex/RUNTIME.md:90"
|
||||
"runtime/opencode/RUNTIME.md:90"
|
||||
"runtime/pi/RUNTIME.md:90"
|
||||
)
|
||||
|
||||
# check_file <abs-path> <max> → echoes "<n>"; returns 0 if n<=max, 1 otherwise.
|
||||
check_file() {
|
||||
local path="$1" max="$2" n
|
||||
n=$(wc -l <"$path" 2>/dev/null || echo 0)
|
||||
n=$((n + 0))
|
||||
echo "$n"
|
||||
[ "$n" -le "$max" ]
|
||||
}
|
||||
|
||||
run_budget() {
|
||||
local fail=0 rel max abs n
|
||||
printf '%-32s %8s %8s %s\n' "FILE" "LINES" "CEILING" "STATUS"
|
||||
for entry in "${CEILINGS[@]}"; do
|
||||
rel="${entry%%:*}"
|
||||
max="${entry##*:}"
|
||||
abs="$FW/$rel"
|
||||
if [ ! -f "$abs" ]; then
|
||||
printf '%-32s %8s %8s %s\n' "$rel" "-" "$max" "MISSING"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
n=$(check_file "$abs" "$max")
|
||||
if [ "$n" -le "$max" ]; then
|
||||
printf '%-32s %8s %8s %s\n' "$rel" "$n" "$max" "ok"
|
||||
else
|
||||
printf '%-32s %8s %8s %s\n' "$rel" "$n" "$max" "OVER BUDGET"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
return "$fail"
|
||||
}
|
||||
|
||||
self_test() {
|
||||
local tmp rc
|
||||
tmp=$(mktemp)
|
||||
# 3 lines, ceiling 5 → within budget (rc 0)
|
||||
printf 'a\nb\nc\n' >"$tmp"
|
||||
check_file "$tmp" 5 >/dev/null
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then echo "self-test FAIL: under-budget file flagged"; rm -f "$tmp"; return 2; fi
|
||||
# 6 lines, ceiling 5 → over budget (rc 1)
|
||||
printf 'a\nb\nc\nd\ne\nf\n' >"$tmp"
|
||||
check_file "$tmp" 5 >/dev/null
|
||||
rc=$?
|
||||
if [ "$rc" -ne 1 ]; then echo "self-test FAIL: over-budget file not flagged"; rm -f "$tmp"; return 2; fi
|
||||
rm -f "$tmp"
|
||||
echo "self-test OK"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "--self-test" ]; then
|
||||
self_test
|
||||
exit $?
|
||||
fi
|
||||
|
||||
if run_budget; then
|
||||
echo "Resident budget: all framework-owned resident files within ceiling."
|
||||
exit 0
|
||||
else
|
||||
echo "Resident budget EXCEEDED — trim prose or raise the ceiling deliberately (see DESIGN §7)." >&2
|
||||
exit 1
|
||||
fi
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-test-enumeration.sh — CI test-membership guard (#1017).
|
||||
#
|
||||
# CI reaches shell suites through two hand-enumerated surfaces:
|
||||
# S1 packages/mosaic/package.json scripts."test:framework-shell"
|
||||
# S2 .woodpecker/ci.yml direct `bash packages/mosaic/framework/tools/...` commands
|
||||
#
|
||||
# A hand-enumerated allowlist re-arms its own gap: a new suite never auto-joins,
|
||||
# so the list silently under-runs the disk (17 of 39 suites were invisible when
|
||||
# #1017 was filed). This guard makes that under-run impossible to do silently:
|
||||
#
|
||||
# FAIL when a suite-shaped file exists on disk and is neither enumerated on
|
||||
# the UNION of both surfaces nor listed in the exclusions file.
|
||||
# ("Enumerated", deliberately — F1/F2 on PR #1018 proved this guard sees
|
||||
# NAMING, not reachability, and its words must not claim otherwise.)
|
||||
# FAIL when either surface names a path that does not exist on disk
|
||||
# (a rename manufactures a stale entry silently — checked BOTH directions).
|
||||
# FAIL when an exclusion entry has no reason, names a path that is gone,
|
||||
# names a path that is also enumerated (contradiction), or names a path
|
||||
# outside the population (dead weight that looks like coverage).
|
||||
#
|
||||
# POPULATION PATTERN — a deliberate decision, stated per #1017's record:
|
||||
# basename matches *test*.sh (contains "test", ends ".sh"). Deliberately BROAD:
|
||||
# the strict `test-*.sh` prefix cannot even name three real boundary files
|
||||
# (tmux/agent-send.test.sh — CI-run; orchestrator/smoke-test.sh;
|
||||
# wake/validate-973/microtest-wake-assert.sh), and three independent censuses
|
||||
# handled that last file three different ways with no trace of the judgement.
|
||||
# The broad pattern makes such files MEMBERS, so their disposition must be a
|
||||
# signed exclusion, not an accident of the glob. The SAME pattern is applied to
|
||||
# both sides of the comparison (disk and enumeration) — a comparison globbed two
|
||||
# ways runs on two different populations. Scripts outside the pattern on both
|
||||
# sides symmetrically (e.g. check-resident-budget.sh, verify-sanitized.sh) are
|
||||
# check-scripts, not suites; their existence is still verified via the
|
||||
# both-directions rule because every surface-named path must exist on disk.
|
||||
#
|
||||
# The surfaces are PARSED, never line-ranged: three seats independently
|
||||
# mis-scoped hand-written line ranges against these files (#1017 thread). S1 is
|
||||
# read via JSON + command-chain tokenization; S2 by extracting every
|
||||
# packages/mosaic/framework/tools/ token wherever it appears in the file.
|
||||
#
|
||||
# Exclusions file format (framework/tools/quality/test-enumeration-exclusions.txt):
|
||||
# <repo-relative-path> | <non-empty reason>
|
||||
# Lines starting with # and blank lines are ignored. An exclusion is a recorded
|
||||
# decision someone signed, not an omission nobody made.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/../../../../../.." && pwd)"
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--root) ROOT="$(cd "$2" && pwd)"; shift 2 ;;
|
||||
*) echo "usage: check-test-enumeration.sh [--root <repo-root>]" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PKG_JSON="$ROOT/packages/mosaic/package.json"
|
||||
CI_YML="$ROOT/.woodpecker/ci.yml"
|
||||
TOOLS_DIR="$ROOT/packages/mosaic/framework/tools"
|
||||
EXCLUSIONS="$TOOLS_DIR/quality/test-enumeration-exclusions.txt"
|
||||
|
||||
for f in "$PKG_JSON" "$CI_YML"; do
|
||||
[[ -f "$f" ]] || { echo "FAIL: required surface file missing: $f" >&2; exit 2; }
|
||||
done
|
||||
[[ -d "$TOOLS_DIR" ]] || { echo "FAIL: tools dir missing: $TOOLS_DIR" >&2; exit 2; }
|
||||
|
||||
fail_count=0
|
||||
fail() { printf 'FAIL %s\n' "$1"; fail_count=$(( fail_count + 1 )); }
|
||||
|
||||
# in_population <repo-relative path> — the single pattern, used for BOTH sides.
|
||||
in_population() {
|
||||
local base; base="$(basename "$1")"
|
||||
[[ "$base" == *test*.sh ]]
|
||||
}
|
||||
|
||||
# --- Surface 1: package.json test:framework-shell, parsed, repo-relative -----
|
||||
# Tokens are script paths iff they contain "/" and end .sh/.py; interpreter
|
||||
# names and flags are skipped. Paths are relative to packages/mosaic/.
|
||||
mapfile -t S1 < <(python3 - "$PKG_JSON" <<'PY'
|
||||
import json, shlex, sys
|
||||
cmd = json.load(open(sys.argv[1]))["scripts"].get("test:framework-shell", "")
|
||||
seen = []
|
||||
for seg in cmd.split("&&"):
|
||||
for tok in shlex.split(seg):
|
||||
if "/" in tok and (tok.endswith(".sh") or tok.endswith(".py")):
|
||||
path = "packages/mosaic/" + tok
|
||||
if path not in seen:
|
||||
seen.append(path)
|
||||
print("\n".join(seen))
|
||||
PY
|
||||
)
|
||||
|
||||
# --- Surface 2: ci.yml, every framework/tools token wherever it appears ------
|
||||
# Comment lines (first non-whitespace char is #) are skipped BEFORE matching:
|
||||
# commenting an invocation out is the most common way a suite actually gets
|
||||
# disabled, and a raw-text regex would keep calling it enumerated (F1, 20155 on
|
||||
# PR #1018 — demonstrated, not argued). Known residual limit: a path named only
|
||||
# in a TRAILING comment on a live line still matches; no such line exists today
|
||||
# and full fidelity would need a YAML parser the CI image does not ship.
|
||||
mapfile -t S2 < <(grep -vE '^[[:space:]]*#' "$CI_YML" \
|
||||
| grep -oE 'packages/mosaic/framework/tools/[A-Za-z0-9_./-]+\.(sh|py)' | sort -u)
|
||||
|
||||
# --- Union, and its population-restricted view -------------------------------
|
||||
declare -A ENUM=() ENUM_POP=()
|
||||
for p in "${S1[@]:-}" "${S2[@]:-}"; do
|
||||
[[ -n "$p" ]] || continue
|
||||
ENUM["$p"]=1
|
||||
in_population "$p" && ENUM_POP["$p"]=1
|
||||
done
|
||||
|
||||
# --- Direction B: every surface-named path must exist on disk ----------------
|
||||
for p in "${!ENUM[@]}"; do
|
||||
[[ -f "$ROOT/$p" ]] || fail "STALE ENUMERATION: surfaces name '$p' but it does not exist on disk"
|
||||
done
|
||||
|
||||
# --- Exclusions: parsed with the same rigor the enumeration gets -------------
|
||||
declare -A EXCLUDED=()
|
||||
if [[ -f "$EXCLUSIONS" ]]; then
|
||||
lineno=0
|
||||
while IFS= read -r line; do
|
||||
lineno=$(( lineno + 1 ))
|
||||
[[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue
|
||||
path="${line%%|*}"; reason="${line#*|}"
|
||||
path="$(echo "$path" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
|
||||
reason="$(echo "$reason" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
|
||||
if [[ "$line" != *"|"* || -z "$reason" ]]; then
|
||||
fail "EXCLUSION MISSING REASON: line $lineno ('$path') — an exclusion is a recorded decision someone signed"
|
||||
continue
|
||||
fi
|
||||
if [[ ! -f "$ROOT/$path" ]]; then
|
||||
fail "STALE EXCLUSION: line $lineno excludes '$path' which does not exist on disk"
|
||||
continue
|
||||
fi
|
||||
if ! in_population "$path"; then
|
||||
fail "EXCLUSION OUTSIDE POPULATION: line $lineno excludes '$path' which the population pattern does not name — dead weight that reads as coverage"
|
||||
continue
|
||||
fi
|
||||
if [[ -n "${ENUM[$path]:-}" ]]; then
|
||||
fail "CONTRADICTORY EXCLUSION: line $lineno excludes '$path' which the surfaces already enumerate"
|
||||
continue
|
||||
fi
|
||||
EXCLUDED["$path"]=1
|
||||
done < "$EXCLUSIONS"
|
||||
fi
|
||||
|
||||
# --- Direction A: disk population must be enumerated or signed-excluded ------
|
||||
disk_total=0
|
||||
unlisted=0
|
||||
while IFS= read -r f; do
|
||||
rel="${f#"$ROOT"/}"
|
||||
in_population "$rel" || continue
|
||||
disk_total=$(( disk_total + 1 ))
|
||||
if [[ -z "${ENUM_POP[$rel]:-}" && -z "${EXCLUDED[$rel]:-}" ]]; then
|
||||
fail "UNENUMERATED: '$rel' exists on disk but is neither enumerated on any CI surface nor signed in the exclusions file"
|
||||
unlisted=$(( unlisted + 1 ))
|
||||
fi
|
||||
done < <(find "$TOOLS_DIR" -type f -name '*.sh' | sort)
|
||||
|
||||
if (( fail_count > 0 )); then
|
||||
printf 'enumeration guard: %d failure(s) — population %d, enumerated (in-population) %d, excluded %d\n' \
|
||||
"$fail_count" "$disk_total" "${#ENUM_POP[@]}" "${#EXCLUDED[@]}"
|
||||
exit 1
|
||||
fi
|
||||
printf 'enumeration guard: OK — population %d, enumerated (in-population) %d, excluded (signed) %d, surfaces name %d path(s), all present on disk\n' \
|
||||
"$disk_total" "${#ENUM_POP[@]}" "${#EXCLUDED[@]}" "${#ENUM[@]}"
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-tools-index.sh — assert every shipped tool is discoverable from the
|
||||
# resident documentation an agent actually has in context.
|
||||
#
|
||||
# WHY THIS GATE EXISTS
|
||||
# --------------------
|
||||
# The framework ships 26 git wrappers. Before this gate, 20 of them were named
|
||||
# in neither `defaults/TOOLS.md` nor `guides/TOOLS-REFERENCE.md`. One of the
|
||||
# undocumented ones was `pr-review.sh` — the wrapper that carries the
|
||||
# APPROVED/APPROVE provider-dialect split.
|
||||
#
|
||||
# The observable consequence, on a live fleet host: an agent needing to place a
|
||||
# review verdict reached for raw `curl`, sent GitHub's `APPROVE` to a Gitea
|
||||
# host, and got HTTP 200 with the review silently filed PENDING — three times,
|
||||
# because nothing about the failure pointed at the wrapper that already handled
|
||||
# it correctly. The agent was not ignoring Constitution gate 7. It was obeying
|
||||
# an index that said the tool did not exist.
|
||||
#
|
||||
# That is not a discipline problem and no amount of prose fixes it. A wrapper
|
||||
# that is not in the resident index is, from inside a session, indistinguishable
|
||||
# from a wrapper that was never written. So the invariant is mechanical:
|
||||
#
|
||||
# shipping a tool and documenting it are the same commit, or CI fails.
|
||||
#
|
||||
# WHAT IT CHECKS
|
||||
# --------------
|
||||
# forward every non-excluded tool in an ENFORCED suite is named in at least
|
||||
# one index document (missing tool -> undiscoverable -> FAIL)
|
||||
# reverse every `<name>.sh` an index document attributes to an enforced
|
||||
# suite exists on disk (stale reference -> agent runs a ghost -> FAIL)
|
||||
#
|
||||
# Suites outside the enforced set are reported with a coverage percentage but do
|
||||
# not fail the build, so the ratchet can be tightened one suite per PR instead of
|
||||
# landing as one unreviewable sweep. `--strict` fails on those too.
|
||||
#
|
||||
# WHY THE ENFORCED LIST LIVES HERE AND NOT IN A MARKER INSIDE THE DOC
|
||||
# -------------------------------------------------------------------
|
||||
# `TOOLS.md` is operator-owned (see framework-manifest.txt). A marker inside it
|
||||
# would let an operator silence this gate by editing their own copy — the gate
|
||||
# would then be strongest exactly where it is least needed and absent where it
|
||||
# is needed most. The list is framework-owned and changes only through a
|
||||
# reviewed PR.
|
||||
#
|
||||
# Usage:
|
||||
# check-tools-index.sh [--tools-dir DIR] [--doc FILE]... [--strict] [--self-test]
|
||||
#
|
||||
# Exit: 0 = every enforced suite fully discoverable · 1 = drift · 2 = bad usage
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Suites whose coverage is a HARD requirement. Add a suite here only together
|
||||
# with the doc changes that make it pass.
|
||||
#
|
||||
# `git` is first because it is the suite Constitution gates 6-8 make mandatory:
|
||||
# an undiscoverable git wrapper converts a hard gate into a coin flip.
|
||||
ENFORCED_SUITES=(git)
|
||||
|
||||
# Files that are not agent-callable tools and must not be required in an index.
|
||||
EXCLUDE_GLOBS=(
|
||||
'test-*' # hermetic regression scripts, invoked by CI not by agents
|
||||
'_*' # private helpers (_lib, _scripts internals)
|
||||
'*.bak' # editor/installer debris
|
||||
'*.pre-*' # pre-change backups (e.g. ci-queue-wait.sh.pre-404fix-bak)
|
||||
'README.md'
|
||||
)
|
||||
|
||||
STRICT=0
|
||||
SELF_TEST=0
|
||||
TOOLS_DIR=""
|
||||
DOCS=()
|
||||
|
||||
die() { printf 'check-tools-index: %s\n' "$*" >&2; exit 2; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--tools-dir) TOOLS_DIR="${2:-}"; shift 2 ;;
|
||||
--doc) DOCS+=("${2:-}"); shift 2 ;;
|
||||
--strict) STRICT=1; shift ;;
|
||||
--self-test) SELF_TEST=1; shift ;;
|
||||
-h|--help) sed -n '2,48p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) die "unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- location resolution ---------------------------------------------------
|
||||
# Runs from two places with different layouts, and must not silently check the
|
||||
# wrong tree: a CI checkout (repo-relative) and an installed host ($MOSAIC_HOME).
|
||||
resolve_locations() {
|
||||
local here framework
|
||||
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# .../framework/tools/quality/scripts -> .../framework
|
||||
framework="$(cd -- "$here/../../.." && pwd)"
|
||||
|
||||
if [ -z "$TOOLS_DIR" ]; then
|
||||
if [ -d "$framework/tools" ]; then
|
||||
TOOLS_DIR="$framework/tools"
|
||||
else
|
||||
TOOLS_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}/tools"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ${#DOCS[@]} -eq 0 ]; then
|
||||
# The two layouts are mutually exclusive on purpose. Unioning them would let
|
||||
# a well-maintained operator TOOLS.md on the developer's own machine mask a
|
||||
# gap in the shipped defaults — the check would pass locally and the defect
|
||||
# would still install on every other host. Repo layout wins when present.
|
||||
if [ -f "$framework/defaults/TOOLS.md" ]; then
|
||||
DOCS+=("$framework/defaults/TOOLS.md")
|
||||
[ -f "$framework/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$framework/guides/TOOLS-REFERENCE.md")
|
||||
else
|
||||
local mosaic_home="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
[ -f "$mosaic_home/TOOLS.md" ] && DOCS+=("$mosaic_home/TOOLS.md")
|
||||
[ -f "$mosaic_home/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$mosaic_home/guides/TOOLS-REFERENCE.md")
|
||||
fi
|
||||
fi
|
||||
|
||||
[ -d "$TOOLS_DIR" ] || die "tools dir not found: $TOOLS_DIR"
|
||||
[ ${#DOCS[@]} -gt 0 ] || die "no index documents found (pass --doc FILE)"
|
||||
}
|
||||
|
||||
is_excluded() {
|
||||
local name="$1" glob
|
||||
for glob in "${EXCLUDE_GLOBS[@]}"; do
|
||||
# shellcheck disable=SC2254 # glob is intentionally a pattern
|
||||
case "$name" in $glob) return 0 ;; esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# A tool counts as documented when its basename appears anywhere in the corpus.
|
||||
# Deliberately permissive about *form* (table cell, code fence, prose) and strict
|
||||
# about *presence*: the gate's job is "an agent can find it", not house style.
|
||||
documented() { grep -qF -- "$1" "$CORPUS"; }
|
||||
|
||||
# ---- the check -------------------------------------------------------------
|
||||
run_check() {
|
||||
local rc=0 suite dir tool base enforced
|
||||
|
||||
CORPUS="$(mktemp)"; trap 'rm -f "$CORPUS"' RETURN
|
||||
cat "${DOCS[@]}" > "$CORPUS"
|
||||
|
||||
printf 'tools: %s\n' "$TOOLS_DIR"
|
||||
for d in "${DOCS[@]}"; do printf 'index: %s\n' "$d"; done
|
||||
printf '\n'
|
||||
|
||||
for dir in "$TOOLS_DIR"/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
suite="$(basename -- "$dir")"
|
||||
case " ${ENFORCED_SUITES[*]} " in *" $suite "*) enforced=1 ;; *) enforced=0 ;; esac
|
||||
[ "$STRICT" -eq 1 ] && enforced=1
|
||||
case "$suite" in _*) continue ;; esac
|
||||
|
||||
local total=0 found=0
|
||||
local -a suite_missing=() suite_noexec=()
|
||||
for tool in "$dir"*.sh; do
|
||||
[ -e "$tool" ] || continue
|
||||
base="$(basename -- "$tool")"
|
||||
is_excluded "$base" && continue
|
||||
total=$((total + 1))
|
||||
if documented "$base"; then
|
||||
found=$((found + 1))
|
||||
# Documented AND present is not enough. The index presents these as
|
||||
# commands to run, and every caller — the wrapper guard included —
|
||||
# decides "is this tool here?" with `[ -x ]`. A 0644 wrapper is
|
||||
# documented, present, and dead: it reads as absent to every check that
|
||||
# matters while scoring 100% here. That is a false green, which is worse
|
||||
# than a red, so it fails rather than warns.
|
||||
[ -x "$tool" ] || suite_noexec+=("$base")
|
||||
else
|
||||
suite_missing+=("$base")
|
||||
fi
|
||||
done
|
||||
[ "$total" -eq 0 ] && continue
|
||||
|
||||
local pct=$(( found * 100 / total ))
|
||||
if [ "$enforced" -eq 1 ] && [ ${#suite_noexec[@]} -gt 0 ]; then
|
||||
printf 'FAIL %-12s %3d%% (%d/%d) documented but not executable: %s\n' \
|
||||
"$suite" "$pct" "$found" "$total" "${suite_noexec[*]}"
|
||||
rc=1
|
||||
fi
|
||||
if [ "$enforced" -eq 1 ] && [ ${#suite_missing[@]} -gt 0 ]; then
|
||||
printf 'FAIL %-12s %3d%% (%d/%d) undocumented: %s\n' \
|
||||
"$suite" "$pct" "$found" "$total" "${suite_missing[*]}"
|
||||
rc=1
|
||||
elif [ "$enforced" -eq 1 ] && [ ${#suite_noexec[@]} -eq 0 ]; then
|
||||
printf 'ok %-12s %3d%% (%d/%d) [enforced]\n' "$suite" "$pct" "$found" "$total"
|
||||
else
|
||||
printf 'info %-12s %3d%% (%d/%d) not yet enforced\n' "$suite" "$pct" "$found" "$total"
|
||||
fi
|
||||
|
||||
# Reverse: an index that names a tool this suite does not have sends agents
|
||||
# after something that cannot run. Only checked for enforced suites, where
|
||||
# the naming is unambiguous enough to attribute.
|
||||
if [ "$enforced" -eq 1 ]; then
|
||||
local -a stale=()
|
||||
local ref
|
||||
while read -r ref; do
|
||||
[ -n "$ref" ] || continue
|
||||
is_excluded "$ref" && continue
|
||||
[ -e "$dir$ref" ] || stale+=("$ref")
|
||||
done < <(grep -oE "$suite/[a-z0-9][a-z0-9._-]*\.sh" "$CORPUS" \
|
||||
| sed "s|^$suite/||" | sort -u)
|
||||
if [ ${#stale[@]} -gt 0 ]; then
|
||||
printf 'FAIL %-12s stale index references (no such file): %s\n' \
|
||||
"$suite" "${stale[*]}"
|
||||
rc=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n'
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
cat <<EOF
|
||||
Undocumented tools are undiscoverable. An agent cannot obey a hard gate that
|
||||
tells it to use a wrapper it has no way to learn exists — it will reach for raw
|
||||
curl/gh/tea instead, and the wrapper's provider-dialect handling will be lost.
|
||||
|
||||
Fix by naming each tool above in one of the index documents listed at the top,
|
||||
in the same commit that ships it.
|
||||
EOF
|
||||
else
|
||||
printf 'every enforced suite is fully discoverable.\n'
|
||||
fi
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# ---- self-test -------------------------------------------------------------
|
||||
# Proves the gate can actually fail. A checker that only ever passes is
|
||||
# indistinguishable from one that is not running, which is the failure mode this
|
||||
# whole file exists to prevent — so it must demonstrate a red on demand.
|
||||
self_test() {
|
||||
local tmp rc
|
||||
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' RETURN
|
||||
mkdir -p "$tmp/tools/git"
|
||||
printf '#!/bin/sh\n' > "$tmp/tools/git/documented-tool.sh"
|
||||
printf '#!/bin/sh\n' > "$tmp/tools/git/test-ignored.sh"
|
||||
chmod +x "$tmp/tools/git/documented-tool.sh" "$tmp/tools/git/test-ignored.sh"
|
||||
|
||||
# run_check reads the TOOLS_DIR / DOCS globals; an array cannot ride in a
|
||||
# command-prefix assignment, so point the globals at the fixture directly.
|
||||
TOOLS_DIR="$tmp/tools"
|
||||
DOCS=("$tmp/doc.md")
|
||||
|
||||
# Case 1: fully documented -> pass.
|
||||
printf 'see tools/git/documented-tool.sh for details\n' > "$tmp/doc.md"
|
||||
if run_check >/dev/null; then
|
||||
printf 'self-test 1/4 ok (complete index passes)\n'
|
||||
else
|
||||
printf 'self-test 1/4 FAIL (complete index should pass)\n'; return 1
|
||||
fi
|
||||
|
||||
# Case 2: an undocumented tool -> fail.
|
||||
printf '#!/bin/sh\n' > "$tmp/tools/git/undocumented-tool.sh"
|
||||
rc=0; run_check >/dev/null || rc=$?
|
||||
if [ "$rc" -eq 1 ]; then
|
||||
printf 'self-test 2/4 ok (undocumented tool fails the gate)\n'
|
||||
else
|
||||
printf 'self-test 2/4 FAIL (undocumented tool should fail, got rc=%s)\n' "$rc"; return 1
|
||||
fi
|
||||
|
||||
# Case 3: a stale index reference -> fail.
|
||||
rm "$tmp/tools/git/undocumented-tool.sh"
|
||||
printf 'also tools/git/deleted-tool.sh\n' >> "$tmp/doc.md"
|
||||
rc=0; run_check >/dev/null || rc=$?
|
||||
if [ "$rc" -eq 1 ]; then
|
||||
printf 'self-test 3/4 ok (stale index reference fails the gate)\n'
|
||||
else
|
||||
printf 'self-test 3/4 FAIL (stale reference should fail, got rc=%s)\n' "$rc"; return 1
|
||||
fi
|
||||
|
||||
# Case 4: documented, present, and NOT executable -> fail. Found by an
|
||||
# independent reviewer: a 0644 wrapper scored 100% here while reading as
|
||||
# absent to every `[ -x ]` in the fleet, including the wrapper guard's.
|
||||
sed -i '/deleted-tool/d' "$tmp/doc.md"
|
||||
printf '#!/bin/sh\n' > "$tmp/tools/git/noexec-tool.sh"
|
||||
chmod 0644 "$tmp/tools/git/noexec-tool.sh"
|
||||
printf 'and tools/git/noexec-tool.sh\n' >> "$tmp/doc.md"
|
||||
rc=0; run_check >/dev/null || rc=$?
|
||||
if [ "$rc" -eq 1 ]; then
|
||||
printf 'self-test 4/4 ok (documented but non-executable tool fails the gate)\n'
|
||||
else
|
||||
printf 'self-test 4/4 FAIL (non-executable tool should fail, got rc=%s)\n' "$rc"; return 1
|
||||
fi
|
||||
|
||||
printf '\nself-test passed: the gate demonstrably reds on every drift direction.\n'
|
||||
}
|
||||
|
||||
if [ "$SELF_TEST" -eq 1 ]; then
|
||||
self_test
|
||||
else
|
||||
resolve_locations
|
||||
run_check
|
||||
fi
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed comparison of deployed Mosaic tools to manifest-owned shipped tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
value = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
value.update(chunk)
|
||||
return value.hexdigest()
|
||||
|
||||
|
||||
def default_source_tools() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def normalize_source(path: Path) -> Path:
|
||||
candidate = path.resolve()
|
||||
return candidate / "tools" if (candidate / "tools").is_dir() else candidate
|
||||
|
||||
|
||||
def assert_traversable_directory(path: Path) -> None:
|
||||
mode = stat.S_IMODE(path.stat(follow_symlinks=False).st_mode)
|
||||
# At least one principal class must have both read and search. This catches
|
||||
# mode-000 even for privileged reviewers for whom os.access() would lie.
|
||||
if not any(mode & read and mode & execute for read, execute in ((0o400, 0o100), (0o040, 0o010), (0o004, 0o001))):
|
||||
raise PermissionError(f"directory has no readable/searchable mode: {path}")
|
||||
|
||||
|
||||
def census(root: Path, *, reject_symlinks: bool) -> dict[str, Path]:
|
||||
result: dict[str, Path] = {}
|
||||
|
||||
def onerror(error: OSError) -> None:
|
||||
raise error
|
||||
|
||||
for current, directories, filenames in os.walk(root, topdown=True, followlinks=False, onerror=onerror):
|
||||
current_path = Path(current)
|
||||
assert_traversable_directory(current_path)
|
||||
for name in directories:
|
||||
entry = current_path / name
|
||||
if entry.is_symlink() and reject_symlinks:
|
||||
# A source symlink makes the shipped census incomplete. Deployed
|
||||
# aliases are assessed later only when they occupy a required
|
||||
# framework path; installed-only aliases remain operator state.
|
||||
raise OSError(f"symlinked directory is not an independent census entry: {entry}")
|
||||
for name in filenames:
|
||||
entry = current_path / name
|
||||
if entry.is_symlink():
|
||||
if reject_symlinks:
|
||||
raise OSError(f"symlinked file is not an independent census entry: {entry}")
|
||||
result[entry.relative_to(root).as_posix()] = entry
|
||||
continue
|
||||
mode = entry.stat(follow_symlinks=False).st_mode
|
||||
if not stat.S_ISREG(mode):
|
||||
raise OSError(f"non-regular census entry: {entry}")
|
||||
if stat.S_IMODE(mode) & 0o444 == 0:
|
||||
raise PermissionError(f"file has no readable mode: {entry}")
|
||||
result[entry.relative_to(root).as_posix()] = entry
|
||||
return result
|
||||
|
||||
|
||||
def classify_with_manifest(source: Path, relatives: list[str]) -> dict[str, str]:
|
||||
framework = source.parent
|
||||
manifest = framework / "framework-manifest.txt"
|
||||
resolver = source / "_lib" / "manifest.sh"
|
||||
if not manifest.is_file() or not os.access(manifest, os.R_OK):
|
||||
raise OSError(f"ownership manifest is missing or unreadable: {manifest}")
|
||||
if not resolver.is_file() or not os.access(resolver, os.R_OK):
|
||||
raise OSError(f"canonical manifest resolver is missing or unreadable: {resolver}")
|
||||
payload = "".join(f"tools/{relative}\n" for relative in relatives)
|
||||
completed = subprocess.run(
|
||||
["bash", str(resolver), "classify"],
|
||||
input=payload,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env={**os.environ, "MANIFEST_FILE": str(manifest)},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
detail = completed.stderr.strip() or f"resolver rc={completed.returncode}"
|
||||
raise OSError(f"ownership manifest failed canonical resolution: {detail}")
|
||||
classified: dict[str, str] = {}
|
||||
for line in completed.stdout.splitlines():
|
||||
ownership, separator, manifest_path = line.partition("\t")
|
||||
if not separator or not manifest_path.startswith("tools/") or ownership not in {"framework", "operator"}:
|
||||
raise OSError(f"invalid canonical ownership output: {line!r}")
|
||||
relative = manifest_path.removeprefix("tools/")
|
||||
if relative in classified:
|
||||
raise OSError(f"duplicate canonical ownership output: {relative}")
|
||||
classified[relative] = ownership
|
||||
if set(classified) != set(relatives):
|
||||
raise OSError("canonical ownership output did not classify the complete source census")
|
||||
return classified
|
||||
|
||||
|
||||
def has_symlinked_component(root: Path, relative: str) -> bool:
|
||||
current = root
|
||||
for component in Path(relative).parts:
|
||||
current = current / component
|
||||
if current.is_symlink():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Detect deployed Mosaic framework-tool drift")
|
||||
parser.add_argument("--source-root", type=Path, default=Path(os.environ["MOSAIC_FRAMEWORK_SOURCE_ROOT"]) if os.environ.get("MOSAIC_FRAMEWORK_SOURCE_ROOT") else default_source_tools())
|
||||
parser.add_argument("--installed-root", type=Path, default=Path(os.environ.get("MOSAIC_HOME", Path.home() / ".config/mosaic")) / "tools")
|
||||
parser.add_argument("--verbose", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
source = normalize_source(args.source_root)
|
||||
installed = args.installed_root.resolve()
|
||||
try:
|
||||
if not source.is_dir():
|
||||
raise OSError(f"source tools missing: {source}")
|
||||
if not installed.is_dir():
|
||||
raise OSError(f"installed tools missing: {installed}")
|
||||
if source.samefile(installed):
|
||||
raise OSError("source and installed roots identify the same filesystem object")
|
||||
source_files = census(source, reject_symlinks=True)
|
||||
if not source_files:
|
||||
raise OSError("source tools census is empty")
|
||||
ownership = classify_with_manifest(source, sorted(source_files))
|
||||
required = sorted(relative for relative, owner in ownership.items() if owner == "framework")
|
||||
if not required:
|
||||
raise OSError("ownership manifest classifies zero shipped tools as framework-owned")
|
||||
installed_files = census(installed, reject_symlinks=False)
|
||||
except (OSError, PermissionError) as error:
|
||||
print(f"[framework-drift] CANNOT_ASSERT {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
in_sync: list[str] = []
|
||||
stale: list[str] = []
|
||||
not_installed: list[str] = []
|
||||
unsafe_alias: list[str] = []
|
||||
for relative in required:
|
||||
deployed = installed / relative
|
||||
if not deployed.is_file():
|
||||
not_installed.append(relative)
|
||||
continue
|
||||
if has_symlinked_component(installed, relative):
|
||||
unsafe_alias.append(relative)
|
||||
continue
|
||||
try:
|
||||
if source_files[relative].samefile(deployed):
|
||||
unsafe_alias.append(relative)
|
||||
elif digest(source_files[relative]) == digest(deployed):
|
||||
in_sync.append(relative)
|
||||
else:
|
||||
stale.append(relative)
|
||||
except OSError as error:
|
||||
print(f"[framework-drift] CANNOT_ASSERT cannot compare {relative}: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
source_relative = set(source_files)
|
||||
installed_only = sorted(set(installed_files) - source_relative)
|
||||
if args.verbose:
|
||||
for relative in in_sync:
|
||||
print(f"[framework-drift] IN_SYNC {relative}")
|
||||
for relative in stale:
|
||||
print(f"[framework-drift] STALE {relative}")
|
||||
for relative in not_installed:
|
||||
print(f"[framework-drift] NOT_INSTALLED {relative}")
|
||||
for relative in unsafe_alias:
|
||||
print(f"[framework-drift] UNSAFE_ALIAS {relative}")
|
||||
if args.verbose:
|
||||
for relative in installed_only:
|
||||
print(f"[framework-drift] INSTALLED_ONLY operator-or-unknown {relative}")
|
||||
|
||||
print(
|
||||
"[framework-drift] summary "
|
||||
f"in-sync={len(in_sync)} stale={len(stale)} not-installed={len(not_installed)} "
|
||||
f"unsafe-alias={len(unsafe_alias)} installed-only={len(installed_only)}"
|
||||
)
|
||||
print("[framework-drift] classification canonical framework-manifest ownership; installed-only=operator-or-unknown-preserved")
|
||||
if stale or not_installed or unsafe_alias:
|
||||
print("[framework-drift] FAIL deployed framework tools do not match independent shipped source; schedule a reviewed framework reseed", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,59 @@
|
||||
# Quality Rails Installation Script (Windows)
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Template,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$TargetDir = "."
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$RepoRoot = Split-Path -Parent $ScriptDir
|
||||
$TemplateDir = Join-Path $RepoRoot "templates\$Template"
|
||||
|
||||
if (-not (Test-Path $TemplateDir)) {
|
||||
Write-Error "Template '$Template' not found at $TemplateDir"
|
||||
Write-Host "Available templates: typescript-node, typescript-nextjs, python, monorepo"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Installing Quality Rails: $Template"
|
||||
Write-Host "Target directory: $TargetDir"
|
||||
Write-Host ""
|
||||
|
||||
# Copy template files
|
||||
Write-Host "Copying template files..."
|
||||
if (Test-Path "$TemplateDir\.husky") {
|
||||
Copy-Item -Path "$TemplateDir\.husky" -Destination $TargetDir -Recurse -Force
|
||||
}
|
||||
Copy-Item -Path "$TemplateDir\.lintstagedrc.js" -Destination $TargetDir -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item -Path "$TemplateDir\.eslintrc.strict.js" -Destination "$TargetDir\.eslintrc.js" -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item -Path "$TemplateDir\tsconfig.strict.json" -Destination "$TargetDir\tsconfig.json" -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item -Path "$TemplateDir\.woodpecker.yml" -Destination $TargetDir -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# Copy shared gitleaks config from templates root
|
||||
$SharedTemplates = Split-Path -Parent $TemplateDir
|
||||
Copy-Item -Path "$SharedTemplates\.gitleaks.toml" -Destination $TargetDir -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Host "✓ Files copied"
|
||||
|
||||
if (Test-Path "$TargetDir\package.json") {
|
||||
Write-Host ""
|
||||
Write-Host "⚠ package.json exists. Please manually merge dependencies from:"
|
||||
Write-Host " $TemplateDir\package.json.snippet"
|
||||
} else {
|
||||
Write-Host "⚠ No package.json found. Create one and add dependencies from:"
|
||||
Write-Host " $TemplateDir\package.json.snippet"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "✓ Quality Rails installed successfully!"
|
||||
Write-Host ""
|
||||
Write-Host "Next steps:"
|
||||
Write-Host "1. Install dependencies: npm install"
|
||||
Write-Host "2. Initialize husky: npx husky install"
|
||||
Write-Host "3. Install gitleaks: winget install gitleaks"
|
||||
Write-Host "4. Run verification: ..\quality-rails\scripts\verify.ps1"
|
||||
Write-Host "5. (Optional) Scan full history: gitleaks git --redact --verbose"
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Quality Rails Installation Script
|
||||
# Usage: ./install.sh --template typescript-node [--target /path/to/project]
|
||||
|
||||
TEMPLATE=""
|
||||
TARGET_DIR="."
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--template)
|
||||
TEMPLATE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--target)
|
||||
TARGET_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Usage: $0 --template <template-name> [--target <directory>]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$TEMPLATE" ]; then
|
||||
echo "Error: --template is required"
|
||||
echo "Available templates: typescript-node, typescript-nextjs, python, monorepo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
TEMPLATE_DIR="$REPO_ROOT/templates/$TEMPLATE"
|
||||
|
||||
if [ ! -d "$TEMPLATE_DIR" ]; then
|
||||
echo "Error: Template '$TEMPLATE' not found at $TEMPLATE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing Quality Rails: $TEMPLATE"
|
||||
echo "Target directory: $TARGET_DIR"
|
||||
echo ""
|
||||
|
||||
# Copy template files
|
||||
echo "Copying template files..."
|
||||
cp -r "$TEMPLATE_DIR/.husky" "$TARGET_DIR/" 2>/dev/null || true
|
||||
cp "$TEMPLATE_DIR/.lintstagedrc.js" "$TARGET_DIR/" 2>/dev/null || true
|
||||
cp "$TEMPLATE_DIR/.eslintrc.strict.js" "$TARGET_DIR/.eslintrc.js" 2>/dev/null || true
|
||||
cp "$TEMPLATE_DIR/tsconfig.strict.json" "$TARGET_DIR/tsconfig.json" 2>/dev/null || true
|
||||
cp "$TEMPLATE_DIR/.woodpecker.yml" "$TARGET_DIR/" 2>/dev/null || true
|
||||
|
||||
# Copy shared gitleaks config from templates root
|
||||
SHARED_TEMPLATES="$(dirname "$TEMPLATE_DIR")"
|
||||
cp "$SHARED_TEMPLATES/.gitleaks.toml" "$TARGET_DIR/" 2>/dev/null || true
|
||||
|
||||
echo "✓ Files copied"
|
||||
|
||||
# Check if package.json exists
|
||||
if [ -f "$TARGET_DIR/package.json" ]; then
|
||||
echo ""
|
||||
echo "⚠ package.json exists. Please manually merge dependencies from:"
|
||||
echo " $TEMPLATE_DIR/package.json.snippet"
|
||||
else
|
||||
echo "⚠ No package.json found. Create one and add dependencies from:"
|
||||
echo " $TEMPLATE_DIR/package.json.snippet"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✓ Quality Rails installed successfully!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Install dependencies: npm install"
|
||||
echo "2. Initialize husky: npx husky install"
|
||||
echo "3. Install gitleaks: https://github.com/gitleaks/gitleaks#installing"
|
||||
echo "4. Run verification: ~/.config/mosaic/bin/mosaic-quality-verify --target $TARGET_DIR"
|
||||
echo "5. (Optional) Scan full history: gitleaks git --redact --verbose"
|
||||
echo ""
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-check-test-enumeration.sh — needles for the enumeration guard (#1017).
|
||||
#
|
||||
# Every failure mode the guard promises gets BOTH polarities:
|
||||
# NEEDLE a fixture that MUST trip the guard, asserted on the guard's OWN
|
||||
# words (--out) — exit 1 alone cannot distinguish "caught the rogue
|
||||
# file" from "choked on the fixture".
|
||||
# CONTROL a fixture that MUST pass. A guard that failed unconditionally
|
||||
# would satisfy every needle here — the null-case defect the guard's
|
||||
# own subject matter (#1017) exists to make impossible.
|
||||
#
|
||||
# The needles encode the specific errors that produced #1017's thread:
|
||||
# n6 is the 20124 boundary file (a suite the strict prefix cannot name);
|
||||
# n2b proves surface 2 is PARSED, not line-ranged (three seats mis-scoped
|
||||
# hand-written ranges against ci.yml);
|
||||
# n5/n7 keep the exclusions file honest so it cannot become the next silent cap;
|
||||
# n8/c4 are F1 (20155): a commented-out ci.yml line is NOT enumeration —
|
||||
# commenting-out is the most common way a suite actually gets disabled,
|
||||
# and it must fail loud in one direction without false-staling the other.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GUARD="$HERE/check-test-enumeration.sh"
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
PASS=0; FAIL=0
|
||||
|
||||
# fixture <name> — a minimal repo root the guard accepts via --root:
|
||||
# one suite enumerated on S1 (plus a naming-outlier suite, so the S1 parser's
|
||||
# handling of non-prefix names is always exercised), one on S2, one check-script
|
||||
# named on S2 that is outside the population, and an empty exclusions file.
|
||||
fixture() {
|
||||
local r="$TMP/$1"
|
||||
mkdir -p "$r/packages/mosaic/framework/tools/git" \
|
||||
"$r/packages/mosaic/framework/tools/tmux" \
|
||||
"$r/packages/mosaic/framework/tools/quality/scripts" \
|
||||
"$r/.woodpecker"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$r/packages/mosaic/framework/tools/git/test-a.sh"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$r/packages/mosaic/framework/tools/tmux/outlier.test.sh"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$r/packages/mosaic/framework/tools/quality/scripts/test-ci.sh"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$r/packages/mosaic/framework/tools/quality/scripts/verify-thing.sh"
|
||||
cat > "$r/packages/mosaic/package.json" <<'JSON'
|
||||
{"scripts": {"test:framework-shell": "bash framework/tools/git/test-a.sh && bash framework/tools/tmux/outlier.test.sh"}}
|
||||
JSON
|
||||
cat > "$r/.woodpecker/ci.yml" <<'YML'
|
||||
steps:
|
||||
sanitize:
|
||||
commands:
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/verify-thing.sh
|
||||
guard:
|
||||
commands:
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/test-ci.sh
|
||||
YML
|
||||
: > "$r/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt"
|
||||
printf '%s' "$r"
|
||||
}
|
||||
|
||||
# expect <kind> <want-exit> <desc> [--out <substring>] -- <root>
|
||||
expect() {
|
||||
local kind="$1" want="$2" desc="$3"; shift 3
|
||||
local need_out=""
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--out) need_out="$2"; shift 2 ;;
|
||||
--) shift; break ;;
|
||||
esac
|
||||
done
|
||||
local root="$1" got=0 out
|
||||
out="$(bash "$GUARD" --root "$root" 2>&1)" || got=$?
|
||||
local why=""
|
||||
[[ "$got" == "$want" ]] || why="wanted exit $want, got $got"
|
||||
if [[ -z "$why" && -n "$need_out" && "$out" != *"$need_out"* ]]; then
|
||||
why="exit $got as expected, but output never said: $need_out"
|
||||
fi
|
||||
if [[ -z "$why" ]]; then
|
||||
printf ' PASS [%-7s] %s (exit %s)\n' "$kind" "$desc" "$got"
|
||||
PASS=$(( PASS + 1 ))
|
||||
else
|
||||
printf ' FAIL [%-7s] %s — %s\n' "$kind" "$desc" "$why"
|
||||
printf '%s\n' "$out" | sed 's/^/ | /'
|
||||
FAIL=$(( FAIL + 1 ))
|
||||
fi
|
||||
}
|
||||
|
||||
excl() { printf '%s\n' "$2" >> "$1/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt"; }
|
||||
|
||||
echo "=== c1: a fully consistent fixture passes ==="
|
||||
R="$(fixture c1)"
|
||||
expect CONTROL 0 "consistent tree: both surfaces enumerated, nothing unlisted" \
|
||||
--out "enumeration guard: OK" -- "$R"
|
||||
|
||||
echo "=== n1: an on-disk suite reachable from no surface must fail ==="
|
||||
R="$(fixture n1)"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/test-rogue.sh"
|
||||
expect NEEDLE 1 "unlisted suite is named in the failure" \
|
||||
--out "UNENUMERATED: 'packages/mosaic/framework/tools/git/test-rogue.sh'" -- "$R"
|
||||
|
||||
echo "=== n6: the 20124 boundary file — a suite the strict prefix cannot name ==="
|
||||
R="$(fixture n6)"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/rogue.test.sh"
|
||||
expect NEEDLE 1 "naming-outlier suite (*.test.sh) is a population member, not invisible" \
|
||||
--out "UNENUMERATED: 'packages/mosaic/framework/tools/git/rogue.test.sh'" -- "$R"
|
||||
|
||||
echo "=== c3: a non-suite script outside the pattern is outside it on BOTH sides ==="
|
||||
R="$(fixture c3)"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/check-unrelated.sh"
|
||||
expect CONTROL 0 "check-script on disk, unlisted, outside population: not the guard's business" \
|
||||
--out "enumeration guard: OK" -- "$R"
|
||||
|
||||
echo "=== n2/n2b: a surface naming a path absent from disk must fail — both surfaces ==="
|
||||
R="$(fixture n2)"
|
||||
rm "$R/packages/mosaic/framework/tools/git/test-a.sh"
|
||||
expect NEEDLE 1 "S1 (package.json) stale entry" \
|
||||
--out "STALE ENUMERATION: surfaces name 'packages/mosaic/framework/tools/git/test-a.sh'" -- "$R"
|
||||
R="$(fixture n2b)"
|
||||
rm "$R/packages/mosaic/framework/tools/quality/scripts/test-ci.sh"
|
||||
expect NEEDLE 1 "S2 (ci.yml) stale entry — proves ci.yml is parsed, not line-ranged" \
|
||||
--out "STALE ENUMERATION: surfaces name 'packages/mosaic/framework/tools/quality/scripts/test-ci.sh'" -- "$R"
|
||||
|
||||
echo "=== c2: a rogue suite with a SIGNED exclusion passes, and is counted ==="
|
||||
R="$(fixture c2)"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/test-rogue.sh"
|
||||
excl "$R" "packages/mosaic/framework/tools/git/test-rogue.sh | non-hermetic pending fixture work (needle-suite specimen)"
|
||||
expect CONTROL 0 "signed exclusion is honoured and visible in the summary" \
|
||||
--out "excluded (signed) 1" -- "$R"
|
||||
|
||||
echo "=== n3: an exclusion with no reason is not a decision ==="
|
||||
R="$(fixture n3)"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/test-rogue.sh"
|
||||
excl "$R" "packages/mosaic/framework/tools/git/test-rogue.sh | "
|
||||
expect NEEDLE 1 "empty reason rejected" --out "EXCLUSION MISSING REASON" -- "$R"
|
||||
R="$(fixture n3b)"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/test-rogue.sh"
|
||||
excl "$R" "packages/mosaic/framework/tools/git/test-rogue.sh"
|
||||
expect NEEDLE 1 "missing separator rejected (the path alone is not a signature)" \
|
||||
--out "EXCLUSION MISSING REASON" -- "$R"
|
||||
|
||||
echo "=== n4: an exclusion whose path is gone is stale, not satisfied ==="
|
||||
R="$(fixture n4)"
|
||||
excl "$R" "packages/mosaic/framework/tools/git/test-vanished.sh | was excluded once, then deleted"
|
||||
expect NEEDLE 1 "stale exclusion rejected" --out "STALE EXCLUSION" -- "$R"
|
||||
|
||||
echo "=== n5: excluding an enumerated suite is a contradiction, not belt-and-braces ==="
|
||||
R="$(fixture n5)"
|
||||
excl "$R" "packages/mosaic/framework/tools/git/test-a.sh | already in CI but excluded anyway"
|
||||
expect NEEDLE 1 "contradictory exclusion rejected" --out "CONTRADICTORY EXCLUSION" -- "$R"
|
||||
|
||||
echo "=== n8/c4: a commented-out ci.yml line is not enumeration (F1, 20155) ==="
|
||||
R="$(fixture n8)"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/test-disabled.sh"
|
||||
printf ' # - bash packages/mosaic/framework/tools/git/test-disabled.sh\n' >> "$R/.woodpecker/ci.yml"
|
||||
expect NEEDLE 1 "suite named only in a commented-out invocation is UNENUMERATED" \
|
||||
--out "UNENUMERATED: 'packages/mosaic/framework/tools/git/test-disabled.sh'" -- "$R"
|
||||
R="$(fixture c4)"
|
||||
printf ' # - bash packages/mosaic/framework/tools/git/test-vanished.sh\n' >> "$R/.woodpecker/ci.yml"
|
||||
expect CONTROL 0 "comment naming an absent path raises no false stale-enumeration" \
|
||||
--out "enumeration guard: OK" -- "$R"
|
||||
|
||||
echo "=== n7: excluding a file outside the population is dead weight, not coverage ==="
|
||||
R="$(fixture n7)"
|
||||
excl "$R" "packages/mosaic/framework/tools/quality/scripts/verify-thing.sh | not a suite but signing it anyway"
|
||||
expect NEEDLE 1 "out-of-population exclusion rejected" --out "EXCLUSION OUTSIDE POPULATION" -- "$R"
|
||||
|
||||
echo
|
||||
printf 'enumeration-guard needles: %d passed, %d failed\n' "$PASS" "$FAIL"
|
||||
(( FAIL == 0 ))
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
CHECKER = Path(__file__).with_name("framework-drift-check.py")
|
||||
REAL_RESOLVER = CHECKER.parents[2] / "_lib" / "manifest.sh"
|
||||
|
||||
|
||||
class FrameworkDriftCheckTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
root = Path(self.temp.name)
|
||||
self.framework = root / "framework"
|
||||
self.source = self.framework / "tools"
|
||||
self.installed = root / "home" / "tools"
|
||||
for directory in (self.source / "git", self.source / "_lib", self.installed / "git", self.installed / "_lib"):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(REAL_RESOLVER, self.source / "_lib" / "manifest.sh")
|
||||
(self.source / "git" / "guard.sh").write_text("fixed\n")
|
||||
(self.source / "git" / "new-wrapper.sh").write_text("new\n")
|
||||
(self.source / "_lib" / "credentials.json").write_text("source-placeholder\n")
|
||||
self.write_manifest()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp.cleanup()
|
||||
|
||||
def write_manifest(self, operator_extra: str = "") -> None:
|
||||
(self.framework / "framework-manifest.txt").write_text(
|
||||
"[framework]\ntools/**\n[operator]\ntools/_lib/credentials.json\n" + operator_extra
|
||||
)
|
||||
|
||||
def run_check(self, *extra: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(CHECKER), "--source-root", str(self.framework), "--installed-root", str(self.installed), *extra],
|
||||
text=True, capture_output=True, check=False,
|
||||
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
||||
)
|
||||
|
||||
def install_matching(self) -> None:
|
||||
for relative in ("git/guard.sh", "git/new-wrapper.sh", "_lib/manifest.sh"):
|
||||
shutil.copy2(self.source / relative, self.installed / relative)
|
||||
(self.installed / "_lib" / "credentials.json").write_text("different-operator-secret\n")
|
||||
|
||||
def test_fails_loudly_and_classifies_stale_missing_and_installed_only(self) -> None:
|
||||
(self.installed / "git" / "guard.sh").write_text("broken\n")
|
||||
shutil.copy2(self.source / "_lib" / "manifest.sh", self.installed / "_lib" / "manifest.sh")
|
||||
(self.installed / "local-helper.sh").write_text("operator\n")
|
||||
result = self.run_check("--verbose")
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("STALE git/guard.sh", result.stdout)
|
||||
self.assertIn("NOT_INSTALLED git/new-wrapper.sh", result.stdout)
|
||||
self.assertIn("INSTALLED_ONLY operator-or-unknown local-helper.sh", result.stdout)
|
||||
self.assertIn("FAIL deployed framework tools", result.stderr)
|
||||
|
||||
def test_passes_only_when_every_manifest_owned_source_file_matches(self) -> None:
|
||||
self.install_matching()
|
||||
result = self.run_check()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("stale=0 not-installed=0 unsafe-alias=0", result.stdout)
|
||||
|
||||
def test_exact_operator_directory_does_not_hide_framework_drift_beneath_it(self) -> None:
|
||||
self.install_matching()
|
||||
(self.installed / "git" / "guard.sh").write_text("drift-hidden-by-directory-entry\n")
|
||||
self.write_manifest("tools/git\n")
|
||||
|
||||
result = self.run_check()
|
||||
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertIn("STALE git/guard.sh", result.stdout)
|
||||
|
||||
def test_manifest_is_required_and_policy_changes_take_effect(self) -> None:
|
||||
self.install_matching()
|
||||
(self.installed / "git" / "guard.sh").write_text("operator-divergence\n")
|
||||
self.write_manifest("tools/git/guard.sh\n")
|
||||
self.assertEqual(self.run_check().returncode, 0)
|
||||
(self.framework / "framework-manifest.txt").unlink()
|
||||
result = self.run_check()
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("CANNOT_ASSERT ownership manifest is missing", result.stderr)
|
||||
|
||||
def test_empty_and_unreadable_source_census_cannot_assert(self) -> None:
|
||||
empty_framework = Path(self.temp.name) / "empty-framework"
|
||||
empty_source = empty_framework / "tools"
|
||||
empty_source.mkdir(parents=True)
|
||||
shutil.copy2(self.framework / "framework-manifest.txt", empty_framework / "framework-manifest.txt")
|
||||
# The canonical resolver is supplied outside the empty census solely so
|
||||
# this probe reaches the explicit minimum-population guard.
|
||||
result = subprocess.run([sys.executable, str(CHECKER), "--source-root", str(empty_framework), "--installed-root", str(self.installed)], text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("CANNOT_ASSERT", result.stderr)
|
||||
|
||||
blocked = self.source / "blocked"
|
||||
blocked.mkdir(); (blocked / "hidden.sh").write_text("hidden\n"); blocked.chmod(0)
|
||||
try:
|
||||
result = self.run_check()
|
||||
finally:
|
||||
blocked.chmod(0o700)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("CANNOT_ASSERT", result.stderr)
|
||||
self.assertTrue("Permission denied" in result.stderr or "no readable/searchable mode" in result.stderr)
|
||||
|
||||
def test_root_and_descendant_aliases_cannot_report_clean(self) -> None:
|
||||
result = subprocess.run([sys.executable, str(CHECKER), "--source-root", str(self.framework), "--installed-root", str(self.source)], text=True, capture_output=True)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("same filesystem object", result.stderr)
|
||||
|
||||
shutil.copy2(self.source / "_lib" / "manifest.sh", self.installed / "_lib" / "manifest.sh")
|
||||
shutil.rmtree(self.installed / "git")
|
||||
(self.installed / "git").symlink_to(self.source / "git", target_is_directory=True)
|
||||
result = self.run_check()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue("symlinked directory" in result.stderr or "UNSAFE_ALIAS" in result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Doctor must contain a stalled drift checker and continue its remaining audit.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DOCTOR="$SCRIPT_DIR/../../_scripts/mosaic-doctor"
|
||||
WORK="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/framework-drift-doctor}"
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/source/tools/quality/scripts" "$WORK/source/tools/_scripts" "$WORK/home/tools"
|
||||
cp "$DOCTOR" "$WORK/source/tools/_scripts/mosaic-doctor"
|
||||
cat > "$WORK/source/tools/quality/scripts/framework-drift-check.py" <<'PY'
|
||||
import time
|
||||
time.sleep(30)
|
||||
PY
|
||||
|
||||
start=$(date +%s)
|
||||
set +e
|
||||
output=$(MOSAIC_HOME="$WORK/home" MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC=1 \
|
||||
bash "$WORK/source/tools/_scripts/mosaic-doctor" --fail-on-warn 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
elapsed=$(( $(date +%s) - start ))
|
||||
|
||||
[[ "$rc" -ne 0 ]] || { echo "FAIL: checker timeout became doctor success" >&2; exit 1; }
|
||||
[[ "$elapsed" -lt 10 ]] || { echo "FAIL: checker hang escaped watchdog (${elapsed}s)" >&2; exit 1; }
|
||||
[[ "$output" == *"CANNOT_ASSERT framework drift checker timed out"* ]] || {
|
||||
echo "FAIL: missing timeout CANNOT_ASSERT diagnostic" >&2; printf '%s\n' "$output" >&2; exit 1;
|
||||
}
|
||||
[[ "$output" == *"[mosaic-doctor] warnings="* ]] || {
|
||||
echo "FAIL: doctor did not continue after checker timeout" >&2; printf '%s\n' "$output" >&2; exit 1;
|
||||
}
|
||||
|
||||
echo "framework drift doctor watchdog regression passed"
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-install-migration.sh — fixture matrix for the v2→v3 (Constitution) upgrade
|
||||
# migration in install.sh. Runs the installer against throwaway MOSAIC_HOME dirs
|
||||
# with MOSAIC_SYNC_ONLY=1 (file phase only — no environment-touching post-install)
|
||||
# and asserts the framework-owned-overwrite + user-preserve + backup semantics.
|
||||
#
|
||||
# Mirrors the TS fixture suite in packages/mosaic/src/config/file-adapter.test.ts;
|
||||
# both installers MUST behave identically.
|
||||
#
|
||||
# Usage: bash test-install-migration.sh
|
||||
set -uo pipefail
|
||||
|
||||
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
|
||||
INSTALL="$FW/install.sh"
|
||||
DEFA="$FW/defaults"
|
||||
|
||||
pass=0; fail=0
|
||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||
run() { MOSAIC_HOME="$1" MOSAIC_INSTALL_MODE="$2" MOSAIC_SYNC_ONLY=1 bash "$INSTALL" >/dev/null 2>&1; }
|
||||
|
||||
echo "install.sh v2→v3 migration fixture matrix:"
|
||||
|
||||
# F1 — fresh install
|
||||
T1=$(mktemp -d); run "$T1" overwrite
|
||||
chk "F1 fresh: CONSTITUTION/AGENTS/STANDARDS/TOOLS seeded" \
|
||||
"[ -f '$T1/CONSTITUTION.md' ] && [ -f '$T1/AGENTS.md' ] && [ -f '$T1/STANDARDS.md' ] && [ -f '$T1/TOOLS.md' ]"
|
||||
chk "F1 fresh: AGENTS == shipped default" "cmp -s '$T1/AGENTS.md' '$DEFA/AGENTS.md'"
|
||||
chk "F1 fresh: framework-version stamped 3" "[ \"\$(cat '$T1/.framework-version' 2>/dev/null)\" = 3 ]"
|
||||
chk "F1 fresh: Pi goal extension deploys under Mosaic runtime" \
|
||||
"cmp -s '$T1/runtime/pi/goal-extension.ts' '$FW/runtime/pi/goal-extension.ts'"
|
||||
chk "F1 fresh: installer creates no nested main Pi config" "[ ! -e '$T1/.pi' ]"
|
||||
|
||||
# F2 — legacy install with a user-edited AGENTS.md (the sanctioned pre-constitution customization)
|
||||
T2=$(mktemp -d); mkdir -p "$T2/credentials"
|
||||
printf '# user-edited AGENTS pre-constitution\n' > "$T2/AGENTS.md"
|
||||
printf '# my persona\n' > "$T2/SOUL.md"
|
||||
printf 'token\n' > "$T2/credentials/c.json"
|
||||
echo 2 > "$T2/.framework-version"
|
||||
run "$T2" keep
|
||||
chk "F2 legacy-edited: AGENTS overwritten to framework version" "cmp -s '$T2/AGENTS.md' '$DEFA/AGENTS.md'"
|
||||
chk "F2 legacy-edited: prior AGENTS saved to .pre-constitution.bak" \
|
||||
"grep -q 'user-edited AGENTS pre-constitution' '$T2/AGENTS.md.pre-constitution.bak'"
|
||||
chk "F2 legacy-edited: SOUL.md preserved" "grep -q 'my persona' '$T2/SOUL.md'"
|
||||
chk "F2 legacy-edited: credentials preserved" "grep -q token '$T2/credentials/c.json'"
|
||||
chk "F2 legacy-edited: CONSTITUTION.md installed" "[ -f '$T2/CONSTITUTION.md' ]"
|
||||
run "$T2" keep
|
||||
chk "F2 idempotent: .pre-constitution.bak preserved across a 2nd upgrade" \
|
||||
"grep -q 'user-edited AGENTS pre-constitution' '$T2/AGENTS.md.pre-constitution.bak'"
|
||||
|
||||
# F3 — user-tuned STANDARDS.md
|
||||
T3=$(mktemp -d); printf '# tuned standards\n' > "$T3/STANDARDS.md"; printf '# persona\n' > "$T3/SOUL.md"; echo 2 > "$T3/.framework-version"
|
||||
run "$T3" keep
|
||||
chk "F3 tuned-standard: STANDARDS overwritten" "cmp -s '$T3/STANDARDS.md' '$DEFA/STANDARDS.md'"
|
||||
chk "F3 tuned-standard: tuned copy backed up" "grep -q 'tuned standards' '$T3/STANDARDS.md.pre-constitution.bak'"
|
||||
|
||||
# F4 — unattended / no TTY (stdin closed): must complete without hanging, default to keep
|
||||
T4=$(mktemp -d); printf '# persona\n' > "$T4/SOUL.md"; printf '# old\n' > "$T4/AGENTS.md"; echo 2 > "$T4/.framework-version"
|
||||
MOSAIC_HOME="$T4" MOSAIC_SYNC_ONLY=1 bash "$INSTALL" </dev/null >/dev/null 2>&1
|
||||
chk "F4 no-TTY: completed, AGENTS updated" "cmp -s '$T4/AGENTS.md' '$DEFA/AGENTS.md'"
|
||||
|
||||
# F5 — failure path must not corrupt existing data (invalid mode rejected before any file op)
|
||||
T5=$(mktemp -d); mkdir -p "$T5/credentials"; printf '# orig\n' > "$T5/SOUL.md"; printf 'keepme\n' > "$T5/credentials/c.json"; echo 2 > "$T5/.framework-version"
|
||||
MOSAIC_HOME="$T5" MOSAIC_INSTALL_MODE=bogus MOSAIC_SYNC_ONLY=1 bash "$INSTALL" >/dev/null 2>&1; rc=$?
|
||||
chk "F5 failure: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]"
|
||||
chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
|
||||
|
||||
# F6 — keep-mode re-seed (the `mosaic update` path) MUST preserve ALL user-owned
|
||||
# fleet state — including an unanticipated file the manifest never names, which
|
||||
# resolves to operator-owned by the #791 fail-safe — while refreshing the
|
||||
# framework-owned schema/examples.
|
||||
T6=$(mktemp -d); mkdir -p "$T6/fleet/examples" "$T6/fleet/run" "$T6/fleet/agents"
|
||||
printf '# persona\n' > "$T6/SOUL.md" # makes it a recognized existing install (→ keep mode)
|
||||
printf 'version: 1\nagents:\n - name: coder0\n' > "$T6/fleet/roster.yaml"
|
||||
printf '{"version":1,"agents":[{"name":"json-user"}]}\n' > "$T6/fleet/roster.json"
|
||||
printf 'version: 1\nagents:\n - name: not-active-roster\n' > "$T6/fleet/my-fleet.yaml"
|
||||
printf 'ts=x\n' > "$T6/fleet/run/coder0.hb"
|
||||
printf 'MOSAIC_AGENT_NAME=coder0\n' > "$T6/fleet/agents/coder0.env"
|
||||
printf '# stale preset\n' > "$T6/fleet/examples/general.yaml"
|
||||
printf '{"stale":true}\n' > "$T6/fleet/roster.schema.json"
|
||||
E6=$(mktemp -d)
|
||||
cp "$T6/fleet/roster.yaml" "$E6/roster-yaml.expected"
|
||||
cp "$T6/fleet/roster.json" "$E6/roster-json.expected"
|
||||
cp "$T6/fleet/my-fleet.yaml" "$E6/my-fleet.expected"
|
||||
cp "$T6/fleet/run/coder0.hb" "$E6/run.expected"
|
||||
cp "$T6/fleet/agents/coder0.env" "$E6/agent.expected"
|
||||
echo 3 > "$T6/.framework-version"
|
||||
run "$T6" keep
|
||||
chk "F6 reseed: exact roster.yaml bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.yaml' '$E6/roster-yaml.expected'"
|
||||
chk "F6 reseed: exact roster.json bytes survive keep-mode sync" "cmp -s '$T6/fleet/roster.json' '$E6/roster-json.expected'"
|
||||
chk "F6 reseed: unanticipated operator fleet file survives (fail-safe, #791)" "cmp -s '$T6/fleet/my-fleet.yaml' '$E6/my-fleet.expected'"
|
||||
chk "F6 reseed: per-agent env bytes survive" "cmp -s '$T6/fleet/agents/coder0.env' '$E6/agent.expected'"
|
||||
chk "F6 reseed: heartbeat bytes survive" "cmp -s '$T6/fleet/run/coder0.hb' '$E6/run.expected'"
|
||||
chk "F6 reseed: framework examples are refreshed" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
|
||||
chk "F6 reseed: framework roster schema is refreshed" "cmp -s '$T6/fleet/roster.schema.json' '$FW/fleet/roster.schema.json'"
|
||||
chk "F6 reseed: Pi goal extension is refreshed from framework source" \
|
||||
"cmp -s '$T6/runtime/pi/goal-extension.ts' '$FW/runtime/pi/goal-extension.ts'"
|
||||
|
||||
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6" "$E6"
|
||||
echo
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
[ "$fail" -eq 0 ]
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression checks for connector-kind-conditional fleet roster schema."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
schema_path = Path(__file__).resolve().parents[3] / "fleet" / "roster.schema.json"
|
||||
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
validator = Draft202012Validator(schema)
|
||||
base = {
|
||||
"version": 1,
|
||||
"transport": "tmux",
|
||||
"agents": [{"name": "orchestrator", "runtime": "pi"}],
|
||||
}
|
||||
|
||||
valid = [
|
||||
{"kind": "tmux"},
|
||||
{"kind": "discord", "discord": {"channel_id": "123"}},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
]
|
||||
invalid = [
|
||||
{"kind": "tmux", "discord": {"channel_id": "123"}},
|
||||
{"kind": "tmux", "matrix": {}},
|
||||
{"kind": "discord"},
|
||||
{"kind": "discord", "matrix": {}},
|
||||
{
|
||||
"kind": "discord",
|
||||
"discord": {"channel_id": "123"},
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
{"kind": "matrix"},
|
||||
{"kind": "matrix", "discord": {"channel_id": "123"}},
|
||||
{"kind": "discord", "discord": {"channel_id": ""}},
|
||||
{"kind": "discord", "discord": {"channel_id": " "}},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "\t",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": "",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": " ",
|
||||
"room_id": "!room:example",
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
"kind": "matrix",
|
||||
"matrix": {
|
||||
"homeserver_url": "https://matrix.example",
|
||||
"user_id": "@mosaic:example",
|
||||
"room_id": "\n",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
for connector in valid:
|
||||
errors = list(validator.iter_errors({**base, "connector": connector}))
|
||||
if errors:
|
||||
print(f"expected valid connector {connector}: {errors}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
for connector in invalid:
|
||||
if not list(validator.iter_errors({**base, "connector": connector})):
|
||||
print(f"expected invalid connector: {connector}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
print("connector schema regression: PASS")
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-upgrade-durable-snapshot.sh — the #791 PR2 regression gate.
|
||||
#
|
||||
# PR1 gave keep-mode upgrades two protections: the manifest (a keep-sync only
|
||||
# ever writes framework-owned paths — operator config is structurally untouched)
|
||||
# and an EPHEMERAL /tmp snapshot that rolls the whole target back if the sync
|
||||
# CRASHES mid-write. PR2 adds a third, independent layer for the case neither
|
||||
# covers: a "successful" upgrade that a manifest/logic bug silently let touch an
|
||||
# operator file. That layer is a DURABLE, operator-scoped pre-update snapshot:
|
||||
#
|
||||
# Part 1 (scope): before any mutation, the installer copies exactly the
|
||||
# operator-owned files that exist into a retained backup
|
||||
# under $XDG_STATE_HOME/mosaic/backups/pre-update-<ts>/ —
|
||||
# framework files are NOT captured.
|
||||
# Part 2 (perms): the backup root, snapshot dir and every nested dir are
|
||||
# 0700; every backed-up file is 0600 (never world-readable,
|
||||
# even though operator config may hold secrets).
|
||||
# Part 3 (no leak): a secret seeded into credentials.json is copied into the
|
||||
# snapshot (proving coverage) but its value never appears
|
||||
# on stdout/stderr — the snapshot reports counts/paths only.
|
||||
# Part 4 (retention): only the newest MOSAIC_BACKUP_RETENTION snapshots survive;
|
||||
# older ones are pruned.
|
||||
# Part 5 (verify net): if the upgrade DID modify an operator file (injected here
|
||||
# with a cp shim that scribbles on SOUL.md while a framework
|
||||
# file is copied), the post-sync verify restores that file
|
||||
# from the durable snapshot and warns loudly. The control —
|
||||
# the same installer with the verify call stripped — leaves
|
||||
# the corruption in place, proving the net is load-bearing.
|
||||
#
|
||||
# Usage: bash test-upgrade-durable-snapshot.sh
|
||||
set -uo pipefail
|
||||
|
||||
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
|
||||
INSTALL="$FW/install.sh"
|
||||
ORIG_PATH="$PATH"
|
||||
FRAMEWORK_VERSION="$(grep -m1 '^FRAMEWORK_VERSION=' "$INSTALL" | cut -d= -f2)"
|
||||
|
||||
# Control installers must live INSIDE $FW: install.sh derives SOURCE_DIR from its
|
||||
# own path and sources tools/_lib/manifest.sh relative to it, so a copy anywhere
|
||||
# else aborts before the sync. Each control is a shipped installer with one guard
|
||||
# line stripped (keyed off a `# <MARKER>` anchor), proving that guard load-bearing.
|
||||
# All controls share the .install-*.tmp.sh glob so one trap sweeps them on exit.
|
||||
VERIFYCTRL="$FW/.install-verifynet-control.tmp.sh"
|
||||
rm -f "$FW"/.install-*.tmp.sh
|
||||
trap 'rm -f "$FW"/.install-*.tmp.sh' EXIT
|
||||
|
||||
# mk_control <marker-regex> <name> — echo a control installer path ($FW-local) that
|
||||
# is $INSTALL with every line matching /<marker-regex>/ deleted.
|
||||
mk_control() {
|
||||
local path="$FW/.install-$2.tmp.sh"
|
||||
sed "/$1/d" "$INSTALL" > "$path"
|
||||
printf '%s' "$path"
|
||||
}
|
||||
|
||||
pass=0; fail=0
|
||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||
|
||||
SECRET='SUPER-SECRET-TOKEN-do-not-log-pr2'
|
||||
SOUL_ORIG='# persona'
|
||||
# A framework file the sync copies (source ships it; the seeded target omits it,
|
||||
# so the bytes differ and cp is attempted). The Part-5 shim keys off this path.
|
||||
POISON_REL='guides/E2E-DELIVERY.md'
|
||||
|
||||
# Seed a recognized keep-mode install holding four operator-owned files across
|
||||
# the identity file, an operator subtree, memory, and the credentials carve-out.
|
||||
seed_home() {
|
||||
local H="$1"
|
||||
mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory"
|
||||
printf '%s\n' "$SOUL_ORIG" > "$H/SOUL.md" # recognized install → keep mode
|
||||
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
|
||||
printf '# operator memory\n' > "$H/memory/note.md"
|
||||
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
|
||||
echo 3 > "$H/.framework-version"
|
||||
# Deliberately NO guides/E2E-DELIVERY.md so the sync copies it (framework file,
|
||||
# bytes differ) — that copy is where the Part-5 corruption shim fires.
|
||||
}
|
||||
|
||||
# A pre-v2 (legacy) keep-mode install: SOUL.md marks it recognized, and a bin/
|
||||
# tree with NO .framework-version makes installed_framework_version() report 1, so
|
||||
# the v1→v2 migration (which deletes bin/) runs. bin/ is unknown⇒operator, so the
|
||||
# durable snapshot captures it — the verify net must NOT heal the intended removal.
|
||||
seed_home_v1() {
|
||||
local H="$1"
|
||||
mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory" "$H/bin"
|
||||
printf '%s\n' "$SOUL_ORIG" > "$H/SOUL.md"
|
||||
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
|
||||
printf '#!/bin/sh\necho legacy\n' > "$H/bin/tool.sh"; chmod +x "$H/bin/tool.sh"
|
||||
# Deliberately NO .framework-version and NO guides/E2E-DELIVERY.md (see seed_home).
|
||||
}
|
||||
|
||||
# A cp shim that, while the framework POISON file is being copied during sync,
|
||||
# swaps the operator credentials file for a symlink pointing at an attacker-
|
||||
# readable file OUTSIDE the target — simulating post-snapshot tampering (CWE-59).
|
||||
# The durable snapshot already holds the real credentials (it is taken before any
|
||||
# sync), so the verify net must restore a REAL file in place WITHOUT following the
|
||||
# link (which would write the snapshot's secret out through it). $EXFIL_TARGET is
|
||||
# expanded at shim-write time from the caller's environment.
|
||||
#
|
||||
# PORTABILITY (why this shim, not the real `cp`): the CWE-59 leak this exercises is
|
||||
# `cp` writing THROUGH a symlinked destination. GNU/BSD cp — what a real operator
|
||||
# runs `mosaic update` under — follows the dest symlink and leaks. busybox cp (the
|
||||
# Alpine CI image) REPLACES a symlinked dest instead of following it, so under the
|
||||
# CI harness the leak vector simply does not exist and the negative control could
|
||||
# never reproduce it. This shim therefore emulates the real-target GNU cp behavior
|
||||
# PORTABLY: when the destination is a symlink it writes the source bytes through the
|
||||
# link via redirection (which follows symlinks on every coreutils, busybox included);
|
||||
# otherwise it delegates to the host's real cp unchanged. Both the shipped-case and
|
||||
# the negative control run through this identical shim, so the ONLY difference
|
||||
# between them remains the SYMLINK-LEAF-GUARD — the control stays load-bearing and
|
||||
# non-tautological. It does NOT touch install.sh (approved) or the real assertions:
|
||||
# with the guard present the symlinked leaf is dropped BEFORE this cp runs, so the
|
||||
# dest is a real file and the delegate path is taken exactly as on a GNU host.
|
||||
make_symlink_leaf_shim() {
|
||||
local dir="$1" home="$2"
|
||||
cat > "$dir/cp" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
dest="\${@: -1}"
|
||||
src="\${@:(-2):1}"
|
||||
case "\$dest" in
|
||||
*/$POISON_REL)
|
||||
rm -f "$home/tools/_lib/credentials.json"
|
||||
ln -s "$EXFIL_TARGET" "$home/tools/_lib/credentials.json"
|
||||
;;
|
||||
esac
|
||||
# Coreutils-agnostic emulation of GNU cp's follow-through-dest-symlink behavior.
|
||||
if [[ -L "\$dest" && -f "\$src" ]]; then
|
||||
cat "\$src" > "\$dest"
|
||||
exit \$?
|
||||
fi
|
||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||
SHIM
|
||||
chmod +x "$dir/cp"
|
||||
}
|
||||
|
||||
# A cp shim that, while the framework POISON file is being copied during sync,
|
||||
# also appends garbage to the operator SOUL.md — simulating a manifest bug that
|
||||
# writes outside the framework lane. The framework copy itself still succeeds
|
||||
# (real cp runs), so the sync completes 0 and the post-sync verify is what must
|
||||
# catch and undo the operator-file damage. The snapshot's own cp only ever
|
||||
# targets operator files (never guides/…), so it is never corrupted by this shim.
|
||||
make_corrupt_shim() {
|
||||
local dir="$1" home="$2"
|
||||
cat > "$dir/cp" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
dest="\${@: -1}"
|
||||
case "\$dest" in
|
||||
*/$POISON_REL) printf 'CORRUPTION-mid-sync\n' >> "$home/SOUL.md" 2>/dev/null || true ;;
|
||||
esac
|
||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||
SHIM
|
||||
chmod +x "$dir/cp"
|
||||
}
|
||||
|
||||
# Run one keep-mode, sync-only upgrade with $XDG_STATE_HOME redirected to a
|
||||
# throwaway dir (so the real ~/.local/state is never touched). Optional args:
|
||||
# $2 shim-maker (default none), $3 MOSAIC_BACKUP_RETENTION (default unset).
|
||||
# Echoes: "<exit>\t<out>\t<state-dir>\t<home>".
|
||||
# $4 seeder (default seed_home) — swap in seed_home_v1 for the migration case.
|
||||
run_snap() {
|
||||
local installer="$1" shim_maker="${2:-}" retention="${3:-}" seeder="${4:-seed_home}" H STATE OUT SHIM rc pathpre
|
||||
H=$(mktemp -d); STATE=$(mktemp -d); OUT=$(mktemp); pathpre="$ORIG_PATH"
|
||||
"$seeder" "$H"
|
||||
if [[ -n "$shim_maker" ]]; then
|
||||
SHIM=$(mktemp -d); "$shim_maker" "$SHIM" "$H"; pathpre="$SHIM:$ORIG_PATH"
|
||||
fi
|
||||
set +e
|
||||
env PATH="$pathpre" XDG_STATE_HOME="$STATE" \
|
||||
${retention:+MOSAIC_BACKUP_RETENTION="$retention"} \
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 \
|
||||
bash "$installer" >"$OUT" 2>&1
|
||||
rc=$?
|
||||
set -e 2>/dev/null || true
|
||||
[[ -n "$shim_maker" ]] && rm -rf "$SHIM"
|
||||
printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$STATE" "$H"
|
||||
}
|
||||
|
||||
# Resolve the single pre-update-* snapshot dir under a state dir (newest if many).
|
||||
snap_dir() {
|
||||
local -a snapshots=()
|
||||
mapfile -t snapshots < <(
|
||||
find "$1/mosaic/backups" -maxdepth 1 -type d -name 'pre-update-*' 2>/dev/null \
|
||||
| LC_ALL=C sort -r
|
||||
)
|
||||
printf '%s\n' "${snapshots[0]:-}"
|
||||
}
|
||||
|
||||
echo "── Part 1/2/3: durable snapshot scope, perms, no-leak ──────────────────"
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL")
|
||||
SNAP="$(snap_dir "$STATE")"
|
||||
|
||||
chk "upgrade succeeds" "[ '$rc' -eq 0 ]"
|
||||
chk "exactly one pre-update snapshot created" "[ \$(find '$STATE/mosaic/backups' -maxdepth 1 -type d -name 'pre-update-*' | wc -l) -eq 1 ]"
|
||||
chk "snapshot: SOUL.md captured" "[ -f '$SNAP/SOUL.md' ]"
|
||||
chk "snapshot: operator subtree captured" "[ -f '$SNAP/agents/coder0.conf' ]"
|
||||
chk "snapshot: memory captured" "[ -f '$SNAP/memory/note.md' ]"
|
||||
chk "snapshot: credentials carve-out captured" "[ -f '$SNAP/tools/_lib/credentials.json' ]"
|
||||
chk "snapshot: SOUL.md bytes preserved" "[ \"\$(cat '$SNAP/SOUL.md')\" = '$SOUL_ORIG' ]"
|
||||
chk "snapshot: framework file NOT captured" "[ ! -e '$SNAP/CONSTITUTION.md' ] && [ ! -e '$SNAP/$POISON_REL' ]"
|
||||
|
||||
# Part 2 — permissions (0700 dirs, 0600 files); never world-readable.
|
||||
chk "perms: backup root is 0700" "[ \$(stat -c '%a' '$STATE/mosaic/backups') -eq 700 ]"
|
||||
chk "perms: snapshot dir is 0700" "[ \$(stat -c '%a' '$SNAP') -eq 700 ]"
|
||||
chk "perms: nested dir is 0700" "[ \$(stat -c '%a' '$SNAP/agents') -eq 700 ]"
|
||||
chk "perms: credentials backup is 0600" "[ \$(stat -c '%a' '$SNAP/tools/_lib/credentials.json') -eq 600 ]"
|
||||
chk "perms: SOUL.md backup is 0600" "[ \$(stat -c '%a' '$SNAP/SOUL.md') -eq 600 ]"
|
||||
|
||||
# Part 3 — the secret is backed up but never emitted to stdout/stderr.
|
||||
chk "no-leak: secret IS in the backup file" "grep -q '$SECRET' '$SNAP/tools/_lib/credentials.json'"
|
||||
chk "no-leak: secret NOT on stdout/stderr" "! grep -q '$SECRET' '$OUT'"
|
||||
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||
|
||||
echo "── Part 4: retention prune (MOSAIC_BACKUP_RETENTION) ───────────────────"
|
||||
# Pre-seed four dated snapshots, then take one real snapshot with retention=2:
|
||||
# only the two newest (the fresh real one + the newest pre-seeded) must survive.
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(
|
||||
H=$(mktemp -d); STATE=$(mktemp -d); OUT=$(mktemp)
|
||||
seed_home "$H"
|
||||
mkdir -p "$STATE/mosaic/backups"
|
||||
for ts in 20200101T000000Z 20210101T000000Z 20220101T000000Z 20230101T000000Z; do
|
||||
mkdir -p "$STATE/mosaic/backups/pre-update-$ts"
|
||||
done
|
||||
set +e
|
||||
env PATH="$ORIG_PATH" XDG_STATE_HOME="$STATE" MOSAIC_BACKUP_RETENTION=2 \
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 \
|
||||
bash "$INSTALL" >"$OUT" 2>&1
|
||||
rc=$?
|
||||
set -e 2>/dev/null || true
|
||||
printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$STATE" "$H"
|
||||
)
|
||||
chk "retention: upgrade succeeds" "[ '$rc' -eq 0 ]"
|
||||
chk "retention: pruned to exactly 2 snapshots" "[ \$(find '$STATE/mosaic/backups' -maxdepth 1 -type d -name 'pre-update-*' | wc -l) -eq 2 ]"
|
||||
chk "retention: newest pre-seeded survives" "[ -d '$STATE/mosaic/backups/pre-update-20230101T000000Z' ]"
|
||||
chk "retention: oldest pre-seeded pruned" "[ ! -d '$STATE/mosaic/backups/pre-update-20200101T000000Z' ]"
|
||||
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||
|
||||
echo "── Part 5: post-sync verify restores an operator file (+ control) ──────"
|
||||
# Shipped installer: the cp shim corrupts SOUL.md mid-sync; verify must restore it.
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" make_corrupt_shim)
|
||||
chk "verify: upgrade still succeeds" "[ '$rc' -eq 0 ]"
|
||||
chk "verify: SOUL.md restored to original" "[ \"\$(cat '$H/SOUL.md')\" = '$SOUL_ORIG' ]"
|
||||
chk "verify: no corruption remains in SOUL.md" "! grep -q 'CORRUPTION-mid-sync' '$H/SOUL.md'"
|
||||
chk "verify: loud restore warning emitted" "grep -qi 'restored from the pre-update snapshot' '$OUT'"
|
||||
chk "verify: secret still not leaked" "! grep -q '$SECRET' '$OUT'"
|
||||
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||
|
||||
# Control: strip the verify call → the corruption must SURVIVE (net is load-bearing).
|
||||
sed '/# VERIFY-NET/d' "$INSTALL" > "$VERIFYCTRL"
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$VERIFYCTRL" make_corrupt_shim)
|
||||
chk "control: SOUL.md corruption survives" "grep -q 'CORRUPTION-mid-sync' '$H/SOUL.md'"
|
||||
chk "control: no restore warning emitted" "! grep -qi 'restored from the pre-update snapshot' '$OUT'"
|
||||
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||
|
||||
echo "── Part 6: verify net honors an intentional migration removal (+ control) ─"
|
||||
# BLOCKER regression: on a pre-v2 install, bin/ is operator-classified so the durable
|
||||
# snapshot captures it — but the v1→v2 migration deletes bin/ ON PURPOSE. The verify
|
||||
# net must SKIP that removal (is_migration_removed), or it heals bin/ back and the
|
||||
# migration is silently undone forever once the version is stamped.
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" "" "" seed_home_v1)
|
||||
chk "migration: upgrade succeeds" "[ '$rc' -eq 0 ]"
|
||||
chk "migration: legacy bin/ stays removed" "[ ! -e '$H/bin' ]"
|
||||
chk "migration: operator SOUL.md untouched" "[ \"\$(cat '$H/SOUL.md')\" = '$SOUL_ORIG' ]"
|
||||
chk "migration: version stamped to $FRAMEWORK_VERSION" "[ \"\$(cat '$H/.framework-version')\" = '$FRAMEWORK_VERSION' ]"
|
||||
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||
|
||||
# Control: strip the MIGRATION-SKIP-GUARD → the verify net restores bin/ from the
|
||||
# snapshot, silently undoing the migration (proves the guard is load-bearing).
|
||||
MIGCTRL="$(mk_control 'MIGRATION-SKIP-GUARD' migration-control)"
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$MIGCTRL" "" "" seed_home_v1)
|
||||
chk "control: bin/ wrongly restored by verify" "[ -e '$H/bin/tool.sh' ]"
|
||||
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||
|
||||
echo "── Part 7: verify net never restores a secret through a symlink (+ control) ─"
|
||||
# HIGH (CWE-59) regression: an attacker who swaps an operator file for a symlink
|
||||
# AFTER the durable snapshot must not cause the verify net's restore to write the
|
||||
# snapshot's secret out THROUGH that link. The shipped net drops a symlinked leaf and
|
||||
# writes a real file in its place, leaving the external target untouched.
|
||||
EXFIL_DIR=$(mktemp -d); EXFIL_TARGET="$EXFIL_DIR/stolen"
|
||||
printf 'ATTACKER-PLACEHOLDER\n' > "$EXFIL_TARGET"
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$INSTALL" make_symlink_leaf_shim)
|
||||
chk "symlink-leaf: upgrade succeeds" "[ '$rc' -eq 0 ]"
|
||||
chk "symlink-leaf: secret NOT written through link" "! grep -q '$SECRET' '$EXFIL_TARGET'"
|
||||
chk "symlink-leaf: credentials.json is a real file" "[ -f '$H/tools/_lib/credentials.json' ] && [ ! -L '$H/tools/_lib/credentials.json' ]"
|
||||
chk "symlink-leaf: credentials.json restored intact" "grep -q '$SECRET' '$H/tools/_lib/credentials.json'"
|
||||
chk "symlink-leaf: secret not leaked to stdout/stderr" "! grep -q '$SECRET' '$OUT'"
|
||||
rm -rf "$STATE" "$H" "$EXFIL_DIR"; rm -f "$OUT"
|
||||
|
||||
# Control: strip the SYMLINK-LEAF-GUARD → cp follows the swapped-in link and writes
|
||||
# the snapshot secret out through it (proves the guard is load-bearing).
|
||||
EXFIL_DIR=$(mktemp -d); EXFIL_TARGET="$EXFIL_DIR/stolen"
|
||||
printf 'ATTACKER-PLACEHOLDER\n' > "$EXFIL_TARGET"
|
||||
LEAFCTRL="$(mk_control 'SYMLINK-LEAF-GUARD' symlinkleaf-control)"
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(run_snap "$LEAFCTRL" make_symlink_leaf_shim)
|
||||
chk "control: secret leaked through the symlink" "grep -q '$SECRET' '$EXFIL_TARGET'"
|
||||
rm -rf "$STATE" "$H" "$EXFIL_DIR"; rm -f "$OUT"
|
||||
|
||||
echo "── Part 8: snapshot umask 077 does not leak into synced files (+ control) ──"
|
||||
# SHOULD-FIX regression: umask 077 is process-global. Scoped to the snapshot it keeps
|
||||
# backups 0600; leaked past it, every later cp/mkdir inherits 0600/0700. A freshly-
|
||||
# synced framework file must be 0644 (per the ambient 022 umask) while the backup of
|
||||
# a secret stays 0600.
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(umask 022; run_snap "$INSTALL")
|
||||
SNAP="$(snap_dir "$STATE")"
|
||||
chk "umask: upgrade succeeds" "[ '$rc' -eq 0 ]"
|
||||
chk "umask: synced framework file is 0644" "[ \$(stat -c '%a' '$H/$POISON_REL') -eq 644 ]"
|
||||
chk "umask: backup of a secret stays 0600" "[ \$(stat -c '%a' '$SNAP/tools/_lib/credentials.json') -eq 600 ]"
|
||||
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||
|
||||
# Control: strip the UMASK-RESTORE-NORMAL line → umask 077 leaks past the snapshot,
|
||||
# so the newly-synced framework file is created 0600 (proves the restore matters).
|
||||
UMASKCTRL="$(mk_control 'UMASK-RESTORE-NORMAL' umask-control)"
|
||||
IFS=$'\t' read -r rc OUT STATE H < <(umask 022; run_snap "$UMASKCTRL")
|
||||
chk "control: leaked umask makes synced file 0600" "[ \$(stat -c '%a' '$H/$POISON_REL') -eq 600 ]"
|
||||
rm -rf "$STATE" "$H"; rm -f "$OUT"
|
||||
|
||||
echo ""
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
[ "$fail" -eq 0 ]
|
||||
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-upgrade-manifest-guard.sh — the #791 HARD GATE.
|
||||
#
|
||||
# Proves that a keep-mode framework upgrade (the `mosaic update` path:
|
||||
# install.sh with MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1) touches NO path
|
||||
# outside the framework-owned manifest. Every operator-owned sentinel — including
|
||||
# a deliberately UNANTICIPATED one the manifest never names — must survive
|
||||
# byte-identical with an unchanged mtime (not even rewritten). Framework files
|
||||
# must still update, and a retired framework file inside a shipped subtree must
|
||||
# still be pruned. No operator secret value may appear in installer output.
|
||||
#
|
||||
# Keep mode is a SINGLE code path (sync_framework_keep, a manifest-driven cp
|
||||
# overlay + scoped prune — no rsync). The matrix still runs twice, once with
|
||||
# rsync on PATH and once with it hidden, to prove the keep path is genuinely
|
||||
# rsync-independent: it must obey the manifest identically whether or not rsync
|
||||
# happens to be installed (rsync --delete is only ever used by overwrite mode,
|
||||
# which has no operator state to protect).
|
||||
#
|
||||
# It also runs a fail-closed matrix (#791 B2/B3): an empty, operator-only,
|
||||
# malformed, or missing manifest must ABORT the upgrade loudly and leave every
|
||||
# operator path untouched — never silently no-op to "complete".
|
||||
#
|
||||
# Usage: bash test-upgrade-manifest-guard.sh
|
||||
set -uo pipefail
|
||||
|
||||
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
|
||||
INSTALL="$FW/install.sh"
|
||||
|
||||
pass=0; fail=0
|
||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||
|
||||
# Redirect the #791 PR2 durable pre-update snapshot ($XDG_STATE_HOME/mosaic/backups)
|
||||
# into a throwaway so a keep-mode upgrade under test never writes into the real
|
||||
# ~/.local/state. This test asserts operator-surface fidelity, not backup content.
|
||||
export XDG_STATE_HOME
|
||||
XDG_STATE_HOME="$(mktemp -d)"
|
||||
trap 'rm -rf "$XDG_STATE_HOME"' EXIT
|
||||
|
||||
SECRET='SUPER-SECRET-TOKEN-do-not-log-3f9a'
|
||||
|
||||
# Seed a throwaway MOSAIC_HOME with an operator sentinel per ownership class.
|
||||
seed_home() {
|
||||
local H="$1"
|
||||
mkdir -p "$H/agents" "$H/policy" "$H/memory" "$H/tools/_lib" \
|
||||
"$H/fleet/agents" "$H/fleet/run/sessions" "$H/harvester" \
|
||||
"$H/unknown-operator-dir" "$H/guides"
|
||||
printf '# persona\n' > "$H/SOUL.md" # marks a recognized existing install → keep mode
|
||||
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
|
||||
printf '# operator policy\n' > "$H/policy/custom.md"
|
||||
printf '# soul overlay\n' > "$H/SOUL.local.md"
|
||||
printf '# operator memory\n' > "$H/memory/note.md"
|
||||
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
|
||||
printf 'MOSAIC_AGENT_NAME=coder0\n' > "$H/fleet/agents/coder0.env"
|
||||
printf 'version: 2\nagents:\n - name: coder0\n' > "$H/fleet/roster.yaml"
|
||||
printf '# harvester SOP\n' > "$H/harvester/sop.md"
|
||||
printf 'operator data the manifest never anticipated\n' > "$H/unknown-operator-dir/x"
|
||||
printf 'version: 1\nagents:\n - name: mine\n' > "$H/fleet/my-fleet.yaml"
|
||||
|
||||
# #797 Runtime Session Ledger (Mos-elevated to a #791 PR1 merge-blocker): a
|
||||
# populated ledger under fleet/run/sessions/ must survive the upgrade — a
|
||||
# runtime ledger an upgrade rsync can wipe is worthless. Seed it exactly as
|
||||
# #797 writes it: a non-empty append journal + a non-empty compacted
|
||||
# projection, files 0600 under a 0700 dir.
|
||||
printf '%s\n%s\n%s\n' \
|
||||
'{"seq":1,"kind":"session.spawn","node":"sess-42","generation":7}' \
|
||||
'{"seq":2,"kind":"lease.grant","node":"sess-42","lease":"web1"}' \
|
||||
'{"seq":3,"kind":"dispatch.create","from":"sess-42","to":"disp-9"}' \
|
||||
> "$H/fleet/run/sessions/events.ndjson"
|
||||
printf '%s\n' \
|
||||
'{"generation":7,"nodes":[{"id":"sess-42","kind":"session"}],"edges":[{"from":"sess-42","to":"disp-9","kind":"dispatch"}]}' \
|
||||
> "$H/fleet/run/sessions/ledger.json"
|
||||
chmod 0700 "$H/fleet/run" "$H/fleet/run/sessions"
|
||||
chmod 0600 "$H/fleet/run/sessions/events.ndjson" "$H/fleet/run/sessions/ledger.json"
|
||||
|
||||
# A retired framework file inside a shipped subtree (absent from source) — must be pruned.
|
||||
printf '# retired guide\n' > "$H/guides/RETIRED-OLD-GUIDE.md"
|
||||
echo 3 > "$H/.framework-version"
|
||||
}
|
||||
|
||||
OPERATOR_SENTINELS=(
|
||||
"agents/coder0.conf"
|
||||
"policy/custom.md"
|
||||
"SOUL.local.md"
|
||||
"memory/note.md"
|
||||
"tools/_lib/credentials.json"
|
||||
"fleet/agents/coder0.env"
|
||||
"fleet/roster.yaml"
|
||||
"harvester/sop.md"
|
||||
"unknown-operator-dir/x"
|
||||
"fleet/my-fleet.yaml"
|
||||
# #797 Runtime Session Ledger — populated journal + projection must survive.
|
||||
"fleet/run/sessions/events.ndjson"
|
||||
"fleet/run/sessions/ledger.json"
|
||||
)
|
||||
|
||||
run_matrix() {
|
||||
local label="$1"; shift # extra env / PATH override applied to the run
|
||||
local H E OUT rel before_hash after_hash before_mt after_mt
|
||||
H=$(mktemp -d); E=$(mktemp -d); OUT=$(mktemp)
|
||||
seed_home "$H"
|
||||
|
||||
# Snapshot hash + mtime of every operator sentinel before the upgrade.
|
||||
for rel in "${OPERATOR_SENTINELS[@]}"; do
|
||||
sha256sum "$H/$rel" | awk '{print $1}' > "$E/$(echo "$rel" | tr / _).hash"
|
||||
stat -c %Y "$H/$rel" > "$E/$(echo "$rel" | tr / _).mt"
|
||||
done
|
||||
|
||||
# Snapshot the ledger directory permission bits (#797 assert: perms unchanged).
|
||||
local before_dirperm after_dirperm
|
||||
before_dirperm=$(stat -c %a "$H/fleet/run/sessions")
|
||||
|
||||
# The upgrade under test (keep + sync-only = the `mosaic update` reseed path).
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 "$@" bash "$INSTALL" >"$OUT" 2>&1
|
||||
|
||||
# HARD GATE: every operator sentinel survives byte-identical AND mtime-unchanged.
|
||||
for rel in "${OPERATOR_SENTINELS[@]}"; do
|
||||
before_hash=$(cat "$E/$(echo "$rel" | tr / _).hash")
|
||||
before_mt=$(cat "$E/$(echo "$rel" | tr / _).mt")
|
||||
after_hash=$(sha256sum "$H/$rel" 2>/dev/null | awk '{print $1}')
|
||||
after_mt=$(stat -c %Y "$H/$rel" 2>/dev/null || echo MISSING)
|
||||
chk "[$label] operator sentinel survives byte-identical: $rel" \
|
||||
"[ -n '$after_hash' ] && [ '$before_hash' = '$after_hash' ]"
|
||||
chk "[$label] operator sentinel not rewritten (mtime unchanged): $rel" \
|
||||
"[ '$before_mt' = '$after_mt' ]"
|
||||
done
|
||||
|
||||
# #797 assert 7: the ledger directory's permission bits are unchanged.
|
||||
after_dirperm=$(stat -c %a "$H/fleet/run/sessions" 2>/dev/null || echo MISSING)
|
||||
chk "[$label] ledger dir perms unchanged (#797): $before_dirperm" \
|
||||
"[ '$before_dirperm' = '$after_dirperm' ]"
|
||||
|
||||
# Positive controls / negative controls — prove the test discriminates: the
|
||||
# upgrade DOES write and prune framework-owned paths, so the operator sentinels
|
||||
# (incl. the #797 ledger) survive because of the manifest, not because the
|
||||
# upgrade is a no-op.
|
||||
chk "[$label] positive control: framework file present after upgrade (guides synced)" \
|
||||
"[ -f '$H/guides/E2E-DELIVERY.md' ]"
|
||||
chk "[$label] negative control: retired framework file inside a subtree IS pruned" \
|
||||
"[ ! -f '$H/guides/RETIRED-OLD-GUIDE.md' ]"
|
||||
chk "[$label] manifest itself is installed" "[ -f '$H/framework-manifest.txt' ]"
|
||||
|
||||
# Secret-safety: the operator secret value never appears in installer output.
|
||||
chk "[$label] operator secret value absent from installer stdout/stderr" \
|
||||
"! grep -q '$SECRET' '$OUT'"
|
||||
|
||||
rm -rf "$H" "$E" "$OUT"
|
||||
}
|
||||
|
||||
# Fail-closed matrix (#791 B2/B3 + blocker-1): run install.sh from a COPY of the
|
||||
# framework so the shipped manifest can be corrupted. Every corruption must abort
|
||||
# the upgrade non-zero with a manifest error, leaving all operator sentinels
|
||||
# byte-identical AND on the SAME inode. The inode check is the load-bearing part:
|
||||
# manifest validation is hoisted BEFORE make_snapshot/the restore trap, so a bad
|
||||
# manifest must abort without ever snapshotting, deleting, and restoring the
|
||||
# target. Were validation still armed under the ERR trap, restore_snapshot would
|
||||
# rm -rf + rebuild the target — same bytes but a NEW inode (broken hard links,
|
||||
# changed ctime), which a content-only hash would miss (#791 blocker-1).
|
||||
run_failclosed() {
|
||||
local label="$1" mutate="$2"
|
||||
local SRC H E OUT rc rel before_hash after_hash before_ino after_ino key
|
||||
SRC=$(mktemp -d); H=$(mktemp -d); E=$(mktemp -d); OUT=$(mktemp)
|
||||
cp -a "$FW/." "$SRC/"
|
||||
case "$mutate" in
|
||||
empty) : > "$SRC/framework-manifest.txt" ;;
|
||||
operator-only) printf '[operator]\nSOUL.md\n*.local.md\n' > "$SRC/framework-manifest.txt" ;;
|
||||
malformed) printf 'stray.md\n[framework]\nguides/**\n' > "$SRC/framework-manifest.txt" ;;
|
||||
degenerate) printf '[framework]\n/\n./\n[operator]\nSOUL.md\n' > "$SRC/framework-manifest.txt" ;;
|
||||
missing) rm -f "$SRC/framework-manifest.txt" ;;
|
||||
esac
|
||||
|
||||
seed_home "$H"
|
||||
for rel in "${OPERATOR_SENTINELS[@]}"; do
|
||||
key=$(echo "$rel" | tr / _)
|
||||
sha256sum "$H/$rel" | awk '{print $1}' > "$E/$key.hash"
|
||||
stat -c '%i' "$H/$rel" > "$E/$key.ino"
|
||||
done
|
||||
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$SRC/install.sh" >"$OUT" 2>&1
|
||||
rc=$?
|
||||
|
||||
chk "[fail-closed:$label] upgrade aborts non-zero" "[ '$rc' -ne 0 ]"
|
||||
chk "[fail-closed:$label] refuses loudly with a manifest error" \
|
||||
"grep -qi 'manifest' '$OUT'"
|
||||
for rel in "${OPERATOR_SENTINELS[@]}"; do
|
||||
key=$(echo "$rel" | tr / _)
|
||||
before_hash=$(cat "$E/$key.hash")
|
||||
after_hash=$(sha256sum "$H/$rel" 2>/dev/null | awk '{print $1}')
|
||||
chk "[fail-closed:$label] operator sentinel untouched: $rel" \
|
||||
"[ -n '$after_hash' ] && [ '$before_hash' = '$after_hash' ]"
|
||||
before_ino=$(cat "$E/$key.ino")
|
||||
after_ino=$(stat -c '%i' "$H/$rel" 2>/dev/null)
|
||||
chk "[fail-closed:$label] operator sentinel not deleted/recreated (inode stable): $rel" \
|
||||
"[ -n '$after_ino' ] && [ '$before_ino' = '$after_ino' ]"
|
||||
done
|
||||
chk "[fail-closed:$label] operator secret value absent from output" \
|
||||
"! grep -q '$SECRET' '$OUT'"
|
||||
|
||||
chmod -R u+w "$SRC" "$H" 2>/dev/null || true
|
||||
rm -rf "$SRC" "$H" "$E" "$OUT"
|
||||
}
|
||||
|
||||
echo "#791 upgrade manifest guard (HARD GATE):"
|
||||
|
||||
# 1) rsync path (if available on this host).
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
run_matrix "rsync"
|
||||
else
|
||||
echo " · rsync not installed — skipping rsync-path matrix"
|
||||
fi
|
||||
|
||||
# 2) rsync-absent path — hide rsync behind a scratch PATH. Keep mode never calls
|
||||
# rsync, so this must resolve identically to run (1); it proves the keep path
|
||||
# does not silently depend on rsync being installed. (Provide the coreutils the
|
||||
# installer needs on the stripped PATH.)
|
||||
FBIN=$(mktemp -d)
|
||||
for t in bash cp find mktemp rm mkdir chmod cmp sed grep cat dirname basename stat sha256sum awk tr date sort; do
|
||||
p=$(command -v "$t" 2>/dev/null) && ln -s "$p" "$FBIN/$t"
|
||||
done
|
||||
run_matrix "rsync-absent" env "PATH=$FBIN"
|
||||
rm -rf "$FBIN"
|
||||
|
||||
# 3) fail-closed matrix (#791 B2/B3) — corrupt the shipped manifest four ways.
|
||||
run_failclosed "empty-manifest" empty
|
||||
run_failclosed "operator-only" operator-only
|
||||
run_failclosed "malformed-manifest" malformed
|
||||
run_failclosed "degenerate-framework" degenerate
|
||||
run_failclosed "missing-manifest" missing
|
||||
|
||||
echo
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
[ "$fail" -eq 0 ]
|
||||
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-upgrade-rollback.sh — the #791 B1 regression gate.
|
||||
#
|
||||
# A keep-mode upgrade takes a pre-update snapshot and installs an ERR/INT/TERM
|
||||
# trap that restores it if the sync aborts midway (install.sh: make_snapshot +
|
||||
# `trap restore_snapshot`). That trap is only reached if `set -E` (errtrace) is
|
||||
# active — otherwise a failure INSIDE sync_framework_keep() (which runs entirely
|
||||
# in a function) never fires the trap, and the upgrade aborts leaving a
|
||||
# half-written target with NO rollback. This test proves:
|
||||
#
|
||||
# Part A (the gate): the shipped installer rolls back a mid-sync failure —
|
||||
# the restore message fires, the corrupted file is put
|
||||
# back, AND the whole target is byte-identical to its
|
||||
# pre-upgrade state.
|
||||
# Part B (the control): the SAME installer with `-E` stripped does NOT roll back
|
||||
# (dead trap) — the mid-sync corruption survives, proving
|
||||
# errtrace is load-bearing. If anyone removes `set -E`,
|
||||
# Part A goes red.
|
||||
#
|
||||
# The mid-sync failure is injected with a PATH-shadowing `cp` shim rather than
|
||||
# file permissions. The earlier 0400/EACCES approach was NOT portable: Woodpecker
|
||||
# runs steps as root (node:24-alpine has no USER directive), and root overwrites a
|
||||
# 0400 file, so the failure never fired and this gate silently passed (#791
|
||||
# blocker-3). The shim fails deterministically for one framework-owned
|
||||
# destination regardless of uid, and — like a real interrupted cp (disk-full
|
||||
# mid-write) — leaves a partially-written target behind, so rollback has real
|
||||
# damage to undo and the control has real damage to expose.
|
||||
#
|
||||
# Usage: bash test-upgrade-rollback.sh
|
||||
set -uo pipefail
|
||||
|
||||
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
|
||||
INSTALL="$FW/install.sh"
|
||||
ORIG_PATH="$PATH"
|
||||
|
||||
# The `-E`-stripped control installer must live INSIDE $FW: install.sh derives
|
||||
# SOURCE_DIR from its own path and `source`s $SOURCE_DIR/tools/_lib/manifest.sh,
|
||||
# so a copy anywhere else aborts at the source line before ever reaching the sync
|
||||
# loop — which would make the control a false negative. A root dotfile is
|
||||
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
|
||||
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
||||
SIGNALED="$FW/.install-signal-control.tmp.sh"
|
||||
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
||||
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
||||
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
||||
rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||
|
||||
pass=0; fail=0
|
||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||
|
||||
SECRET='SUPER-SECRET-TOKEN-do-not-log-b1'
|
||||
# A framework-owned file the shim fails the copy of. The seeded target holds GOOD
|
||||
# bytes; source ships different bytes, so sync_framework_keep() attempts the copy
|
||||
# and the shim intercepts it. Root-level framework files sort before guides/, so
|
||||
# several framework files are already refreshed when the copy reaches this one.
|
||||
POISON_REL='guides/E2E-DELIVERY.md'
|
||||
GOOD='GOOD-REFERENCE-CONTENT-pre-upgrade-b1'
|
||||
GARBAGE='PARTIAL-WRITE-GARBAGE-mid-sync-b1'
|
||||
|
||||
# A `cp` shim: for the poisoned destination, simulate an interrupted copy — write
|
||||
# partial garbage to the target, then fail — otherwise delegate to the real cp
|
||||
# (resolved via the ORIGINAL PATH so make_snapshot/restore still work).
|
||||
make_cp_shim() {
|
||||
local dir="$1"
|
||||
cat > "$dir/cp" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
dest="\${@: -1}"
|
||||
case "\$dest" in
|
||||
*/$POISON_REL)
|
||||
printf '%s' '$GARBAGE' > "\$dest" 2>/dev/null || true
|
||||
exit 1 ;;
|
||||
esac
|
||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
||||
SHIM
|
||||
chmod +x "$dir/cp"
|
||||
}
|
||||
|
||||
# A `find` shim that fails every enumeration scan (`-print0`) as if it hit an
|
||||
# EACCES/I/O error partway — it emits the real (here: complete) list first, then
|
||||
# exits non-zero, exactly the class of failure a `< <(find …)` process
|
||||
# substitution silently swallows. All non-`-print0` finds (e.g. the -delete
|
||||
# sweep) delegate to the real find on the original PATH. Used to prove #791
|
||||
# blocker-D1: the shipped installer must honor find's exit status and roll back.
|
||||
make_find_fail_shim() {
|
||||
local dir="$1"
|
||||
cat > "$dir/find" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
for a in "\$@"; do
|
||||
if [ "\$a" = "-print0" ]; then
|
||||
env PATH="$ORIG_PATH" find "\$@" # emit the real list…
|
||||
exit 1 # …then fail as if the scan hit EACCES
|
||||
fi
|
||||
done
|
||||
exec env PATH="$ORIG_PATH" find "\$@"
|
||||
SHIM
|
||||
chmod +x "$dir/find"
|
||||
}
|
||||
|
||||
# An `rm` shim that fails ONLY `rm -rf <FAIL_RM_TARGET>` (the restore's target
|
||||
# reset) and delegates every other rm to the real one. Used to prove #791
|
||||
# blocker-D2: when the target reset inside restore_snapshot fails, the installer
|
||||
# must emit the manual-recovery pointer (snapshot path) instead of exiting
|
||||
# silently under `set -e`. FAIL_RM_TARGET is exported into the installer env.
|
||||
make_rm_fail_shim() {
|
||||
local dir="$1"
|
||||
cat > "$dir/rm" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
last="${@: -1}"
|
||||
if [ -n "${FAIL_RM_TARGET:-}" ] && [ "$last" = "$FAIL_RM_TARGET" ]; then
|
||||
exit 1
|
||||
fi
|
||||
exec env PATH="$ORIG_PATH_FOR_RM" rm "$@"
|
||||
SHIM
|
||||
chmod +x "$dir/rm"
|
||||
}
|
||||
|
||||
seed_home() {
|
||||
local H="$1"
|
||||
mkdir -p "$H/agents" "$H/tools/_lib" "$H/memory" "$H/guides"
|
||||
printf '# persona\n' > "$H/SOUL.md" # recognized install → keep mode + snapshot
|
||||
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
|
||||
printf '# operator memory\n' > "$H/memory/note.md"
|
||||
printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json"
|
||||
echo 3 > "$H/.framework-version"
|
||||
# Good pre-upgrade bytes; source ships different bytes, so cp is attempted.
|
||||
printf '%s' "$GOOD" > "$H/$POISON_REL"
|
||||
}
|
||||
|
||||
# Run one keep-mode upgrade against $1=installer, seeding a fresh home and a
|
||||
# byte-for-byte reference of the pre-upgrade state, with the cp shim first on
|
||||
# PATH. Echoes: "<exit>\t<out>\t<ref>\t<home>".
|
||||
run_upgrade() {
|
||||
local installer="$1" shim_maker="${2:-make_cp_shim}" H REF OUT SHIM rc
|
||||
H=$(mktemp -d); REF=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
||||
seed_home "$H"
|
||||
env PATH="$ORIG_PATH" cp -a "$H/." "$REF/" # pre-upgrade reference (real cp)
|
||||
"$shim_maker" "$SHIM"
|
||||
set +e
|
||||
PATH="$SHIM:$ORIG_PATH" \
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||
rc=$?
|
||||
set -e 2>/dev/null || true
|
||||
rm -rf "$SHIM"
|
||||
printf '%s\t%s\t%s\t%s\n' "$rc" "$OUT" "$REF" "$H"
|
||||
}
|
||||
|
||||
echo "#791 upgrade rollback (B1 regression gate):"
|
||||
|
||||
# ── Part A: the shipped installer must roll back a mid-sync failure ───────────
|
||||
IFS=$'\t' read -r rcA OUTA REFA HA < <(run_upgrade "$INSTALL")
|
||||
|
||||
chk "[shipped] upgrade aborts non-zero on the injected mid-sync failure" \
|
||||
"[ '$rcA' -ne 0 ]"
|
||||
chk "[shipped] restore_snapshot fires (rollback message present)" \
|
||||
"grep -q 'restoring previous state from snapshot' '$OUTA'"
|
||||
chk "[shipped] the corrupted file is restored to its pre-upgrade bytes" \
|
||||
"[ \"\$(cat '$HA/$POISON_REL')\" = '$GOOD' ]"
|
||||
chk "[shipped] target rolled back byte-identical to pre-upgrade state" \
|
||||
"diff -r '$REFA' '$HA' >/dev/null 2>&1"
|
||||
chk "[shipped] operator secret value absent from installer output" \
|
||||
"! grep -q '$SECRET' '$OUTA'"
|
||||
|
||||
# ── Part B: control — strip `-E`, the trap is dead, no rollback happens ───────
|
||||
# Proves errtrace is what makes the trap reachable. If `set -E` is ever removed
|
||||
# from install.sh, Part A's rollback assertions fail exactly like this control.
|
||||
sed 's/^set -Eeuo pipefail/set -euo pipefail/' "$INSTALL" > "$STRIPPED"
|
||||
chk "[control] the -E strip actually changed the installer" \
|
||||
"! cmp -s '$INSTALL' '$STRIPPED'"
|
||||
|
||||
IFS=$'\t' read -r rcB OUTB REFB HB < <(run_upgrade "$STRIPPED")
|
||||
chk "[control] without -E the upgrade still aborts non-zero" \
|
||||
"[ '$rcB' -ne 0 ]"
|
||||
# The load-bearing, deterministic proof of B1: without errtrace the ERR trap
|
||||
# never fires for a failure inside sync_framework_keep(), so no rollback runs.
|
||||
chk "[control] without -E the rollback message does NOT fire (dead trap)" \
|
||||
"! grep -q 'restoring previous state from snapshot' '$OUTB'"
|
||||
chk "[control] without -E the mid-sync corruption survives (no rollback)" \
|
||||
"[ \"\$(cat '$HB/$POISON_REL')\" = '$GARBAGE' ]"
|
||||
|
||||
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
||||
# A bash signal trap that merely returns lets the script continue past the
|
||||
# interrupt — restoring the snapshot, then resuming the sync and reporting
|
||||
# success. The earlier test used a child cp shim to signal its parent, making
|
||||
# child completion race Bash's interrupted wait. Concurrency is not part of the
|
||||
# guarded property: sync_framework_keep() runs in the installer's own Bash
|
||||
# process, and `kill` is a builtin. Generate two installer fixtures that signal
|
||||
# themselves at the same known mid-sync point. Their TERM handlers emit the same
|
||||
# observable before diverging, so missing signal delivery fails BOTH arms rather
|
||||
# than manufacturing a pass. The only semantic difference between fixtures is
|
||||
# the explicit `exit 1` whose load-bearing behavior this control proves.
|
||||
TERM_MARKER='[test-control] TERM handler entered'
|
||||
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
|
||||
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
|
||||
|
||||
make_signal_installer() {
|
||||
local output="$1" handler="$2"
|
||||
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
|
||||
local target_cp=' cp "$abs" "$dst/$rel"'
|
||||
local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then"
|
||||
local inject_kill=' kill -TERM "$$" # TEST-TERM-INJECTION'
|
||||
local inject_close=' fi'
|
||||
|
||||
if ! awk \
|
||||
-v target_trap="$target_trap" -v target_cp="$target_cp" \
|
||||
-v handler="$handler" -v inject_open="$inject_open" \
|
||||
-v inject_kill="$inject_kill" -v inject_close="$inject_close" '
|
||||
$0 == target_cp {
|
||||
print inject_open
|
||||
print inject_kill
|
||||
print inject_close
|
||||
injection_sites++
|
||||
}
|
||||
{ print }
|
||||
$0 == target_trap {
|
||||
print handler
|
||||
handler_sites++
|
||||
}
|
||||
END {
|
||||
if (handler_sites != 1 || injection_sites != 1) exit 42
|
||||
}
|
||||
' "$INSTALL" > "$output"; then
|
||||
rm -f "$output"
|
||||
fail "Could not construct the self-TERM control installer at the exact trap/copy sites"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$output"
|
||||
}
|
||||
|
||||
make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
|
||||
make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT"
|
||||
signal_fixture_ready() {
|
||||
local fixture="$1" expected_handler="$2"
|
||||
[[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \
|
||||
&& [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \
|
||||
&& grep -Fqx "$expected_handler" "$fixture"
|
||||
}
|
||||
signaled_fixture_ready() { signal_fixture_ready "$SIGNALED" "$HANDLER_WITH_EXIT"; }
|
||||
noexit_fixture_ready() { signal_fixture_ready "$NOEXIT" "$HANDLER_WITHOUT_EXIT"; }
|
||||
chk "[signal] shipped fixture has exactly one self-TERM injection and marked handler" \
|
||||
"signaled_fixture_ready"
|
||||
chk "[control] no-exit fixture has exactly one self-TERM injection and marked handler" \
|
||||
"noexit_fixture_ready"
|
||||
chk "[control] removing the explicit TERM exit changes the fixture" \
|
||||
"! cmp -s '$SIGNALED' '$NOEXIT'"
|
||||
|
||||
# Run one keep-mode upgrade whose own shell delivers SIGTERM synchronously at
|
||||
# the selected copy. Echoes "<exit>\t<out>\t<home>".
|
||||
run_signal_upgrade() {
|
||||
local installer="$1" H OUT rc
|
||||
H=$(mktemp -d); OUT=$(mktemp)
|
||||
seed_home "$H"
|
||||
set +e
|
||||
PATH="$ORIG_PATH" \
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||
rc=$?
|
||||
set -e 2>/dev/null || true
|
||||
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
||||
}
|
||||
|
||||
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED")
|
||||
chk "[signal] TERM handler observable fires exactly once" \
|
||||
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]"
|
||||
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
|
||||
"[ '$rcC' -ne 0 ]"
|
||||
chk "[signal] restore_snapshot fires on the interrupt" \
|
||||
"grep -q 'restoring previous state from snapshot' '$OUTC'"
|
||||
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
||||
"! grep -q 'file phase complete' '$OUTC'"
|
||||
|
||||
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||
chk "[control] TERM handler observable fires exactly once" \
|
||||
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
|
||||
chk "[control] without 'exit 1' the handler restores before returning" \
|
||||
"grep -q 'restoring previous state from snapshot' '$OUTD'"
|
||||
chk "[control] without 'exit 1' the installer exits zero after resuming" \
|
||||
"[ '$rcD' -eq 0 ]"
|
||||
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
||||
"grep -q 'file phase complete' '$OUTD'"
|
||||
|
||||
# ── Part D: a failed source/prune `find` scan must abort + roll back (D1) ─────
|
||||
# A `< <(find …)` process substitution discards find's exit status, so an
|
||||
# EACCES/I/O failure mid-scan would truncate the file list yet leave the reading
|
||||
# loop exiting 0 — a partial upgrade committed and reported as success, with the
|
||||
# ERR/restore trap never firing. The shipped installer captures the scan into a
|
||||
# checked temp file (_scan_or_die) and aborts on failure. We inject a `find` that
|
||||
# fails every `-print0` scan and assert the shipped installer rolls back.
|
||||
IFS=$'\t' read -r rcE OUTE REFE HE < <(run_upgrade "$INSTALL" make_find_fail_shim)
|
||||
chk "[find-fail] a failing framework scan aborts the upgrade non-zero" \
|
||||
"[ '$rcE' -ne 0 ]"
|
||||
chk "[find-fail] restore_snapshot fires on the aborted scan" \
|
||||
"grep -q 'restoring previous state from snapshot' '$OUTE'"
|
||||
chk "[find-fail] the abort is a fail-closed enumeration error (not a silent truncation)" \
|
||||
"grep -q 'Could not enumerate framework files' '$OUTE'"
|
||||
chk "[find-fail] target rolled back byte-identical to pre-upgrade state" \
|
||||
"diff -r '$REFE' '$HE' >/dev/null 2>&1"
|
||||
|
||||
# Control: neuter the D1 guard (turn its `return 1` into a no-op) so a find
|
||||
# failure is swallowed exactly as `< <(find …)` would — the scan appears to
|
||||
# succeed and the upgrade reports completion with NO rollback.
|
||||
sed 's/return 1 # D1-GUARD/: # D1-GUARD-DISABLED/' "$INSTALL" > "$D1CTRL"
|
||||
chk "[control] the D1-guard strip actually changed the installer" \
|
||||
"! cmp -s '$INSTALL' '$D1CTRL'"
|
||||
IFS=$'\t' read -r _rcF OUTF REFF HF < <(run_upgrade "$D1CTRL" make_find_fail_shim)
|
||||
chk "[control] with the D1 guard disabled the find failure is swallowed (no rollback)" \
|
||||
"! grep -q 'restoring previous state from snapshot' '$OUTF'"
|
||||
chk "[control] with the D1 guard disabled the upgrade wrongly reports success" \
|
||||
"grep -q 'file phase complete' '$OUTF'"
|
||||
|
||||
# ── Part E: a failed target reset inside restore must not exit silently (D2) ──
|
||||
# restore_snapshot resets the target (`rm -rf; mkdir -p`) before rebuilding from
|
||||
# the snapshot. Under `set -e` (trap disarmed) a bare reset that fails would exit
|
||||
# the whole script immediately — after `rm` may have deleted part of the target —
|
||||
# WITHOUT printing where the snapshot lives. We trigger a rollback (cp poison) AND
|
||||
# fail the target reset (rm shim), then assert the shipped installer emits the
|
||||
# manual-recovery pointer and preserves the snapshot.
|
||||
run_rmfail_upgrade() {
|
||||
local installer="$1" H OUT SHIM rc
|
||||
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
||||
seed_home "$H"
|
||||
make_cp_shim "$SHIM" # poison cp → triggers the abort + restore
|
||||
make_rm_fail_shim "$SHIM" # rm -rf <H> fails → exercises the D2 reset guard
|
||||
set +e
|
||||
PATH="$SHIM:$ORIG_PATH" ORIG_PATH_FOR_RM="$ORIG_PATH" FAIL_RM_TARGET="$H" \
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||
rc=$?
|
||||
set -e 2>/dev/null || true
|
||||
rm -rf "$SHIM"
|
||||
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
||||
}
|
||||
|
||||
IFS=$'\t' read -r rcG OUTG HG < <(run_rmfail_upgrade "$INSTALL")
|
||||
chk "[reset-fail] a failed target reset still aborts non-zero" \
|
||||
"[ '$rcG' -ne 0 ]"
|
||||
chk "[reset-fail] the manual-recovery pointer is emitted (not a silent set -e exit)" \
|
||||
"grep -q 'Snapshot restore could not reset' '$OUTG'"
|
||||
chk "[reset-fail] the recovery message points at a preserved snapshot dir" \
|
||||
"grep -q 'preserved at: .*mosaic-snapshot' '$OUTG'"
|
||||
SNAP_E="$(grep -m1 -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTG")"
|
||||
chk "[reset-fail] the named snapshot directory actually survives for recovery" \
|
||||
"[ -n '$SNAP_E' ] && [ -d '$SNAP_E' ]"
|
||||
chk "[reset-fail] operator secret value never appears in installer output" \
|
||||
"! grep -q '$SECRET' '$OUTG'"
|
||||
|
||||
# Control: delete the D2 recovery line so a failed reset returns non-zero with NO
|
||||
# operator pointer — the observable defect (half-reset target, snapshot orphaned
|
||||
# in /tmp with no path told to the operator). Proves the message is load-bearing.
|
||||
sed '/Snapshot restore could not reset/d' "$INSTALL" > "$D2CTRL"
|
||||
chk "[control] the D2-recovery strip actually changed the installer" \
|
||||
"! cmp -s '$INSTALL' '$D2CTRL'"
|
||||
IFS=$'\t' read -r _rcH OUTH HH < <(run_rmfail_upgrade "$D2CTRL")
|
||||
chk "[control] without the D2 recovery line the operator gets no snapshot pointer" \
|
||||
"! grep -q 'Snapshot restore could not reset' '$OUTH'"
|
||||
[ -n "${SNAP_E:-}" ] && rm -rf "$SNAP_E"
|
||||
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
|
||||
orphan_snapshot="$(grep -m1 -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null || true)"
|
||||
[ -n "$orphan_snapshot" ] && rm -rf "$orphan_snapshot"
|
||||
|
||||
# Cleanup (generated installer controls are also removed by the EXIT trap).
|
||||
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
|
||||
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
|
||||
"$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||
|
||||
echo
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
[ "$fail" -eq 0 ]
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# verify-sanitized.sh — blocking CI gate: the public framework package must
|
||||
# contain no operator-specific personal data or private executable defaults.
|
||||
#
|
||||
# Two rule classes, with DELIBERATELY DIFFERENT scopes:
|
||||
# 1. DENYLIST (identity) — a LABELED, one-time regression guard for the CURRENT
|
||||
# operator's identity tokens. Scanned EVERYWHERE including examples/, because a
|
||||
# jarvis/jason/private-home regression in a SHIPPED example would break the
|
||||
# open-source guarantee just as badly as one in a default. NOT a general PII
|
||||
# detector (a future operator's name can't be enumerated) — the durable control
|
||||
# is the L0 framework-PR firewall + human review; this just stops re-contamination.
|
||||
# 2. STRUCTURAL (private $HOME default in *.sh) — scanned everywhere EXCEPT examples/,
|
||||
# because worked example overlays/personas legitimately show placeholder paths.
|
||||
#
|
||||
# File types: *.md, *.sh, *.ps1, *.json, *.yml/*.yaml, *.toml, *.env, *.service, and the CLI scripts under
|
||||
# tools/_scripts/. Excludes node_modules/ and this gate file.
|
||||
#
|
||||
# NOTE: '\bPDA\b' intentionally matches "PDA-friendly" (the contamination removed in P2);
|
||||
# a hyphen is not a \b word boundary on the right, so "PDA-foo" matches. If a future
|
||||
# legitimate doc needs the literal token "PDA" in a non-personal sense, reword it or
|
||||
# narrow this rule — do not weaken the gate silently.
|
||||
#
|
||||
# NOTE: private THIRD-PARTY host refs (e.g. a maintainer's employer Gitea) are NOT in
|
||||
# this denylist — they are functionally entangled in host-routing + test fixtures and
|
||||
# tracked as a separate follow-up.
|
||||
#
|
||||
# Usage: verify-sanitized.sh [FRAMEWORK_ROOT]
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
FRAMEWORK_ROOT="${1:-$(cd "$SCRIPT_DIR/../../.." && pwd)}"
|
||||
SELF_REL="tools/quality/scripts/verify-sanitized.sh"
|
||||
|
||||
DENYLIST='jarvis|jason|woltje|brain\.woltje\.com|/home/jwoltje|\bPDA\b'
|
||||
STRUCTURAL_SH=':[-=]\$\{?HOME\}?/src/'
|
||||
|
||||
cd "$FRAMEWORK_ROOT" || { echo "FRAMEWORK_ROOT not found: $FRAMEWORK_ROOT" >&2; exit 3; }
|
||||
|
||||
# Identity scope = ALL shipped text files (examples/ INCLUDED).
|
||||
_files_identity() {
|
||||
find . -type f \
|
||||
\( -name '*.md' -o -name '*.sh' -o -name '*.ps1' -o -name '*.json' -o -name '*.yml' -o -name '*.yaml' -o -name '*.toml' -o -name '*.env' -o -name '*.service' -o -path '*/tools/_scripts/*' \) \
|
||||
-not -path '*/node_modules/*' -not -path "./$SELF_REL" -print0
|
||||
}
|
||||
# Structural scope = shipped scripts, examples/ EXCLUDED.
|
||||
_files_structural() {
|
||||
find . -type f \( -name '*.sh' -o -path '*/tools/_scripts/*' \) \
|
||||
-not -path '*/examples/*' -not -path '*/node_modules/*' -not -path "./$SELF_REL" -print0
|
||||
}
|
||||
|
||||
# ---- self-test FIRST: a broken regex must never silently no-op the gate ----
|
||||
_selftest() {
|
||||
local tmp; tmp="$(mktemp -d)" || return 1
|
||||
printf 'contact jason.woltje at jarvis-brain (PDA-friendly)\n' > "$tmp/planted.md"
|
||||
printf 'X="${VAR:-$HOME/src/whatever/x.json}"\n' > "$tmp/planted.sh"
|
||||
printf 'name: jason-woltje\n' > "$tmp/planted.yaml"
|
||||
printf '[Service]\nUser=jarvis\n' > "$tmp/planted.service"
|
||||
local rc=0
|
||||
grep -qIEi "$DENYLIST" "$tmp/planted.md" || { echo "✗ SELF-TEST: identity denylist regex broken" >&2; rc=1; }
|
||||
grep -qIE "$STRUCTURAL_SH" "$tmp/planted.sh" || { echo "✗ SELF-TEST: structural regex broken" >&2; rc=1; }
|
||||
# Prove the identity scan covers the config formats it claims to (yaml/service/etc).
|
||||
local n_ext
|
||||
n_ext=$(find "$tmp" -type f \( -name '*.yaml' -o -name '*.service' \) -print0 | xargs -0 -r grep -lIEi "$DENYLIST" 2>/dev/null | wc -l)
|
||||
[[ "$n_ext" -eq 2 ]] || { echo "✗ SELF-TEST: identity scan does not cover .yaml/.service extensions" >&2; rc=1; }
|
||||
rm -rf "$tmp"; return $rc
|
||||
}
|
||||
_selftest || exit 2
|
||||
|
||||
fail=0
|
||||
deny_hits="$(_files_identity | xargs -0 -r grep -nIEi "$DENYLIST" 2>/dev/null || true)"
|
||||
if [[ -n "$deny_hits" ]]; then
|
||||
echo "✗ [denylist] operator-identity tokens in shipped files (examples/ included):"
|
||||
echo "$deny_hits" | sed "s#^\./##; s/^/ /"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
struct_hits="$(_files_structural | xargs -0 -r grep -nIE "$STRUCTURAL_SH" 2>/dev/null || true)"
|
||||
if [[ -n "$struct_hits" ]]; then
|
||||
echo "✗ [structural] private \$HOME/src default in a shipped script:"
|
||||
echo "$struct_hits" | sed "s#^\./##; s/^/ /"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -ne 0 ]]; then
|
||||
echo
|
||||
echo "Sanitization gate FAILED. Public framework files must not contain operator identity" >&2
|
||||
echo "or private \$HOME defaults. Move personal content to init-generated files or genericize." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ sanitization gate passed (identity scan incl. examples/; structural scan excl. examples/)"
|
||||
@@ -0,0 +1,91 @@
|
||||
# Quality Rails Verification Script (Windows)
|
||||
|
||||
Write-Host "═══════════════════════════════════════════"
|
||||
Write-Host "Quality Rails Enforcement Verification"
|
||||
Write-Host "═══════════════════════════════════════════"
|
||||
Write-Host ""
|
||||
|
||||
$Passed = 0
|
||||
$Failed = 0
|
||||
|
||||
# Test 1: Type error blocked
|
||||
Write-Host "Test 1: Type errors should be blocked..."
|
||||
"const x: string = 123;" | Out-File -FilePath test-file.ts -Encoding utf8
|
||||
git add test-file.ts 2>$null
|
||||
$output = git commit -m "Test commit" 2>&1 | Out-String
|
||||
if ($output -match "error") {
|
||||
Write-Host "✅ PASS: Type errors blocked" -ForegroundColor Green
|
||||
$Passed++
|
||||
} else {
|
||||
Write-Host "❌ FAIL: Type errors NOT blocked" -ForegroundColor Red
|
||||
$Failed++
|
||||
}
|
||||
git reset HEAD test-file.ts 2>$null
|
||||
Remove-Item test-file.ts -ErrorAction SilentlyContinue
|
||||
|
||||
# Test 2: any type blocked
|
||||
Write-Host ""
|
||||
Write-Host "Test 2: 'any' types should be blocked..."
|
||||
"const x: any = 123;" | Out-File -FilePath test-file.ts -Encoding utf8
|
||||
git add test-file.ts 2>$null
|
||||
$output = git commit -m "Test commit" 2>&1 | Out-String
|
||||
if ($output -match "no-explicit-any") {
|
||||
Write-Host "✅ PASS: 'any' types blocked" -ForegroundColor Green
|
||||
$Passed++
|
||||
} else {
|
||||
Write-Host "❌ FAIL: 'any' types NOT blocked" -ForegroundColor Red
|
||||
$Failed++
|
||||
}
|
||||
git reset HEAD test-file.ts 2>$null
|
||||
Remove-Item test-file.ts -ErrorAction SilentlyContinue
|
||||
|
||||
# Test 3a: gitleaks binary must be present
|
||||
Write-Host ""
|
||||
Write-Host "Test 3a: gitleaks must be installed..."
|
||||
$gitleaksPath = Get-Command gitleaks -ErrorAction SilentlyContinue
|
||||
if ($gitleaksPath) {
|
||||
$gitleaksVer = & gitleaks version 2>&1 | Out-String
|
||||
Write-Host "✅ PASS: gitleaks found ($($gitleaksVer.Trim()))" -ForegroundColor Green
|
||||
$Passed++
|
||||
} else {
|
||||
Write-Host "❌ FAIL: gitleaks is NOT installed — secret scanning will not work" -ForegroundColor Red
|
||||
Write-Host " Install: winget install gitleaks"
|
||||
$Failed++
|
||||
}
|
||||
|
||||
# Test 3b: gitleaks detects a planted AWS key
|
||||
Write-Host ""
|
||||
Write-Host "Test 3b: gitleaks should detect planted AWS key..."
|
||||
if ($gitleaksPath) {
|
||||
"aws_access_key_id = AKIAIOSFODNN7REALKEY" | Out-File -FilePath gitleaks-test-secret.txt -Encoding utf8
|
||||
git add gitleaks-test-secret.txt 2>$null
|
||||
$output = & gitleaks git --pre-commit --staged --redact 2>&1 | Out-String
|
||||
if ($output -match "leak|finding") {
|
||||
Write-Host "✅ PASS: gitleaks detected planted secret" -ForegroundColor Green
|
||||
$Passed++
|
||||
} else {
|
||||
Write-Host "❌ FAIL: gitleaks did NOT detect planted secret" -ForegroundColor Red
|
||||
$Failed++
|
||||
}
|
||||
git reset HEAD gitleaks-test-secret.txt 2>$null
|
||||
Remove-Item gitleaks-test-secret.txt -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
Write-Host "⚠ SKIP: gitleaks not installed (Test 3a already failed)"
|
||||
}
|
||||
|
||||
# Summary
|
||||
Write-Host ""
|
||||
Write-Host "═══════════════════════════════════════════"
|
||||
Write-Host "Verification Summary"
|
||||
Write-Host "═══════════════════════════════════════════"
|
||||
Write-Host "✅ Passed: $Passed"
|
||||
Write-Host "❌ Failed: $Failed"
|
||||
Write-Host ""
|
||||
|
||||
if ($Failed -eq 0) {
|
||||
Write-Host "🎉 All tests passed! Quality enforcement is working." -ForegroundColor Green
|
||||
exit 0
|
||||
} else {
|
||||
Write-Host "⚠ Some tests failed. Review configuration." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Quality Rails Verification Script
|
||||
# Tests that enforcement actually works
|
||||
|
||||
echo "═══════════════════════════════════════════"
|
||||
echo "Quality Rails Enforcement Verification"
|
||||
echo "═══════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
|
||||
# Test 1: Type error blocked
|
||||
echo "Test 1: Type errors should be blocked..."
|
||||
echo "const x: string = 123;" > test-file.ts
|
||||
git add test-file.ts 2>/dev/null
|
||||
if git commit -m "Test commit" 2>&1 | grep -q "error"; then
|
||||
echo "✅ PASS: Type errors blocked"
|
||||
((PASSED++))
|
||||
else
|
||||
echo "❌ FAIL: Type errors NOT blocked"
|
||||
((FAILED++))
|
||||
fi
|
||||
git reset HEAD test-file.ts 2>/dev/null
|
||||
rm test-file.ts 2>/dev/null
|
||||
|
||||
# Test 2: any type blocked
|
||||
echo ""
|
||||
echo "Test 2: 'any' types should be blocked..."
|
||||
echo "const x: any = 123;" > test-file.ts
|
||||
git add test-file.ts 2>/dev/null
|
||||
if git commit -m "Test commit" 2>&1 | grep -q "no-explicit-any"; then
|
||||
echo "✅ PASS: 'any' types blocked"
|
||||
((PASSED++))
|
||||
else
|
||||
echo "❌ FAIL: 'any' types NOT blocked"
|
||||
((FAILED++))
|
||||
fi
|
||||
git reset HEAD test-file.ts 2>/dev/null
|
||||
rm test-file.ts 2>/dev/null
|
||||
|
||||
# Test 3a: gitleaks binary must be present
|
||||
echo ""
|
||||
echo "Test 3a: gitleaks must be installed..."
|
||||
if command -v gitleaks &> /dev/null; then
|
||||
echo "✅ PASS: gitleaks found ($(gitleaks version 2>/dev/null || echo 'unknown version'))"
|
||||
PASSED=$((PASSED + 1))
|
||||
else
|
||||
echo "❌ FAIL: gitleaks is NOT installed — secret scanning will not work"
|
||||
echo " Install: https://github.com/gitleaks/gitleaks#installing"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
|
||||
# Test 3b: gitleaks detects a planted AWS key
|
||||
echo ""
|
||||
echo "Test 3b: gitleaks should detect planted AWS key..."
|
||||
if command -v gitleaks &> /dev/null; then
|
||||
echo 'aws_access_key_id = AKIAIOSFODNN7REALKEY' > gitleaks-test-secret.txt
|
||||
git add gitleaks-test-secret.txt 2>/dev/null
|
||||
if gitleaks git --pre-commit --staged --redact 2>&1 | grep -q -i "leak\|finding"; then
|
||||
echo "✅ PASS: gitleaks detected planted secret"
|
||||
PASSED=$((PASSED + 1))
|
||||
else
|
||||
echo "❌ FAIL: gitleaks did NOT detect planted secret"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
git reset HEAD gitleaks-test-secret.txt 2>/dev/null
|
||||
rm gitleaks-test-secret.txt 2>/dev/null
|
||||
else
|
||||
echo "⚠ SKIP: gitleaks not installed (Test 3a already failed)"
|
||||
fi
|
||||
|
||||
# Test 4: Lint error blocked
|
||||
echo ""
|
||||
echo "Test 4: Lint errors should be blocked..."
|
||||
echo "const x=123" > test-file.ts # Missing semicolon
|
||||
git add test-file.ts 2>/dev/null
|
||||
if git commit -m "Test commit" 2>&1 | grep -q "prettier"; then
|
||||
echo "✅ PASS: Lint errors blocked"
|
||||
((PASSED++))
|
||||
else
|
||||
echo "❌ FAIL: Lint errors NOT blocked"
|
||||
((FAILED++))
|
||||
fi
|
||||
git reset HEAD test-file.ts 2>/dev/null
|
||||
rm test-file.ts 2>/dev/null
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════"
|
||||
echo "Verification Summary"
|
||||
echo "═══════════════════════════════════════════"
|
||||
echo "✅ Passed: $PASSED"
|
||||
echo "❌ Failed: $FAILED"
|
||||
echo ""
|
||||
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
echo "🎉 All tests passed! Quality enforcement is working."
|
||||
exit 0
|
||||
else
|
||||
echo "⚠ Some tests failed. Review configuration."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,162 @@
|
||||
# Mosaic Quality Rails — gitleaks configuration
|
||||
# Shared across all project templates. Copied to project root by install.sh.
|
||||
# Built-in rules: https://github.com/gitleaks/gitleaks/tree/master/config
|
||||
# This file adds custom rules for patterns the 150+ built-in rules miss.
|
||||
|
||||
title = "Mosaic gitleaks config"
|
||||
|
||||
[allowlist]
|
||||
description = "Global allowlist — skip files that never contain real secrets"
|
||||
paths = [
|
||||
'''node_modules/''',
|
||||
'''dist/''',
|
||||
'''build/''',
|
||||
'''\.next/''',
|
||||
'''\.nuxt/''',
|
||||
'''\.output/''',
|
||||
'''coverage/''',
|
||||
'''__pycache__/''',
|
||||
'''\.venv/''',
|
||||
'''vendor/''',
|
||||
'''pnpm-lock\.yaml$''',
|
||||
'''package-lock\.json$''',
|
||||
'''yarn\.lock$''',
|
||||
'''\.lock$''',
|
||||
'''\.snap$''',
|
||||
'''\.min\.js$''',
|
||||
'''\.min\.css$''',
|
||||
'''\.gitleaks\.toml$''',
|
||||
]
|
||||
stopwords = [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"changeme",
|
||||
"placeholder",
|
||||
"example",
|
||||
"example.com",
|
||||
"test",
|
||||
"dummy",
|
||||
"fake",
|
||||
"sample",
|
||||
"your-",
|
||||
"xxx",
|
||||
"CHANGEME",
|
||||
"PLACEHOLDER",
|
||||
"TODO",
|
||||
"REPLACE_ME",
|
||||
]
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Custom rules — patterns the built-in rules miss
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
[[rules]]
|
||||
id = "database-url-with-credentials"
|
||||
description = "Database connection URL with embedded password"
|
||||
regex = '''(?i)(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis|amqp)://[^:\s]+:[^@\s]+@[^/\s]+'''
|
||||
tags = ["database", "connection-string"]
|
||||
[rules.allowlist]
|
||||
stopwords = ["localhost", "127.0.0.1", "changeme", "password", "example", "test_", "placeholder"]
|
||||
|
||||
[[rules]]
|
||||
id = "alembic-ini-sqlalchemy-url"
|
||||
description = "SQLAlchemy URL in alembic.ini with credentials"
|
||||
regex = '''sqlalchemy\.url\s*=\s*\S+://[^:\s]+:[^@\s]+@\S+'''
|
||||
paths = ['''alembic\.ini$''', '''\.ini$''']
|
||||
tags = ["python", "alembic", "database"]
|
||||
[rules.allowlist]
|
||||
stopwords = ["localhost", "127.0.0.1", "changeme", "driver://user:pass"]
|
||||
|
||||
[[rules]]
|
||||
id = "dotenv-secret-value"
|
||||
description = "High-entropy secret value in .env file"
|
||||
regex = '''(?i)(?:SECRET|TOKEN|PASSWORD|KEY|CREDENTIALS|AUTH)[\w]*\s*=\s*['"]?[A-Za-z0-9/+=]{20,}['"]?\s*$'''
|
||||
paths = ['''\.env$''', '''\.env\.\w+$''']
|
||||
tags = ["dotenv", "secret"]
|
||||
[rules.allowlist]
|
||||
stopwords = ["changeme", "placeholder", "example", "your_", "REPLACE", "TODO"]
|
||||
|
||||
[[rules]]
|
||||
id = "jdbc-url-with-password"
|
||||
description = "JDBC connection string with embedded password"
|
||||
regex = '''jdbc:[a-z]+://[^;\s]+password=[^;\s&]+'''
|
||||
tags = ["java", "jdbc", "database"]
|
||||
[rules.allowlist]
|
||||
stopwords = ["changeme", "placeholder", "example"]
|
||||
|
||||
[[rules]]
|
||||
id = "dsn-inline-password"
|
||||
description = "DSN-style connection string with inline password"
|
||||
regex = '''(?i)(?:dsn|connection_string|conn_str)\s*[:=]\s*\S+://[^:\s]+:[^@\s]+@\S+'''
|
||||
tags = ["database", "connection-string"]
|
||||
[rules.allowlist]
|
||||
stopwords = ["localhost", "127.0.0.1", "changeme", "example"]
|
||||
|
||||
[[rules]]
|
||||
id = "hardcoded-password-variable"
|
||||
description = "Hardcoded password assignment in source code"
|
||||
regex = '''(?i)(?:password|passwd|pwd)\s*[:=]\s*['"][^'"]{8,}['"]'''
|
||||
tags = ["password", "hardcoded"]
|
||||
[rules.allowlist]
|
||||
stopwords = ["changeme", "placeholder", "example", "test", "dummy", "password123", "your_password"]
|
||||
paths = [
|
||||
'''test[s]?/''',
|
||||
'''spec[s]?/''',
|
||||
'''__test__/''',
|
||||
'''fixture[s]?/''',
|
||||
'''mock[s]?/''',
|
||||
]
|
||||
|
||||
[[rules]]
|
||||
id = "bearer-token-in-code"
|
||||
description = "Hardcoded bearer token in source code"
|
||||
regex = '''(?i)['"]Bearer\s+[A-Za-z0-9\-._~+/]+=*['"]'''
|
||||
tags = ["auth", "bearer", "token"]
|
||||
[rules.allowlist]
|
||||
stopwords = ["example", "test", "dummy", "placeholder", "fake"]
|
||||
|
||||
[[rules]]
|
||||
id = "spring-application-properties-password"
|
||||
description = "Password in Spring Boot application properties"
|
||||
regex = '''(?i)spring\.\w+\.password\s*=\s*\S+'''
|
||||
paths = ['''application\.properties$''', '''application\.yml$''', '''application-\w+\.properties$''', '''application-\w+\.yml$''']
|
||||
tags = ["java", "spring", "password"]
|
||||
[rules.allowlist]
|
||||
stopwords = ["changeme", "placeholder", "${"]
|
||||
|
||||
[[rules]]
|
||||
id = "docker-compose-env-secret"
|
||||
description = "Hardcoded secret in docker-compose environment"
|
||||
regex = '''(?i)(?:POSTGRES_PASSWORD|MYSQL_ROOT_PASSWORD|MYSQL_PASSWORD|REDIS_PASSWORD|RABBITMQ_DEFAULT_PASS|MONGO_INITDB_ROOT_PASSWORD)\s*[:=]\s*['"]?[^\s'"$]{8,}['"]?'''
|
||||
paths = ['''compose\.ya?ml$''', '''docker-compose\.ya?ml$''']
|
||||
tags = ["docker", "compose", "secret"]
|
||||
[rules.allowlist]
|
||||
stopwords = ["changeme", "placeholder", "example", "${"]
|
||||
|
||||
[[rules]]
|
||||
id = "terraform-variable-secret"
|
||||
description = "Sensitive default value in Terraform variable"
|
||||
regex = '''(?i)default\s*=\s*"[^"]{8,}"'''
|
||||
paths = ['''variables\.tf$''', '''\.tf$''']
|
||||
tags = ["terraform", "secret"]
|
||||
[rules.allowlist]
|
||||
stopwords = ["changeme", "placeholder", "example", "TODO"]
|
||||
|
||||
[[rules]]
|
||||
id = "private-key-pem-inline"
|
||||
description = "PEM-encoded private key in source"
|
||||
regex = '''-----BEGIN\s+(?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----'''
|
||||
tags = ["key", "pem", "private-key"]
|
||||
|
||||
[[rules]]
|
||||
id = "base64-encoded-secret"
|
||||
description = "Base64 value assigned to secret-named variable"
|
||||
regex = '''(?i)(?:secret|token|key|password|credentials)[\w]*\s*[:=]\s*['"]?[A-Za-z0-9+/]{40,}={0,2}['"]?'''
|
||||
tags = ["base64", "encoded", "secret"]
|
||||
[rules.allowlist]
|
||||
stopwords = ["changeme", "placeholder", "example", "test"]
|
||||
paths = [
|
||||
'''test[s]?/''',
|
||||
'''spec[s]?/''',
|
||||
'''fixture[s]?/''',
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
// Root ESLint config for monorepo
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'security'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:security/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
rules: {
|
||||
// Type Safety - STRICT
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/explicit-function-return-type': 'warn',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'error',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
|
||||
// Promise/Async Safety
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
|
||||
// Code Quality
|
||||
'@typescript-eslint/no-var-requires': 'error',
|
||||
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
|
||||
'@typescript-eslint/prefer-optional-chain': 'warn',
|
||||
|
||||
// Prettier
|
||||
'prettier/prettier': [
|
||||
'error',
|
||||
{
|
||||
endOfLine: 'auto',
|
||||
},
|
||||
],
|
||||
},
|
||||
ignorePatterns: ['node_modules', 'dist', 'build', '.next', 'out', 'coverage', '.turbo'],
|
||||
overrides: [
|
||||
{
|
||||
// Next.js apps
|
||||
files: ['apps/**/app/**/*.{ts,tsx}', 'apps/**/pages/**/*.{ts,tsx}'],
|
||||
extends: ['next/core-web-vitals'],
|
||||
},
|
||||
{
|
||||
// NestJS apps
|
||||
files: ['apps/**/*.controller.ts', 'apps/**/*.service.ts', 'apps/**/*.module.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-function-return-type': 'error',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
npx lint-staged
|
||||
|
||||
# Secret scanning — gitleaks is REQUIRED (not optional like git-secrets was)
|
||||
if ! command -v gitleaks &>/dev/null; then
|
||||
echo ""
|
||||
echo "ERROR: gitleaks is not installed. Secret scanning is required."
|
||||
echo ""
|
||||
echo "Install:"
|
||||
echo " Linux: curl -sSfL https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_8.24.0_linux_x64.tar.gz | sudo tar -xz -C /usr/local/bin gitleaks"
|
||||
echo " macOS: brew install gitleaks"
|
||||
echo " Windows: winget install gitleaks"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
gitleaks git --pre-commit --redact --staged --verbose
|
||||
@@ -0,0 +1,29 @@
|
||||
// Monorepo-aware lint-staged configuration
|
||||
module.exports = {
|
||||
// TypeScript files across all packages
|
||||
'**/*.{ts,tsx}': (filenames) => {
|
||||
const commands = [`eslint ${filenames.join(' ')} --fix --max-warnings=0`];
|
||||
|
||||
// Only run tsc in packages that have tsconfig.json
|
||||
// NOTE: lint-staged passes absolute paths, so we need to match both:
|
||||
// - Relative: apps/api/src/file.ts
|
||||
// - Absolute: /home/user/project/apps/api/src/file.ts
|
||||
const packages = [
|
||||
...new Set(
|
||||
filenames.map((f) => {
|
||||
const match = f.match(/(?:^|\/)(apps|packages)\/([^/]+)\//);
|
||||
return match ? `${match[1]}/${match[2]}` : null;
|
||||
}),
|
||||
),
|
||||
].filter(Boolean);
|
||||
|
||||
packages.forEach((pkg) => {
|
||||
commands.push(`tsc --project ${pkg}/tsconfig.json --noEmit`);
|
||||
});
|
||||
|
||||
return commands;
|
||||
},
|
||||
|
||||
// Format all files
|
||||
'**/*.{js,jsx,ts,tsx,json,md,yml,yaml}': ['prettier --write'],
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
# Woodpecker CI Quality Enforcement Pipeline - Monorepo
|
||||
when:
|
||||
- event: [push, pull_request, manual]
|
||||
|
||||
# Dependencies are installed ONCE in the `install` step and every downstream
|
||||
# step depends on it, reusing the populated node_modules from the shared
|
||||
# workspace volume. Do NOT re-run `npm ci` per step — that pays the full cold
|
||||
# install (network fetch + native rebuilds) N times and is the dominant cost
|
||||
# in a pipeline.
|
||||
#
|
||||
# For best results, replace `&node_image` with a pre-baked CI base image that
|
||||
# ships your toolchain (python3/make/g++ for native modules) and a warm npm
|
||||
# cache, then keep `--prefer-offline` so installs resolve from the cache. See
|
||||
# the Mosaic Stack repo's Dockerfile.ci + .woodpecker/ci-image.yml for the
|
||||
# baked-image pattern.
|
||||
variables:
|
||||
- &node_image 'node:20-alpine'
|
||||
- &gitleaks_image 'ghcr.io/gitleaks/gitleaks:v8.24.0'
|
||||
|
||||
steps:
|
||||
# Secret scanning (runs in parallel with install, no deps)
|
||||
secret-scan:
|
||||
image: *gitleaks_image
|
||||
commands:
|
||||
- gitleaks git --redact --verbose --log-opts="HEAD~1..HEAD"
|
||||
depends_on: []
|
||||
|
||||
# Single cached install. Every other step depends on this and reuses the
|
||||
# node_modules it produces in the shared workspace.
|
||||
install:
|
||||
image: *node_image
|
||||
commands:
|
||||
- corepack enable
|
||||
- npm ci --ignore-scripts --prefer-offline
|
||||
depends_on: []
|
||||
|
||||
security-audit:
|
||||
image: *node_image
|
||||
commands:
|
||||
- npm audit --audit-level=high
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
lint:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: 'true'
|
||||
commands:
|
||||
- npm run lint
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: 'true'
|
||||
commands:
|
||||
- npm run type-check
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
test:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: 'true'
|
||||
commands:
|
||||
- npm run test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}'
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
build:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: 'true'
|
||||
NODE_ENV: 'production'
|
||||
commands:
|
||||
- npm run build
|
||||
depends_on:
|
||||
- lint
|
||||
- typecheck
|
||||
- test
|
||||
- security-audit
|
||||
- secret-scan
|
||||
@@ -0,0 +1,102 @@
|
||||
# Monorepo Structure
|
||||
|
||||
This quality-rails monorepo template supports the following structure:
|
||||
|
||||
```
|
||||
monorepo/
|
||||
├── apps/
|
||||
│ ├── web/ # Next.js frontend
|
||||
│ │ ├── package.json
|
||||
│ │ ├── tsconfig.json # extends ../../tsconfig.base.json
|
||||
│ │ └── .eslintrc.js # extends ../../.eslintrc.strict.js
|
||||
│ └── api/ # NestJS backend
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── .eslintrc.js
|
||||
├── packages/
|
||||
│ ├── shared-types/ # Shared TypeScript types
|
||||
│ ├── ui/ # Shared UI components
|
||||
│ └── config/ # Shared configuration
|
||||
├── .husky/
|
||||
│ └── pre-commit
|
||||
├── .lintstagedrc.js # Multi-package aware
|
||||
├── .eslintrc.strict.js # Root ESLint config
|
||||
├── tsconfig.base.json # Base TypeScript config
|
||||
├── turbo.json # TurboRepo configuration
|
||||
├── pnpm-workspace.yaml # pnpm workspaces
|
||||
└── package.json # Root package with scripts
|
||||
```
|
||||
|
||||
## Package-Specific Configs
|
||||
|
||||
Each package extends the root configuration:
|
||||
|
||||
**apps/web/tsconfig.json:**
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"jsx": "preserve",
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules", ".next"]
|
||||
}
|
||||
```
|
||||
|
||||
**apps/api/tsconfig.json:**
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
```
|
||||
|
||||
## Running Commands
|
||||
|
||||
**All packages:**
|
||||
|
||||
```bash
|
||||
npm run lint # Lint all packages
|
||||
npm run type-check # Type check all packages
|
||||
npm run test # Test all packages
|
||||
npm run build # Build all packages
|
||||
```
|
||||
|
||||
**Single package:**
|
||||
|
||||
```bash
|
||||
npm run lint --workspace=apps/web
|
||||
npm run dev --workspace=apps/api
|
||||
```
|
||||
|
||||
**With TurboRepo:**
|
||||
|
||||
```bash
|
||||
turbo run build # Build with caching
|
||||
turbo run dev --parallel # Run dev servers in parallel
|
||||
```
|
||||
|
||||
## Pre-Commit Enforcement
|
||||
|
||||
lint-staged automatically detects which packages contain modified files and runs:
|
||||
|
||||
- ESLint on changed files
|
||||
- TypeScript check on affected packages
|
||||
- Prettier on all changed files
|
||||
|
||||
Only runs checks on packages that have changes (efficient).
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "monorepo",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"type-check": "turbo run type-check",
|
||||
"test": "turbo run test",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-security": "^3.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.0.0",
|
||||
"turbo": "^2.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- 'apps/*'
|
||||
- 'packages/*'
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"composite": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
// STRICT MODE - All enabled
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
|
||||
// Additional Checks
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"allowUnusedLabels": false,
|
||||
"allowUnreachableCode": false
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "build", ".next", "out"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"globalDependencies": ["**/.env.*local"],
|
||||
"pipeline": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", ".next/**", "build/**"]
|
||||
},
|
||||
"lint": {
|
||||
"cache": false
|
||||
},
|
||||
"type-check": {
|
||||
"cache": false
|
||||
},
|
||||
"test": {
|
||||
"cache": false
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'next/core-web-vitals',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
'plugin:security/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'security'],
|
||||
rules: {
|
||||
// Type Safety - STRICT
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/explicit-function-return-type': 'warn',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'error',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
|
||||
// Promise/Async Safety
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
|
||||
// React/Next.js specific
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
|
||||
// Prettier
|
||||
'prettier/prettier': [
|
||||
'error',
|
||||
{
|
||||
endOfLine: 'auto',
|
||||
},
|
||||
],
|
||||
},
|
||||
ignorePatterns: ['.next', 'out', 'public', '.eslintrc.js'],
|
||||
};
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
npx lint-staged
|
||||
|
||||
# Secret scanning — gitleaks is REQUIRED (not optional like git-secrets was)
|
||||
if ! command -v gitleaks &>/dev/null; then
|
||||
echo ""
|
||||
echo "ERROR: gitleaks is not installed. Secret scanning is required."
|
||||
echo ""
|
||||
echo "Install:"
|
||||
echo " Linux: curl -sSfL https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_8.24.0_linux_x64.tar.gz | sudo tar -xz -C /usr/local/bin gitleaks"
|
||||
echo " macOS: brew install gitleaks"
|
||||
echo " Windows: winget install gitleaks"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
gitleaks git --pre-commit --redact --staged --verbose
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
'*.{ts,tsx}': ['eslint --fix --max-warnings=0', 'bash -c "tsc --noEmit"'],
|
||||
'*.{js,jsx,ts,tsx,json,md,css}': ['prettier --write'],
|
||||
};
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# Woodpecker CI Quality Enforcement Pipeline - Next.js
|
||||
when:
|
||||
- event: [push, pull_request, manual]
|
||||
|
||||
variables:
|
||||
- &node_image 'node:20-alpine'
|
||||
- &gitleaks_image 'ghcr.io/gitleaks/gitleaks:v8.24.0'
|
||||
- &install_deps |
|
||||
corepack enable
|
||||
npm ci --ignore-scripts
|
||||
|
||||
steps:
|
||||
# Secret scanning (runs in parallel with install, no deps)
|
||||
secret-scan:
|
||||
image: *gitleaks_image
|
||||
commands:
|
||||
- gitleaks git --redact --verbose --log-opts="HEAD~1..HEAD"
|
||||
depends_on: []
|
||||
|
||||
install:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *install_deps
|
||||
|
||||
security-audit:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *install_deps
|
||||
- npm audit --audit-level=high
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
lint:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: 'true'
|
||||
commands:
|
||||
- *install_deps
|
||||
- npm run lint
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: 'true'
|
||||
commands:
|
||||
- *install_deps
|
||||
- npm run type-check
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
test:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: 'true'
|
||||
commands:
|
||||
- *install_deps
|
||||
- npm run test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}'
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
build:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: 'true'
|
||||
NODE_ENV: 'production'
|
||||
commands:
|
||||
- *install_deps
|
||||
- npm run build
|
||||
depends_on:
|
||||
- lint
|
||||
- typecheck
|
||||
- test
|
||||
- security-audit
|
||||
- secret-scan
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
|
||||
// Security headers
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'X-DNS-Prefetch-Control',
|
||||
value: 'on'
|
||||
},
|
||||
{
|
||||
key: 'Strict-Transport-Security',
|
||||
value: 'max-age=63072000; includeSubDomains; preload'
|
||||
},
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'SAMEORIGIN'
|
||||
},
|
||||
{
|
||||
key: 'X-Content-Type-Options',
|
||||
value: 'nosniff'
|
||||
},
|
||||
{
|
||||
key: 'X-XSS-Protection',
|
||||
value: '1; mode=block'
|
||||
},
|
||||
{
|
||||
key: 'Referrer-Policy',
|
||||
value: 'origin-when-cross-origin'
|
||||
},
|
||||
{
|
||||
key: 'Permissions-Policy',
|
||||
value: 'camera=(), microphone=(), geolocation=()'
|
||||
},
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self';"
|
||||
}
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint --max-warnings=0",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^14.0.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "^14.0.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-security": "^3.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.0.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"allowUnusedLabels": false,
|
||||
"allowUnreachableCode": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules", ".next", "out"]
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin', 'security'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
'plugin:security/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
// Type Safety - STRICT
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/explicit-function-return-type': 'warn',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'error',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
|
||||
// Promise/Async Safety
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
|
||||
// Type Assertions
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
||||
'@typescript-eslint/consistent-type-assertions': [
|
||||
'error',
|
||||
{ assertionStyle: 'as', objectLiteralTypeAssertions: 'never' },
|
||||
],
|
||||
|
||||
// Code Quality
|
||||
'@typescript-eslint/no-var-requires': 'error',
|
||||
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
|
||||
'@typescript-eslint/prefer-optional-chain': 'warn',
|
||||
'@typescript-eslint/strict-boolean-expressions': 'warn',
|
||||
|
||||
// Prettier
|
||||
'prettier/prettier': [
|
||||
'error',
|
||||
{
|
||||
endOfLine: 'auto',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
npx lint-staged
|
||||
|
||||
# Secret scanning — gitleaks is REQUIRED (not optional like git-secrets was)
|
||||
if ! command -v gitleaks &>/dev/null; then
|
||||
echo ""
|
||||
echo "ERROR: gitleaks is not installed. Secret scanning is required."
|
||||
echo ""
|
||||
echo "Install:"
|
||||
echo " Linux: curl -sSfL https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_8.24.0_linux_x64.tar.gz | sudo tar -xz -C /usr/local/bin gitleaks"
|
||||
echo " macOS: brew install gitleaks"
|
||||
echo " Windows: winget install gitleaks"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
gitleaks git --pre-commit --redact --staged --verbose
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
'*.{ts,tsx}': ['eslint --fix --max-warnings=0', 'bash -c "tsc --noEmit"'],
|
||||
'*.{js,jsx,ts,tsx,json,md}': ['prettier --write'],
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
# Woodpecker CI Quality Enforcement Pipeline
|
||||
# Runs on: push, pull_request, manual
|
||||
|
||||
when:
|
||||
- event: [push, pull_request, manual]
|
||||
|
||||
variables:
|
||||
- &node_image 'node:20-alpine'
|
||||
- &gitleaks_image 'ghcr.io/gitleaks/gitleaks:v8.24.0'
|
||||
- &install_deps |
|
||||
corepack enable
|
||||
npm ci --ignore-scripts
|
||||
|
||||
steps:
|
||||
# Secret scanning (runs in parallel with install, no deps)
|
||||
secret-scan:
|
||||
image: *gitleaks_image
|
||||
commands:
|
||||
- gitleaks git --redact --verbose --log-opts="HEAD~1..HEAD"
|
||||
depends_on: []
|
||||
|
||||
# Stage 1: Install
|
||||
install:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *install_deps
|
||||
|
||||
# Stage 2: Security Audit
|
||||
security-audit:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *install_deps
|
||||
- npm audit --audit-level=high
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
# Stage 3: Lint
|
||||
lint:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *install_deps
|
||||
- npm run lint
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
# Stage 4: Type Check
|
||||
typecheck:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *install_deps
|
||||
- npm run type-check
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
# Stage 5: Test with Coverage
|
||||
test:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *install_deps
|
||||
- npm run test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}'
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
# Stage 6: Build
|
||||
build:
|
||||
image: *node_image
|
||||
commands:
|
||||
- *install_deps
|
||||
- npm run build
|
||||
depends_on:
|
||||
- lint
|
||||
- typecheck
|
||||
- test
|
||||
- security-audit
|
||||
- secret-scan
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"scripts": {
|
||||
"lint": "eslint 'src/**/*.{ts,tsx}' --max-warnings=0",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"build": "tsc",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-security": "^3.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.0.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"removeComments": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
// STRICT MODE - All enabled
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
|
||||
// Additional Checks
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"allowUnusedLabels": false,
|
||||
"allowUnreachableCode": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# test-enumeration-exclusions.txt — signed exclusions for check-test-enumeration.sh (#1017).
|
||||
#
|
||||
# Every entry is a recorded decision: a suite-shaped file that exists on disk,
|
||||
# is NOT reachable from any CI surface, and carries the reason someone signed
|
||||
# for that. The guard FAILS on an entry with no reason, a stale path, or a path
|
||||
# the surfaces already enumerate. Burning an entry down = making it CI-reachable
|
||||
# (package.json test:framework-shell or a ci.yml step) and deleting its line.
|
||||
#
|
||||
# Format: <repo-relative path> | <reason>
|
||||
# All entries below were signed at #1017's filing base (main 826a8b3b, 2026-07-31)
|
||||
# by pepper (sb-it-1-dt); measurements cited are one-run assertions from that seat.
|
||||
|
||||
# --- tools/git: the #1007 five — non-hermetic, resolve real credentials ---
|
||||
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping)
|
||||
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
|
||||
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh | resolves real credentials (#1007 census, fourth entry via family-grep); joins CI after the wrapper-half hermeticity fix
|
||||
|
||||
# --- tools/git: push guards — measured green locally, CI-image fitness unverified ---
|
||||
packages/mosaic/framework/tools/git/test-push-guard.sh | measured green at 826a8b3b (46 passed / 0 failed, one run, 2026-07-31); CI-image fitness unverified; #1017 burndown
|
||||
packages/mosaic/framework/tools/git/test-mutate-push-guard.sh | measured green at 826a8b3b (8/0, 13 mutants killed 0 survived, one run, 2026-07-31); requires setsid (util-linux), absent from the alpine base image; #1017 burndown
|
||||
packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh | hermeticity unaudited — the unprotected suite in #1007's protected/unprotected split; audit before CI; #1017 burndown
|
||||
|
||||
# --- tools/git: unmeasured ---
|
||||
packages/mosaic/framework/tools/git/test-verify-clean-clone.sh | unmeasured in CI image; asserts git file-mode (100644/755) semantics that need verification on the CI filesystem first; #1017 burndown
|
||||
packages/mosaic/framework/tools/git/test-help-exit-code.sh | unmeasured in CI image; stub-based (#701 regression harness), likely CI-fit; #1017 burndown
|
||||
packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh | unmeasured in CI image; fixture-based (#546/#547 regression harness), likely CI-fit; #1017 burndown
|
||||
|
||||
# --- tools/tmux: require a live tmux server ---
|
||||
packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a real tmux server on a throwaway socket; CI image ships no tmux; #1017 burndown (needs tmux in image or a signed permanent exclusion)
|
||||
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
||||
packages/mosaic/framework/tools/tmux/test-send-message-glyph-agnostic.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its siblings) — signed at adoption of #1262 (rev-code-02 F5), red-first verified on sb-it-1-dt
|
||||
|
||||
# --- single-suite directories: unmeasured in CI ---
|
||||
|
||||
# --- naming-boundary files the strict test-*.sh prefix cannot even name ---
|
||||
# (#1017: three independent censuses handled the microtest file three different
|
||||
# ways — editorial drop, structural exclusion, accidental inclusion — with no
|
||||
# recorded judgement. These lines ARE that judgement, signed.)
|
||||
packages/mosaic/framework/tools/orchestrator/smoke-test.sh | behavior smoke checks for coord continue/run workflows, run manually by orchestrator seats; unmeasured in CI; #1017 burndown
|
||||
packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh | #973 instrument self-test, run as a precondition of the validate-973 evidence procedure rather than as a standing CI suite; #1017 burndown candidate
|
||||
|
||||
# --- tools/fleet: precondition is unsatisfiable in the CI image (#1271) ---
|
||||
# Signed by fred (sb-it-1-dt, 2026-08-16) at origin/next 476db12.
|
||||
# This suite asserts the launcher's behaviour when `mosaic` and `pi` are MISSING.
|
||||
# It shims fakes into $FAKE_BIN, but the constructed PANE_PATH always ends in the
|
||||
# real system path, so on a host that installs those binaries the missing-binary
|
||||
# cases cannot be measured at all. The suite's own guard (line 103) says so and
|
||||
# fails rather than reporting a pass it cannot back. That guard is correct.
|
||||
# The error was wiring the suite into CI: #1017 (c56483eb) enumerated it and
|
||||
# dropped this exclusion, and the CI image provides `pi` in the system path, so
|
||||
# it has failed on every pipeline since. Measured 2026-08-16 across pipelines
|
||||
# 2444 (#1256), 2438 (#1240) and 2441 (#1017-quality): exactly one FAIL line in
|
||||
# each full log, identical, this assertion; control `zzz-not-present-zzz` -> 0.
|
||||
# Burn-down and the full measurement are tracked in #1271; unwired by PR #1270.
|
||||
# Because test:framework-shell is one && chain and this sat at position 44 of 48,
|
||||
# the four suites after it had not run at all since the merge.
|
||||
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | precondition unsatisfiable in the CI image: asserts missing-binary behaviour, but PANE_PATH always ends in the system path and the image provides `pi` there; guard at line 103 fails by design rather than passing unmeasured. Burn down by controlling the tail of PANE_PATH inside the test. NOT by removing `pi` from the image: the CI image installs @earendil-works/[email protected] deliberately (measured in pipeline 2444's test-step log), and other suites depend on that pin. Burn-down tracked in #1271
|
||||
Reference in New Issue
Block a user