Skip to content
Open
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
262 changes: 246 additions & 16 deletions docs/config.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion docs/cron.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ git-syncable workspace subtree):
> `<workspace>/config/cron/`. Installs that still have the legacy
> `~/.nerve/cron/` keep working: if the workspace location doesn't exist yet,
> Nerve reads from `~/.nerve/cron/` automatically. You can also pin the paths
> explicitly with `cron.jobs_file` / `cron.system_file` / `cron.gate_plugins_dir`.
> explicitly with `cron.jobs_file` / `cron.system_file` / `cron.gate_plugins_dir`
> — except under [lockdown](config.md#lockdown-remote-only-read-only), where all
> three must resolve inside `<workspace>/config/`. An escaping path is ignored in
> favour of the in-workspace default; if the default is what escapes (a symlinked
> `config/cron` or `config/cron/gates`) the instance refuses to start.

Both files use the same format. On startup, CronService loads and merges both:
- If a job ID appears in both files, the **user version wins** (with a warning in the log).
Expand Down
65 changes: 65 additions & 0 deletions nerve/agent/backends/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,42 @@ def _translate_tool_result(
)


def lockdown_denial(tool_name: str, tool_input: dict[str, Any]) -> str | None:
"""Why a locked instance refuses this file-writing tool call, if it does.

A locked instance promises that the config it runs came from a reviewed,
merged change. The write guards on the REST and MCP surfaces enforce that for
callers coming in over the network; the agent's own ``Write``/``Edit`` did not
go through any of them, and every non-interactive tool here is auto-approved.
This is where that gap closes.

**Scope, precisely.** It covers the tools that name a path — ``Write``,
``Edit``, ``NotebookEdit`` — and nothing else. It is emphatically not a
sandbox: ``Bash`` is auto-approved on the same code path, so an agent that
means to write ``<workspace>/config/`` can still do it through a shell. That
is a known and documented limitation. Filtering command strings is not the
answer — quoting, ``sh -c``, redirection, ``python -c``, ``tee`` and every
editor defeat it — and a filter that looks like a boundary without being one
is worse than a gap someone can read about. Real sandboxing is separate work.

What this does buy is that the ordinary way an agent edits a file no longer
silently rewrites the config the box is promising to run, and that the refusal
says what to do instead.
"""
if tool_name not in FILE_MODIFY_TOOLS:
return None
path = tool_input.get("file_path") or tool_input.get("notebook_path")
if not path:
return None
from nerve.config import tracked_config_write_refusal

try:
return tracked_config_write_refusal(path)
except Exception as e: # noqa: BLE001 — a broken guard must not kill the turn
logger.warning("lockdown write check failed for %r: %s", path, e)
return None


# ------------------------------------------------------------------ #
# can_use_tool adapter #
# ------------------------------------------------------------------ #
Expand All @@ -241,6 +277,18 @@ async def can_use_tool(
context: ToolPermissionContext,
) -> PermissionResult:
hub = self._hub
# Checked before the snapshot: a refused write has nothing to snapshot,
# and marking it snapshotted would suppress the snapshot of a later,
# allowed write to the same path.
denial = lockdown_denial(tool_name, tool_input)
if denial:
logger.info(
"Session %s: denying %s under lockdown (%s)",
hub.session_id, tool_name,
tool_input.get("file_path") or tool_input.get("notebook_path"),
)
return PermissionResultDeny(message=denial)

# Capture pre-execution file snapshot for diff tracking
# (also done via PreToolUse hook in the backend as primary path).
if hub.snapshot_fn and tool_name in FILE_MODIFY_TOOLS:
Expand Down Expand Up @@ -712,10 +760,27 @@ async def _grant_permission_hook(hook_input, tool_use_id, context):
approved via ``can_use_tool``, which never fires for them,
so the exclusion protected nothing. The image validator
still runs for Read and its deny wins over this allow.

The lockdown check is repeated here rather than left to
``can_use_tool``: for a background sub-agent this hook is the only
thing that runs, so the allow issued here is the entire decision.
It only ever refuses the path-naming write tools, so the Read
parity above is unaffected by it.
"""
tool_name = hook_input.get("tool_name", "")
if tool_name in INTERACTIVE_TOOLS:
return {"hookSpecificOutput": {"hookEventName": "PreToolUse"}}
denial = lockdown_denial(
tool_name, hook_input.get("tool_input", {}) or {},
)
if denial:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": denial,
}
}
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
Expand Down
42 changes: 39 additions & 3 deletions nerve/agent/backends/codex/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -1479,9 +1479,12 @@ async def _handle_server_request(self, method: str, params: dict) -> dict:
"command_approval", self._approval_payload(params),
)
if method == "item/fileChange/requestApproval":
return await self._approval(
"file_approval", self._approval_payload(params),
)
payload = self._approval_payload(params)
refusal = self._lockdown_refusal(payload)
if refusal:
logger.warning("codex: declining file change — %s", refusal)
return {"decision": "decline"}
return await self._approval("file_approval", payload)
if method == "item/permissions/requestApproval":
# The response type requires a constructed GrantedPermissionProfile
# — there is no decline variant to express. Unsupported in v1
Expand All @@ -1507,6 +1510,39 @@ async def _handle_server_request(self, method: str, params: dict) -> dict:
logger.warning("codex: unknown server request %s — empty reply", method)
return {}

def _lockdown_refusal(self, payload: dict) -> str | None:
"""Why a locked instance declines this file change, if it does.

**This is a partial control and the shape of the gap matters.** Codex's
sandbox is a mode — ``read-only`` / ``workspace-write`` /
``danger-full-access`` — not a path list, so there is no way to express
"everything except ``<workspace>/config/``" to the app-server. The one
in-band hook is this approval request, and it only fires when
``approval_policy`` asks for approvals; nerve ships ``never`` with
``danger-full-access``, so under the default configuration codex never
asks and this never runs. Wired up anyway, because an operator who does
run codex with approvals should get the same answer the Claude backend
gives, and because a control that exists is easier to reach for than one
that has to be invented later. Documented as a gap rather than presented
as a boundary.
"""
from nerve.config import tracked_config_write_refusal

item = payload.get("item")
changes = self._changes(item) if isinstance(item, dict) else []
for change in changes:
path = str(change.get("path") or "")
if not path:
continue
try:
refusal = tracked_config_write_refusal(path)
except Exception as e: # noqa: BLE001 — never break the turn
logger.warning("lockdown write check failed for %r: %s", path, e)
continue
if refusal:
return refusal
return None

def _approval_payload(self, params: dict) -> dict:
"""Attach the originating item's context (the raw request carries
only ids/reason — the UI wants the command / changed files)."""
Expand Down
15 changes: 15 additions & 0 deletions nerve/agent/tools/handlers/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
TASK_UPDATE_SCHEMA,
TASK_WRITE_SCHEMA,
)
from nerve.config import ensure_path_not_tracked_config
from nerve.db.task_statuses import (
DEFAULT_STATUS,
STATUS_NAME_RE,
Expand Down Expand Up @@ -315,6 +316,7 @@ async def task_update_handler(ctx: ToolContext, args: dict) -> ToolResult:

if ctx.workspace and (note or deadline or raw_tags or new_title):
file_path = ctx.workspace / task["file_path"]
ensure_path_not_tracked_config(file_path, "write")
if file_path.exists():
content = await asyncio.to_thread(
file_path.read_text, encoding="utf-8",
Expand Down Expand Up @@ -371,6 +373,11 @@ async def task_read_handler(ctx: ToolContext, args: dict) -> ToolResult:

if ctx.workspace:
file_path = ctx.workspace / task["file_path"]
# Deliberately unguarded. Lockdown makes the tracked config subtree
# unwritable, not unreadable — it arrives from a repo the agent can
# already read, and the HTTP GET for a task doesn't guard either.
# Guarding here would refuse a read with a "Cannot write" message
# the caller has no way to act on.
if file_path.exists():
content = await asyncio.to_thread(
file_path.read_text, encoding="utf-8",
Expand Down Expand Up @@ -404,6 +411,7 @@ async def task_write_handler(ctx: ToolContext, args: dict) -> ToolResult:
return ToolResult.text("Workspace not configured.")

file_path = ctx.workspace / task["file_path"]
ensure_path_not_tracked_config(file_path, "write")
await asyncio.to_thread(file_path.write_text, new_content, encoding="utf-8")

from nerve.tasks.models import (
Expand Down Expand Up @@ -441,6 +449,13 @@ async def task_done_handler(ctx: ToolContext, args: dict) -> ToolResult:
if not task:
return ToolResult.text(f"Task not found: {task_id}")

# Done is a write like any other — it copies the file into done/ and
# unlinks the source, so a stored ``file_path`` inside the tracked config
# subtree would delete config. Refuse before the status flip, or a
# refusal leaves a task marked done whose file never moved.
if ctx.workspace:
ensure_path_not_tracked_config(ctx.workspace / task["file_path"], "move")

await ctx.db.update_task_status(task_id, "done")

# Mark any implementing plans for this task as done
Expand Down
56 changes: 56 additions & 0 deletions nerve/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,18 @@
WORKSPACE_INCLUDE_DIRS: tuple[str, ...] = ("config", "memory", "scripts", "skills")
WORKSPACE_INCLUDE_FILE_GLOBS: tuple[str, ...] = ("*.md",)

# What restore may write back, which is deliberately *not* what backup collects.
# The asymmetry is the point, and it is one directory wide: `config` is captured
# so a lost machine loses nothing, and never written back, because the authority
# for tracked config is the git remote rather than a tarball. Writing it back
# would replace reviewed config on a locked instance, and on any instance leave
# the checkout dirty enough that the next sync refuses to merge.
#
# These must stay separate constants. Defining the restore side as "whatever
# backup collects" is what silently opened that hole the moment `config` was
# added above — one edit, two opposite meanings.
WORKSPACE_RESTORE_DIRS: tuple[str, ...] = ("memory", "scripts", "skills")

# Directory names always pruned while walking the included workspace dirs.
_PRUNE_DIR_NAMES: frozenset[str] = frozenset({
".git", "node_modules", ".venv", "venv", "__pycache__",
Expand All @@ -117,6 +129,30 @@
# missed something (e.g. a build artifact landed under memory/).
WORKSPACE_WARN_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB


def _restorable_workspace_path(rel: Path) -> bool:
"""Whether a workspace path from a bundle may be written back on restore.

