Skip to content

Self-modification via PR (propose_config_change + nerve-workspace skill) (11/13) - #214

Open
alex-clickhouse wants to merge 4 commits into
config-refactor/10-lockdownfrom
config-refactor/11-self-modify
Open

Self-modification via PR (propose_config_change + nerve-workspace skill) (11/13)#214
alex-clickhouse wants to merge 4 commits into
config-refactor/10-lockdownfrom
config-refactor/11-self-modify

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What

propose_config_change, plus a nerve-workspace skill that tells the agent how to use it. Under lockdown the agent cannot write tracked config directly, so it opens a pull request against the workspace repo instead: branch, commit, push, PR. Unlocked installs keep editing directly.

Proposals are confined to the reviewed-config surface and classified by effect rather than by file extension, and the destination path is resolved before containment is checked.

Why

Lockdown removes the agent's ability to change its own configuration, which is the intent — but with nothing in its place, the agent's only recourse is to ask a human to make the edit, and the useful half of self-modification is lost along with the dangerous half. A pull request keeps the capability and puts a review in front of it.

Classifying by effect rather than extension is the security-relevant part. A gate plugin is executable code regardless of what it is called, so a proposal touching one is a different kind of change from a schedule edit, and deciding that from the filename would be trivially avoidable.

Resolving the destination before checking containment is the ordering that makes traversal detectable: checking a path that still contains .. tests the string rather than the location it names.

Added in review

Proposals targeted the wrong branch. propose_config_change always used the
remote's default branch, while sync fetches origin/<workspace_sync.branch>, so
an instance following staging opened its PR against main. This PR's own
earlier commit stated the invariant the code broke: "the proposal has to target
the branch sync pulls from."

Worse than useless, because the agent submits the full content of a file it
read in the synced tree. Staged over a different branch's revision, that reverts
whatever the other branch holds and the synced one does not — as a deletion in
the diff, under a title saying the change adds a setting.

base is now threaded through the fetch, the worktree and gh pr create --base
as one resolved name. An empty setting still falls back to origin's default
rather than the local checkout, since a detached HEAD — what sync leaves behind
while validating a revision — has no business choosing what a PR targets.

Validation ran with the machine-local layers overlaid, so a local value
masked an invalid proposed one; the suite already pinned that masking in
test_machine_override_masks_a_shared_error_by_default. A proposal changes the
shared layer and is now judged as one. The comment explaining the lenient choice
covered --strict-keys only and never the layer question. portable_only also
makes an empty portable bundle an error, which is right for a CI gate and wrong
here — a proposal touching only skills/ cannot satisfy it — so that one error is
tolerated, with a test pinning its wording.

The skill now tells the agent to route reviewed-surface changes through a PR on
any box with a remote
, not just a locked one. It said "prefer a PR. Nothing
forces it," which reads oddly to an agent holding a skill_create tool that
writes the file directly. Sync refuses to merge while the reviewed surface has
uncommitted local state, so a direct write stops config reaching the instance
until it is committed or dropped, whereas propose_config_change stages in an
isolated worktree and leaves the tree clean. Still a divergence argument, not a
permission one.

🤖 Generated with Claude Code

@alex-clickhouse alex-clickhouse changed the title Self-modification via PR (propose_config_change + nerve-workspace skill) Self-modification via PR (propose_config_change + nerve-workspace skill) (11/13) Jul 28, 2026
@alex-clickhouse
alex-clickhouse requested a review from Copilot July 28, 2026 10:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a “self-modification via PR” path so the agent can propose reviewed workspace-config changes (skills/cron/settings/instruction files) by staging them in an isolated git worktree, validating, pushing a branch, and opening a PR via gh. This supports lockdown mode where direct edits to tracked config are blocked.

