Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules/
archive/
receipts/
workspace/
**/.agent-os/**
**/.venv/**
**/__pycache__/**

26 changes: 26 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}

2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
60 changes: 60 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.

22 changes: 22 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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.

32 changes: 27 additions & 5 deletions backend/mcp-server.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,44 @@ 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");
return abs;
}
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);
Expand Down
58 changes: 58 additions & 0 deletions backend/tests/chat-smoke.cjs
Original file line number Diff line number Diff line change
@@ -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');
}
})();

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down