diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..cf44923d --- /dev/null +++ b/.eslintignore @@ -0,0 +1,8 @@ +node_modules/ +archive/ +receipts/ +workspace/ +**/.agent-os/** +**/.venv/** +**/__pycache__/** + diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..bff93e30 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,26 @@ +{ + "root": true, + "env": { "es2021": true, "node": true }, + "parserOptions": { "ecmaVersion": 2021 }, + "extends": ["eslint:recommended"], + "ignorePatterns": [ + "node_modules/**", + "archive/**", + "receipts/**", + "workspace/**", + "**/.agent-os/**", + "**/.venv/**", + "**/__pycache__/**" + ], + "overrides": [ + { + "files": ["backend/**/*.cjs", "tools/**/*.cjs"], + "rules": { + "no-undef": "off", + "no-unused-vars": ["warn", { "args": "none", "ignoreRestSiblings": true }], + "no-console": "off" + } + } + ] +} + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65f2978d..7bc79688 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,8 @@ jobs: sleep 1 curl -fsS http://127.0.0.1:8790/health | tee health.json kill $(cat api.pid) + - name: Run ESLint (npx) + run: npx -y eslint . - name: Lint (if present) run: npm run -s lint --if-present - name: Test (if present) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..6196deea --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + packages: write + +jobs: + docker: + name: Build and publish image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/${{ github.repository_owner }}/soulfield + tags: | + type=raw,value=latest + type=ref,event=tag + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + github_release: + name: Create GitHub Release + needs: docker + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Create release with autogenerated notes + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..80f3928d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +Contributing to Soulfield + +Thanks for helping improve Soulfield! This guide keeps contributions smooth and predictable. + +Basics +- Use feature branches from `main`; open PRs early as draft when helpful. +- Keep PRs focused and small; include a clear title and summary. +- Prefer tests or a quick manual validation plan in the PR. + +Development +- Node: `npm ci && DEV_NO_API=1 npm start` then hit `GET /health`. +- MCP (read‑only FS): `npm run start:mcp` then `GET /mcp/tools`. +- Python (optional): run `pytest` for FastAPI sanity tests. + +Coding Standards +- JS/Node: follow existing patterns; keep changes minimal and explicit. +- Python: prefer pytest for tests; descriptive names; no one‑letter vars. +- Avoid unrelated refactors in the same PR; call them out if necessary. + +CI & Checks +- CI runs Node and Python jobs; both must be green. +- New workflows should be minimal and secure by default. + +Commit & PR Hygiene +- Conventional style (suggested): + - feat:, fix:, chore:, docs:, ci:, test:, refactor: +- PR template: fill Summary, Checklist, and Validation steps. + +Security +- Never commit secrets. `.env` is ignored; use `.env.example` for docs. +- MCP is read‑only and hardened, but treat it as a potential disclosure surface. +- See SECURITY.md for reporting vulnerabilities. + +Release +- Tag `vX.Y.Z` on main to trigger Docker image publish and a GitHub Release. + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..ceaf2d60 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,22 @@ +Security Policy + +Supported Versions +- Active development on `main`. Releases are tagged `vX.Y.Z`. + +Reporting a Vulnerability +- Please report security issues privately via GitHub Security Advisories: + - Go to the repository’s “Security” tab → “Advisories” → “Report a vulnerability”. +- If that’s unavailable, email the maintainer or open a minimal issue requesting a private channel. + +Do not disclose publicly until a fix is available and coordinated. + +Handling & Disclosure +- We will acknowledge reports within 72 hours. +- We aim to provide a fix or mitigation and publish a patch release. +- After a fix is released, we’ll coordinate a responsible disclosure timeline. + +Secrets & Hardening +- `.env` and `.env.*` are ignored by git; do not commit secrets. +- The MCP server is read‑only and blocks `.env*`, `.git`, `.ssh`, and `receipts/` paths. +- Avoid logging sensitive values; use environment variables for API keys. + diff --git a/backend/mcp-server.cjs b/backend/mcp-server.cjs index c3e1c54f..48ddea1d 100644 --- a/backend/mcp-server.cjs +++ b/backend/mcp-server.cjs @@ -9,6 +9,19 @@ app.use(express.json()); const ROOT = path.resolve(__dirname); // ~/soulfield const SAFE = ROOT; // restrict to project root +// Basic denylist to avoid accidental secret exfiltration when MCP is exposed. +const FORBIDDEN_DIRS = new Set([".git", ".ssh", "receipts"]); +function isForbidden(relPath){ + const rel = relPath.replace(/\\/g, "/"); + if (!rel || rel === ".") return false; + // Block .env files anywhere + if (/(^|\/)\.env(\.|$)/.test(rel)) return true; + // Block specific directories anywhere in the path + const parts = rel.split("/"); + if (parts.some(p => FORBIDDEN_DIRS.has(p))) return true; + return false; +} + function safePath(p){ const abs = path.resolve(SAFE, p || "."); if (!abs.startsWith(SAFE)) throw new Error("path escapes SAFE root"); @@ -16,15 +29,24 @@ function safePath(p){ } function listDir(p){ const abs = safePath(p); + const baseRel = path.relative(SAFE, abs) || "."; const ents = fs.readdirSync(abs, { withFileTypes: true }); - return ents.map(e => ({ - name: e.name, - type: e.isDirectory() ? "dir" : e.isFile() ? "file" : "other", - size: e.isFile() ? (fs.statSync(path.join(abs, e.name)).size) : null, - })); + return ents + .filter(e => { + const rel = baseRel === "." ? e.name : path.posix.join(baseRel.replace(/\\/g,"/"), e.name); + // Hide forbidden entries from listing + return !isForbidden(rel); + }) + .map(e => ({ + name: e.name, + type: e.isDirectory() ? "dir" : e.isFile() ? "file" : "other", + size: e.isFile() ? (fs.statSync(path.join(abs, e.name)).size) : null, + })); } function readFile(p, maxBytes=131072){ // 128 KiB cap const abs = safePath(p); + const rel = path.relative(SAFE, abs).replace(/\\/g, "/"); + if (isForbidden(rel)) throw new Error("access denied"); const st = fs.statSync(abs); if (!st.isFile()) throw new Error("not a file"); const cap = Math.min(st.size, maxBytes|0 || 0); diff --git a/backend/tests/chat-smoke.cjs b/backend/tests/chat-smoke.cjs new file mode 100644 index 00000000..397bc3ce --- /dev/null +++ b/backend/tests/chat-smoke.cjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +/* Simple smoke test for /chat using DEV_NO_API=1 (offline). */ +const cp = require('child_process'); +const fetch = require('node-fetch'); + +const PORT = 8790; + +function sleep(ms){ return new Promise(r=>setTimeout(r, ms)); } + +async function waitForHealth(timeoutMs=5000){ + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline){ + try { + const r = await fetch(`http://127.0.0.1:${PORT}/health`); + if (r.ok) return true; + } catch {} + await sleep(150); + } + return false; +} + +(async () => { + const child = cp.spawn(process.execPath, ['backend/index.cjs'], { + env: { ...process.env, DEV_NO_API: '1', PORT: String(PORT) }, + stdio: 'ignore' + }); + + try { + const ok = await waitForHealth(6000); + if (!ok) throw new Error('API did not become healthy'); + + // !help path + const helpRes = await fetch(`http://127.0.0.1:${PORT}/chat`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ prompt: '!help' }) + }); + if (!helpRes.ok) throw new Error('!help request failed'); + const helpJson = await helpRes.json(); + if (!helpJson.output || typeof helpJson.output !== 'string') throw new Error('!help output missing'); + + // @aiden path + const aidenRes = await fetch(`http://127.0.0.1:${PORT}/chat`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ prompt: '@aiden: ping' }) + }); + if (!aidenRes.ok) throw new Error('@aiden request failed'); + const aidenJson = await aidenRes.json(); + if (!aidenJson.output || typeof aidenJson.output !== 'string') throw new Error('@aiden output missing'); + + process.exit(0); + } catch (e){ + console.error(String(e && e.message || e)); + process.exit(1); + } finally { + child.kill('SIGTERM'); + } +})(); + diff --git a/package.json b/package.json index 17ce74db..c78af9cb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "start:tui": "node backend/tui.js", "test:apply": "node backend/tests/test-apply.js --dry", "test:apply:run": "node backend/tests/test-apply.js --apply", - "test": "echo \"No unit tests; try npm run test:apply\" && exit 0" + "test": "node backend/tests/chat-smoke.cjs" }, "keywords": [], "author": "",