Changes:

  • Introduces nerve.config_pr.propose_config_change with path-surface allowlisting, traversal/symlink containment guards, validation-before-PR, and executable-effect announcements.
  • Adds an agent tool (propose_config_change) with schema + handler, and registers it in the default tool registry.
  • Adds extensive unit + integration tests and a new nerve-workspace skill documenting the workflow (plus a pointer from nerve-dev).

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/test_config_pr.py Comprehensive tests for PR-based config proposals, including real-git integration cases and safety/effect announcements.
nerve/config_pr.py Implements PR proposal workflow: path/scope guards, worktree staging, validation, push, and PR creation via gh.
nerve/agent/tools/handlers/config_pr.py Tool handler + spec for propose_config_change, running the workflow off-thread and formatting agent-facing output.
nerve/agent/tools/schemas.py Adds JSON schema for propose_config_change tool inputs.
nerve/agent/tools/handlers/__init__.py Registers config PR tool specs into the default tool registry.
nerve/templates/skills/nerve-workspace/SKILL.md New skill instructing the agent to always propose workspace config via PR (esp. under lockdown) and clarifying scope.
nerve/templates/skills/nerve-dev/SKILL.md Directs “config changes” requests to the nerve-workspace skill instead of app-code development.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread nerve/config_pr.py Outdated
Comment thread nerve/config_pr.py Outdated
Comment thread nerve/config_pr.py
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch from ffc39b8 to f85e621 Compare July 28, 2026 12:11
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch from f85e621 to abfd8e9 Compare July 28, 2026 12:36
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch from abfd8e9 to f60b5a3 Compare July 28, 2026 13:15
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch from f60b5a3 to fa47c06 Compare July 28, 2026 14:23
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch from fa47c06 to f8ace59 Compare July 28, 2026 14:54
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch from f8ace59 to da545bb Compare July 28, 2026 15:34
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch 2 times, most recently from a505bc8 to 86739f0 Compare July 28, 2026 16:20
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch from 86739f0 to 7af2ebc Compare July 28, 2026 18:40
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch from 7af2ebc to a6da8ad Compare July 29, 2026 08:29
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch from a6da8ad to ee9a90e Compare July 29, 2026 09:18
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch from ee9a90e to 486db4c Compare July 29, 2026 09:31
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch from 486db4c to c3335b4 Compare July 29, 2026 10:31
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch from c3335b4 to 87672ab Compare July 29, 2026 13:28
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch 2 times, most recently from d90e6cc to 83ecee6 Compare July 30, 2026 08:25
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch 2 times, most recently from ba04b55 to 737e878 Compare July 30, 2026 09:04
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch from 737e878 to 1a8b5c1 Compare July 30, 2026 09:48
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch from 1a8b5c1 to 7ebd10e Compare July 30, 2026 10:06
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review July 30, 2026 11:21
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch from a4880e4 to ca583a9 Compare July 31, 2026 08:19
alex-clickhouse and others added 4 commits July 31, 2026 10:29
Under lockdown the agent cannot edit tracked config directly, so it proposes:
branch, commit, push, and open a PR against the workspace repo where a human
reviews it. Unlocked instances keep editing directly. This ships as its own skill
so the existing app-codebase workflow is untouched, with a one-line pointer added
to it.

Proposals are restricted to the reviewed-config surface and classified by effect,
not by file extension — the question is what a file causes the daemon to run, not
what it is named. Path traversal is refused and the branch ref is not leaked. A
proposal is based on the remote's default branch rather than local HEAD, so it
cannot carry unreviewed local commits into the very PR meant to get them
reviewed, and it can commit on a box with no git identity configured.

The reviewer notice flags a change to lockdown by diffing the proposal against the
current tracked file rather than by presence: presence would flag every proposal
from a locked instance for its own unchanged flag, and a notice that always fires
teaches reviewers to skip it. Keys that are dangerous whenever they appear stay on
presence; keys that are dangerous when they change are diffed. The split is
commented so it is not unified back into a bug.

Co-Authored-By: Claude <noreply@anthropic.com>
…leniently

Three from the review, and the case the first one made visible.

Require an `origin` remote up front rather than "a remote". Everything downstream
names origin — the ls-remote, the fetch, the branch the worktree comes from, the
push — as does the sync that pulls a merged change back, which has no remote
setting either. So a workspace whose only remote is `upstream` passed the check
and was then told origin was unreachable, and prescribed a `git remote set-head
origin -a` that fails for the same reason. Following that name here instead would
be worse: the proposal has to target the branch sync pulls from, so the PR would
be one that can never reach the instance.