Restore applies :data:`WORKSPACE_RESTORE_DIRS`, not the collect-side
allowlist. Bundles this module writes *do* carry ``config/`` — deliberately,
so a lost machine loses no settings or schedule — and a bundle it is handed
can carry anything at all. Either way, writing ``config/settings.yaml`` or
``config/cron/gates/x.py`` back would let a tarball replace the git-tracked
config subtree: on a locked instance that displaces the reviewed remote
config the whole mode exists to guarantee, and on any instance it leaves the
checkout dirty enough that the next sync refuses to merge.

So the config subtree travels in the bundle and is never written back. It is
still there to be read out and applied through review if a restore really
needs it, which the skip warning says.
"""
import fnmatch

parts = rel.parts
if len(parts) == 1:
return any(fnmatch.fnmatch(parts[0], g) for g in WORKSPACE_INCLUDE_FILE_GLOBS)
return parts[0] in WORKSPACE_RESTORE_DIRS

# Retention prune only ever touches files matching this exact pattern, so it
# can never delete an unrelated file that happens to share the directory.
# An optional ``-<n>`` disambiguates bundles created within the same second.
Expand Down Expand Up @@ -876,13 +912,33 @@ def restore_bundle(
staged_ws = staging / "workspace"
if staged_ws.is_dir():
workspace.mkdir(parents=True, exist_ok=True)
refused: list[str] = []
for src in sorted(staged_ws.rglob("*")):
if not src.is_file():
continue
rel = src.relative_to(staged_ws)
if not _restorable_workspace_path(rel):
refused.append(str(rel))
continue
dst = workspace / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
if refused:
logger.warning(
"Restore: skipped %d workspace file(s) not restorable (%s): "
"%s. The config subtree is intentionally among these — it "
"travels in the bundle but is never written back, because "
"tracked config comes from the git remote. Read it out of "
"the bundle and apply it through review if you need it.",
len(refused), ", ".join(WORKSPACE_RESTORE_DIRS),
", ".join(refused[:10]),
)
report.warnings.append(
f"skipped {len(refused)} workspace file(s) this bundle "
f"carries that restore does not write back (the config "
f"subtree is deliberately one of them) — including "
f"{refused[0]!r}"
)

logger.info("Restore complete: %s → %s", path.name, nerve_dir)
return report
Expand Down
19 changes: 19 additions & 0 deletions nerve/channels/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -1023,10 +1023,29 @@ async def _handle_pair(self, update: Update, context: Any) -> None:
)
return

# Refuse to pair under lockdown — allowed users must come from the
# tracked remote config, not a local runtime edit. Check BEFORE the
# in-memory add so lockdown isn't bypassed for the current run.
from nerve.config import LockdownError, is_locked

if is_locked():
await update.message.reply_text(
"This instance is locked (remote-only). Add your Telegram user "
"to telegram.allowed_users in the workspace config repo instead."
)
return

# Success: authorize in memory and persist to config.local.yaml
self._allowed_users.add(user_id)
try:
append_telegram_allowed_user(self.config.config_dir, user_id)
except LockdownError:
# Belt-and-suspenders: config was locked between the check and here.
self._allowed_users.discard(user_id)
await update.message.reply_text(
"This instance is locked (remote-only) — pairing is disabled."
)
return
except Exception:
logger.exception("Paired user %d but failed to persist to config", user_id)
await update.message.reply_text(
Expand Down
25 changes: 23 additions & 2 deletions nerve/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,19 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s
lines.append("[--] Anthropic API key not set (using proxy)")
elif config.anthropic_api_key:
lines.append(f"[OK] Anthropic API key: ...{config.anthropic_api_key[-4:]}")
elif config.lockdown:
# The generic message names config.local.yaml, which a locked instance
# never reads. The cause is also often upstream of the key: if the
# provider block was only in config.yaml, this instance has already
# reverted to the Anthropic default and is failing for a key it would not
# otherwise need.
errors.append(
"[ERR] No Anthropic API key. Lockdown does not read "
"config.local.yaml, so supply it as ${ANTHROPIC_API_KEY} in "
"workspace/config/settings.yaml. If this box should be using "
"Bedrock, provider.type is absent from the tracked settings and this "
"instance has fallen back to 'anthropic'"
)
else:
errors.append("[ERR] Anthropic API key not set and proxy not enabled (config.local.yaml)")

Expand Down Expand Up @@ -1239,6 +1252,7 @@ def config_sync(
config.workspace, ctx.obj["config_dir"], branch=branch,
validate=not no_validate,
strict_env=config.workspace_sync.strict_env and not no_strict_env,
locked=config.lockdown,
)
for warning in result.validation_warnings:
click.secho(f" [WARN] {warning}", fg="yellow")
Expand Down Expand Up @@ -1273,10 +1287,17 @@ def config_sync(
help="Ignore this machine's config.yaml / config.local.yaml and validate "
"only the portable workspace config — what a shared repo carries.",
)
@click.option(
"--assume-lockdown", "assume_locked", is_flag=True,
help="Validate the locked view whatever this bundle's lockdown flag resolves "
"to here. A fleet repo writes `lockdown: ${NERVE_LOCKDOWN:-false}`, so "
"CI resolves it to false and checks a config no locked box will run. Use "
"this in CI on any repo one of whose instances is locked.",
)
@click.pass_context
def config_validate(
ctx: click.Context, workspace: str | None, strict_env: bool, strict_keys: bool,
portable_only: bool,
portable_only: bool, assume_locked: bool,
) -> None:
"""Validate the configuration bundle. Non-zero exit on any error (CI-ready)."""
from nerve.config_validate import validate_config_bundle
Expand All @@ -1285,7 +1306,7 @@ def config_validate(
result = validate_config_bundle(
config_dir, workspace_override=workspace,
strict_env=strict_env, strict_keys=strict_keys,
portable_only=portable_only,
portable_only=portable_only, assume_locked=assume_locked,
)
for msg in result.info:
click.echo(f"[info] {msg}")
Expand Down
Loading
Loading