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
21 changes: 17 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@
# ENABLED_TOOLS=chat,code,files,shell,git,memory,review,test,web_fetch,web_search,research
# WORKSPACE_DIR=data/workspace
# SHELL_TIMEOUT=30
# SHELL_ALLOWED_COMMANDS=ls,cat,head,tail,wc,grep,find,python3,pip,pytest
# Only parts[0] is checked, never the arguments -- so adding an
# interpreter (python3, pip, find, xargs, sh, git, sed, awk...) makes
# this list decorative: `python3 -c "..."` runs anything. That's a
# reasonable thing to do on a trusted local box, and a bad thing to do
# on anything reachable over WireGuard. A warning is logged at startup
# when one of them is present.
# SHELL_ALLOWED_COMMANDS=ls,cat,head,tail,wc,grep

# --- Test/lint tool ---------------------------------------------------------
# Separate allowlist from the general shell tool above, on purpose --
Expand Down Expand Up @@ -132,10 +138,17 @@
# SHOW_DEBUG=false

# --- API auth ---------------------------------------------------------------
# Empty = the HTTP API (/chat, /review, /run, /tools, /traces, /remember,
# /search) stays open.
# Set this before exposing forge-core beyond localhost or a trusted LAN.
# Bearer token required on /chat, /review, /run, /tools, /traces,
# /remember, /search, /history, /drawer, /compact.
# Forge REFUSES TO START if this is empty, unless you also set
# API_ALLOW_UNAUTHENTICATED=true below. That's deliberate: /chat
# dispatches whatever is in ENABLED_TOOLS, so an open instance is
# arbitrary tool execution for anyone who can reach the port.
# API_TOKEN=
#
# Set to true only for a genuinely local-only instance you want open on
# purpose. Never set it on anything reachable over WireGuard/LAN.
# API_ALLOW_UNAUTHENTICATED=false

# --- API rate limiting -------------------------------------------------------
# In-memory sliding window, per client IP, single-process only.
Expand Down
96 changes: 96 additions & 0 deletions deploy/compose.example.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Exemple de stack Forge complète — à COPIER et adapter, pas à lancer tel quel.
#
# cp deploy/compose.example.yaml compose.yaml # à la racine du repo
# # puis adapter les chemins ${HOME}/... et décommenter les montages
# # sysadmin une fois les proxies hôte en place (voir deploy/README.md)
#
# podman compose build forge
# podman compose up -d
#
# Pourquoi cet exemple vit DANS le dépôt, à la racine :
# `build: context: .` ne pointe correctement vers le Containerfile que
# si le compose est lu depuis la racine du repo. Un compose gardé hors
# dépôt avec un `context:` relatif fragile est précisément ce qui a fait
# servir une image `forge-core` gelée (ancien code) pendant tout un cycle
# de patch — `podman compose build` répondait "No services to build"
# faute de clé `build:`, donc l'image n'était jamais reconstruite.
#
# Seul `forge` a une clé `build:` : c'est le seul service dont le source
# vit dans ce dépôt (le Containerfile à la racine). `forge-embedding` et
# `forge-llm` sont des images llama.cpp construites séparément, et
# `searxng` est upstream — elles restent en `image:` sans build.

services:
searxng:
image: docker.io/searxng/searxng:latest
container_name: searxng
restart: unless-stopped
ports:
- "8888:8080"
volumes:
# SearXNG a besoin de "json" dans search.formats (voir config.py,
# section web_search) — désactivé par défaut en amont.
- ${HOME}/searxng-config/settings.yml:/etc/searxng/settings.yml:ro

forge-embedding:
image: forge-embedding # image llama.cpp embedding, construite séparément
container_name: forge-embedding
restart: unless-stopped
ports:
- "8082:8081"
volumes:
- ${HOME}/path/to/forge-embedding/models/:/models:Z

forge-llm:
image: forge-llm # image llama.cpp, construite séparément
container_name: forge-llm
restart: unless-stopped
devices:
- /dev/dri:/dev/dri # accès GPU (iGPU du Steam Deck)
annotations:
run.oci.keep_original_groups: "1"
ports:
- "8080:8080"
volumes:
- ${HOME}/path/to/forge-llm/models:/models
command:
- -m
- /models/VOTRE-MODELE.Q4_K_M.gguf
- --host
- 0.0.0.0
- --port
- "8080"
- -c
- "8192"
- -ngl
- "28"
- -t
- "8"

forge:
# La clé build est ce qui manquait : sans elle, compose ne reconstruit
# jamais l'image et `build` répond "No services to build".
build:
context: .
containerfile: Containerfile
image: forge-core
container_name: forge
restart: unless-stopped
annotations:
run.oci.keep_original_groups: "1"
ports:
- "8000:8000"
env_file:
# Copier depuis .env.example ; jamais commité. Doit contenir
# API_TOKEN (sinon Forge refuse de démarrer — voir audit C-1).
- .env.local
volumes:
- ./data:/app/data
# Montages sysadmin (v3.11) — à décommenter UNE FOIS les proxies
# hôte en place (journal en lecture seule, proxy D-Bus filtré,
# proxy podman read-only). Détails et mise en place complète :
# deploy/README.md. Les laisser actifs sans les proxies fait
# échouer le démarrage du container sur des chemins absents.
# - /var/log/journal:/host-journal:ro
# - ${XDG_RUNTIME_DIR}/forge-dbus-proxy:/run/forge-dbus-proxy:ro
# - ${XDG_RUNTIME_DIR}/forge-podman-ro-proxy.sock:/run/forge-podman-ro-proxy.sock:ro
87 changes: 81 additions & 6 deletions src/forge/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@

Auth: set API_TOKEN in the environment to require
`Authorization: Bearer <token>` on every endpoint except / and
/health. Unset (default) means the API stays open, unchanged from
before this was added.
/health. Leaving it unset no longer silently opens the API -- the app
refuses to start unless API_ALLOW_UNAUTHENTICATED=true says the open
posture is intentional. See check_auth_configuration() below.

Rate limiting: in-memory sliding window, per client IP, on every
endpoint except / and /health. RATE_LIMIT_REQUESTS per
Expand All @@ -33,6 +34,7 @@
import asyncio
import hmac
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Literal

Expand All @@ -41,10 +43,54 @@
from pydantic import BaseModel

from forge import rag, ratelimit, trace
from forge.config import API_TOKEN, FORGE_PROVIDER, LLAMA_CPP_URL, LLM_MODEL
from forge.config import (
API_ALLOW_UNAUTHENTICATED,
API_TOKEN,
FORGE_PROVIDER,
LLAMA_CPP_URL,
LLM_MODEL,
)
from forge.orchestrator import Orchestrator

app = FastAPI(title="Forge", version="3.3.0", docs_url="/docs")

class InsecureConfiguration(RuntimeError):
"""Raised at startup when the API would come up unauthenticated
without anyone having asked for that in writing."""


def check_auth_configuration() -> None:
"""
Refuse to start with no API_TOKEN unless API_ALLOW_UNAUTHENTICATED
is explicitly set.

/chat dispatches whatever is in ENABLED_TOOLS -- shell, files, test,
sysadmin -- and the container's CMD binds 0.0.0.0. So "no token" is
not a mild default: it's arbitrary tool dispatch for anyone who can
reach the port. Failing loudly at startup is the only version of
this warning that can't be scrolled past.

Reads the module globals rather than the config constants directly
so tests (and anything embedding the app) can patch them at the
same boundary the auth dependency already uses.
"""
if API_TOKEN or API_ALLOW_UNAUTHENTICATED:
return
raise InsecureConfiguration(
"refusing to start: API_TOKEN is unset, so /chat, /run and every "
"other endpoint would accept unauthenticated requests -- including "
"tool dispatch. Set API_TOKEN in .env.local, or set "
"API_ALLOW_UNAUTHENTICATED=true if this instance really is "
"local-only and you want it open on purpose."
)


@asynccontextmanager
async def lifespan(_app: FastAPI):
check_auth_configuration()
yield


app = FastAPI(title="Forge", version="3.3.0", docs_url="/docs", lifespan=lifespan)
_executor = ThreadPoolExecutor(max_workers=2)
_orchestrator = Orchestrator()

Expand Down Expand Up @@ -434,11 +480,40 @@ async def compact():
# ─── UI ────────────────────────────────────────────────────────────


# Third layer under the two fixes in static/index.html (quote escaping
# + link-scheme validation): defense in depth, not a replacement for
# them. 'unsafe-inline' is unavoidable for now -- the UI is a single
# self-contained file with inline <script>/<style> and onclick=
# attributes, deliberately (no CDN, must work offline). So this does
# NOT stop an inline XSS from running. What it does stop is the part
# that actually hurts: connect-src 'self' means injected script can't
# POST the localStorage API token to an attacker's host, and
# default-src 'self' blocks pulling a payload from anywhere external.
# Splitting the JS into its own static file would let 'unsafe-inline'
# go away entirely -- worth doing, but a bigger change than this fix.
_CSP = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"connect-src 'self'; "
"base-uri 'none'; "
"form-action 'none'; "
"frame-ancestors 'none'"
)
_UI_HEADERS = {
"Content-Security-Policy": _CSP,
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
}


@app.get("/", response_class=HTMLResponse)
async def ui():
static = Path(__file__).parent / "static" / "index.html"
if static.exists():
return HTMLResponse(static.read_text(encoding="utf-8"))
return HTMLResponse(static.read_text(encoding="utf-8"), headers=_UI_HEADERS)
return HTMLResponse(
"<h1>Forge UI not found</h1><p>Run from the installed package.</p>"
"<h1>Forge UI not found</h1><p>Run from the installed package.</p>",
headers=_UI_HEADERS,
)
55 changes: 54 additions & 1 deletion src/forge/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,56 @@ def _bool(name: str, default: str = "false") -> bool:

# --- Shell tool -------------------------------------------------------------
SHELL_TIMEOUT = int(os.getenv("SHELL_TIMEOUT", "30"))
_default_shell_cmds = "ls,cat,head,tail,wc,grep,find,python3,pip,pytest"
# The default no longer includes python3, pip or find. Each of those is
# an interpreter in its own right, so allowlisting it allowlists
# everything:
# python3 -c "import os; os.system(...)"
# pip install <url> (runs setup.py, plus network egress)
# find . -exec <anything> {} \;
# The allowlist only checks parts[0], never the arguments -- an
# allowlist containing an interpreter is not an allowlist.
#
# _SHELL_ALLOWLIST_DEFEATING below is the same idea generalised: adding
# any of those to SHELL_ALLOWED_COMMANDS is a decision to disable this
# protection, which is legitimate on a trusted local box and should be
# a deliberate act, not a default. tools/shell.py logs a warning at
# import when one is present, so the choice is visible in the logs.
_default_shell_cmds = "ls,cat,head,tail,wc,grep"
SHELL_ALLOWED_COMMANDS: set[str] = {
c.strip()
for c in os.getenv("SHELL_ALLOWED_COMMANDS", _default_shell_cmds).split(",")
if c.strip()
}
# Not exhaustive and can't be: this is a "you are switching the
# allowlist off" tripwire, not a blocklist. Anything that can execute
# an arbitrary argument belongs here.
_SHELL_ALLOWLIST_DEFEATING: set[str] = {
"awk",
"bash",
"env",
"find",
"gawk",
"git",
"less",
"man",
"more",
"nano",
"nc",
"perl",
"pip",
"pip3",
"python",
"python3",
"ruby",
"sed",
"sh",
"ssh",
"tar",
"vi",
"vim",
"xargs",
"zsh",
}

# --- Test/lint tool ----------------------------------------------------------
# Separate from SHELL_ALLOWED_COMMANDS on purpose: the test tool has its own
Expand Down Expand Up @@ -161,6 +205,15 @@ def _bool(name: str, default: str = "false") -> bool:
# -- /chat, /review, /run, /traces and /tools currently have zero
# protection otherwise.
API_TOKEN = os.getenv("API_TOKEN", "")
# Forge refuses to start with no API_TOKEN unless this is set to true.
# The old behaviour was "open by default, documented as risky" -- but a
# documented unsafe default is still an unsafe default, and this one is
# the kind you notice the day you add a published port to a compose
# file, not before. Flipping it means the risky configuration has to be
# written down in .env.local, where it's visible, instead of being what
# happens when you write nothing at all. Local-only development is a
# perfectly good reason to set it; forgetting isn't.
API_ALLOW_UNAUTHENTICATED = _bool("API_ALLOW_UNAUTHENTICATED", "false")

# --- API rate limiting ----------------------------------------------------
# In-memory sliding window, per client IP, single-process only (see
Expand Down
32 changes: 30 additions & 2 deletions src/forge/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -532,11 +532,23 @@
return working.replace(/\x00BLOCK(\d+)\x00/g, (_, i) => blocks[Number(i)])
}

// Only these schemes may ever become an href. escapeHtml() now closes
// the attribute-escape route, but a *syntactically valid* href is
// still dangerous on its own: `[clique ici](javascript:alert)` needs
// no quote at all, it just needs to be rendered as a link. Anything
// not matching is downgraded to plain text rather than dropped, so a
// legitimate-but-unusual URL is still readable instead of vanishing.
const _SAFE_LINK_SCHEME = /^(https?:\/\/|mailto:|\/|#)/i

function inlineMarkdown(s) {
return s
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
const href = url.trim()
if (!_SAFE_LINK_SCHEME.test(href)) return `${label} (${href})`
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>`
})
}

// ```diff blocks: colored like `git diff` instead of plain text, so a
Expand All @@ -552,8 +564,24 @@
return `<pre class="diff">${lines.join('\n')}</pre>`
}

// Quotes matter as much as angle brackets here: this output lands in
// ATTRIBUTE position in several places (inlineMarkdown's <a href="...">
// above, the trace/drawer template literals below), so escaping only
// &/</> leaves a straight attribute escape open. Concretely, before
// this: `[x](" autofocus onfocus="location=name)` rendered as
// <a href="" autofocus onfocus="location=name" ...> and executed with
// no interaction at all -- and the text reaching formatContent()
// includes tool output (web_fetch/research page content, files:read,
// sysadmin logs), so a hostile page was enough to reach it. The API
// token lives in localStorage, so that XSS was a token theft, not a
// cosmetic bug.
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}

function showThinking() {
Expand Down
Loading
Loading