Stage through a helper that falls back to $TMPDIR. The mkdtemp sat outside the
try, so an unwritable workspace parent raised PermissionError out of a function
documented "Never raises". Beside the workspace is still the preference — an
untracked directory inside it would show up in the status ff-only sync reads — but
the parent being writable is an assumption nothing else makes, since sync needs
the workspace itself and no more. A workspace provisioned into a root-owned
directory with the daemon unprivileged is an ordinary locked-fleet layout.

Decode gh's output as UTF-8 with errors="replace" and catch broad Exception, as
sync_service._git already does. errors="strict" is the default under every codec,
so this was not a C-locale edge case: one byte gh relayed from elsewhere raised.
It raised after the push, which replaced "branch pushed but 'gh pr create' failed
… Open the PR manually" with an internal error naming a codec, leaving a pushed
branch with nothing pointing at it.

The origin check then made the local case visible. The nerve-workspace skill
offered the PR route and only that route — "always propose", "don't edit tracked
config files directly", "don't try to bypass it by editing files directly" —
while being installed by every `nerve init`, which never git-inits the workspace.
An unlocked instance with a local workspace was told to always propose, refused
by the tool, and told not to edit: it would report that its config could not be
changed on a box where writing the file is both allowed and correct. The refusal
now says which situation it is, split by lockdown in the handler because that is
the layer told whether the instance is locked, and the skill establishes whether
the workspace is shared before choosing a route.

Co-Authored-By: Claude <noreply@anthropic.com>
…lone

Two defects in `propose_config_change`, both reproduced.

It always targeted the remote's default branch, while sync fetches
`origin/<workspace_sync.branch>`. An instance following `staging` opened
its PR against `main`. The commit below this one already stated the
invariant its own code broke: "the proposal has to target the branch sync
pulls from."

Worse than useless, because the agent submits the *full content* of a
file it read in the synced tree. Staged over a different branch's
revision, that reverts whatever the other branch holds and the synced one
does not — appearing as a deletion in the diff, under a title saying the
change adds a setting:

     timezone: UTC
    -log_level: DEBUG      <- main's line, silently reverted
    +model: sonnet

`base` is now threaded through the fetch, the worktree and `gh pr create
--base` as one resolved name, passed from `workspace_sync.branch`. An
empty setting still falls back to origin's default rather than the local
checkout: sync follows the upstream there, but a detached HEAD — which is
what sync itself leaves behind while validating a revision — has no
business choosing what a pull request targets.

Validation ran with the machine-local layers overlaid, so a local value
masked an invalid proposed one; the suite already pinned that masking in
`test_machine_override_masks_a_shared_error_by_default`. A proposal is a
change to the shared layer and is now judged as one. The comment
explaining the lenient choice covered `--strict-keys` only and never the
layer question.

`portable_only` also makes an empty portable bundle an error, which is
right for a CI gate and wrong here: a proposal touching only `skills/` on
a workspace with no `config/` cannot contain anything that would satisfy
it. That one error is tolerated, matched by its leading phrase, with a
test pinning the phrase so a rewording fails loudly rather than quietly
refusing valid proposals again.

Co-Authored-By: Claude <noreply@anthropic.com>
The skill said "prefer a PR. Nothing forces it," which understates what
now happens and reads oddly to an agent holding a `skill_create` tool
that writes the file directly.

Sync refuses to merge while the reviewed surface has uncommitted local
state, so a direct write to `config/`, `skills/` or a root instruction
file leaves the workspace diverged from the reviewed revision and stops
the instance receiving config changes until it is committed or dropped.
`propose_config_change` stages in an isolated worktree, so the PR route
leaves the live tree clean — it is the route that keeps sync working, not
merely the tidier one.

Still a divergence argument, not a permission one: on a box with a remote
but no lockdown the agent may write directly, and the point is what that
costs. The no-remote case is unchanged, where direct edits are correct
and a PR has nothing to target.

Co-Authored-By: Claude <noreply@anthropic.com>
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/11-self-modify branch from ca583a9 to b5a7eec Compare July 31, 2026 08:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants