Skip to content

feat(profile): relative paths in extends and save to an extended profile (#2065) - #2066

Open
ranguard wants to merge 27 commits into
nolabs-ai:mainfrom
ranguard:feat/relative-extends-2065
Open

ranguard wants to merge 27 commits into
nolabs-ai:mainfrom
ranguard:feat/relative-extends-2065

Conversation

@ranguard

@ranguard ranguard commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Linked Issue

Closes #2065

Summary

A profile can now extend another profile file by a path relative to itself, and the save-on-exit prompt can write grants into any writable profile file in the chain. One PR, two phases as separate commits.

Phase 1: relative extends paths, and two save fixes

  • extends entries that start with ./ or ../ are files relative to the declaring profile. They must end in .json/.jsonc. Entries starting with / or ~ are rejected. A path that lands in the pack store is rejected (extend packs by name). Path entries are rejected inside pack profiles, drafts and built-ins. Cycles are detected by canonical path.
  • One function (profile/extends_ref.rs::classify_extends_entry) owns classification and resolution. The resolver, CLI --extends, Claude-pack detection (walk_extends_chain), pack update hints and profile init --extends all use it.
  • CLI --extends ./x.json resolves against the cwd. CLI bases are now a separate resolved list, merged ahead of the profile's own bases, so they work with pack and built-in --profile values.
  • Fix: a --profile ./x.json file can now be updated on exit.
  • Fix: updating a profile keeps JSONC comments and layout. Updates go through jsonc-parser's cst feature, which only appends new entries and re-validates before the atomic write.

Phase 2: choose the save target

  • A loaded profile records the files it was built from (source_files, with kinds user / draft / project / pack). Pack files are never offered for saving.
  • With 2+ writable files, the save prompt shows a numbered menu in precedence order, top-level profile first. When no file is writable, "a new user profile" is offered, as today. When the only writable file is a base (for example a pack or built-in --profile with a CLI --extends file), the menu is shown so you can see and confirm the file.

Hardening. The exit save now writes into directories the sandboxed agent may be able to write to, so:

  • the temp file gets a random name and is created exclusively (O_EXCL);
  • just before writing, the target must still canonicalize to the exact file recorded at load, and it must be a regular file.

Behaviour changes worth knowing

  • The save question for a named user profile now shows the full path (profile '/…/mine.json') instead of the bare name.
  • A symlinked profile file is updated at its target. Before, the link was replaced with a regular file.
  • CLI --extends entries are validated before the profile loads, so a bad entry fails earlier, with the same error type.

Known follow-ups (not in this PR)

  • A small race remains between the write-time check and the rename. Closing it fully needs fd-based I/O (openat/O_NOFOLLOW).
  • profile promote (profile_cmd.rs atomic_write_file) still uses a predictable temp-file name. Under the default policy the agent can't reach it. It should share the hardened writer.
  • --profile ./a.json --extends ./a.json lists the same file twice in the menu (harmless).
  • .jsonc siblings are ignored by bare-name sibling lookup (pre-existing; out of scope per the spec).

Design spec and implementation plan: docs/superpowers/specs/2026-10-06-relative-extends-design.md, docs/superpowers/plans/2026-10-06-relative-extends.md.

Agent Disclosure

This PR was implemented by AI agents (Claude Code, Claude Opus), directed by the maintainer. Process:

  • A design spec, reviewed (pushback and alignment passes) and approved by the maintainer.
  • A 9-task plan. Each task was implemented test-first by a separate agent, then reviewed for spec compliance and quality.
  • A final whole-branch security and code review, a fix wave, and a re-review.

Files and sections consulted: AGENTS.md, CONTRIBUTING.md, neps/README.md (the maintainer confirmed the issue suffices and no NEP is needed), .github/pull_request_template.md, and the spec above.

The agents complied with the repository requirements:

  • DCO sign-off on every commit.
  • No production unwrap/expect.
  • NonoError for expected failures.
  • Paths compared with Path::starts_with on canonical paths.
  • Fail-closed handling throughout.
  • No change to Landlock/Seatbelt enforcement.

Test Plan

  • Unit tests:
    • classification and resolution: relative, parent, symlinked and cycle cases, each error message, and the pack, draft and built-in rules;
    • CLI bases;
    • source-file recording and precedence;
    • the CST writer, including equivalence with merge_profile_patch, conditional entries, null sections, and invalid files left unchanged;
    • save targets and the menu, with the prompt loops driven by scripted input;
    • write hardening: a planted temp symlink, a symlink redirect, a non-regular file, and a pack-store directory swap.
  • Integration tests through the binary (crates/nono-cli/tests/relative_extends.rs): profile show on path profiles, every documented error, run --dry-run --extends ./x.json from a different cwd, and profile init --extends ../base.json.
  • make check test test-doc audit (4025 passed) and make clippy (clean) on macOS. The only local failure was lint_docs, caused by an untracked local file outside the repo's content; make lint-docs passes on a clean checkout. Linux runs in CI.

Checklist

  • An issue exists and is linked above, or this is maintainer-directed routine work
  • All commits are signed-off, using DCO
  • Code changes follow the project's coding standards (AGENTS.md) and have appropriate test coverage
  • Public-facing changes are paired with documentation updates
  • If this PR implements a major feature, capability, or security-relevant change, a corresponding accepted NEP is linked (N/A: the maintainer confirmed the issue suffices)

🤖 Generated with Claude Code

ranguard and others added 26 commits October 6, 2026 10:12
Design for nolabs-ai#2065 phase 1: relative-path extends entries, plus save-on-exit
fixes for path profiles and comment-preserving updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
- CLI --extends bases stay out of the profile's own extends list
- save-target menu uses precedence order
- path-profile save matches the named-user-profile branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
…ai#2065)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
resolve_extends classifies each entry with classify_extends_entry
(File origin when the declaring file is known, Builtin otherwise) and
loads Path entries via parse_file_backed_profile as sibling-style
bases, so their own entries resolve against the canonical target's
directory. Cycle detection keys on ExtendsRef::visited_key, so the same
file reached by two spellings is one node.

ResolvedBase::Global now carries the pack-store profile path, so path
entries inside a pack base hit the pack-profile rule; built-ins carry
None and hit the built-in rule. resolve_extends gains a `leading`
parameter for pre-classified CLI bases (unused until the CLI change).

classify_extends_entry canonicalizes the declaring file before the
pack-store and draft checks so a non-canonical spelling cannot bypass
them. The duplicate name validation in load_base_profile_raw is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
CLI --extends entries are now classified up front with ExtendsOrigin::Cli
and passed to resolve_extends as a separate leading list, instead of being
spliced into the selected profile's own extends. Path entries resolve
against the current directory, so `--profile ./proj/agent.json --extends
./x.json` no longer picks up proj/x.json, and CLI paths work with pack and
built-in profiles, whose file-written path rules no longer see them.
Removes prepend_cli_extends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
…init (nolabs-ai#2065)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
…s-ai#2065)

Updating an existing profile from the save prompt re-serialized it with
serde_json, which dropped JSONC comments. Updates now edit the file's
concrete syntax tree (jsonc-parser `cst` feature) to append the patch's
grants, then re-parse the result as a profile before the atomic write.
An edit that fails, or yields an invalid profile, leaves the file
untouched. New profiles keep the serde_json writer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
…es (nolabs-ai#2065)

The comment-preserving profile update only counted plain strings as
already present, so a platform-conditional entry such as
{ "path": "/a", "when": "macos" } got an unconditional "/a" appended
beside it, widening the grant to every platform. Present entries are now
resolved with the same conditional deserializer profile loading uses.

"open_urls": null is a valid profile but made the update fail; a null
nullable section is now replaced with an object. "filesystem": null does
not load and still errors.

Update errors now name the profile file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
…#2065)

Profiles now carry the canonical files they were loaded from, tagged
User, Draft, Project or Pack, and the run threads the writable ones in
precedence order to the save offer. Nothing reads them yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
The exit save prompt now writes to the highest-precedence writable file
the session's profile was loaded from, so a --profile path (or a user
profile) is updated in place. A new user profile is offered only when no
writable file exists. The update prompt shows the full file path.

Recording a pack-store base now fails closed when its path cannot be
canonicalized, so it cannot be misclassed as a writable project file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
…ai#2065)

When the session ran with two or more writable profile files, the save
prompt now shows a numbered menu of them in precedence order, after the
interactive selector and in the text prompt. Enter picks the top-level
profile, a number picks a base, skip cancels, and invalid input re-prompts.

Before writing an existing profile file, the save re-canonicalizes the
target and refuses when it now resolves into the pack store, so a parent
directory swapped for a symlink during the run cannot redirect the save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
…#2065)

With several writable profile files, the save question no longer names
the top-level file before the menu lets the user pick another one. A
closed input (EOF) at the save-target menu now cancels instead of
choosing the first file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
…2065)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
…abs-ai#2065)

Create the save temp file with a random name and O_EXCL so a symlink
planted at a predictable name is never followed, and require the save
target to still canonicalize to the path recorded at load and to be a
regular file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
…ot writable (nolabs-ai#2065)

Record which source file is the top-level profile. Only that file is
labelled "this profile" in the save menu, and the menu is shown even
for a single target when that target is a base, such as a file added
with --extends under a pack or built-in profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
…only saves (nolabs-ai#2065)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
…e targets (nolabs-ai#2065)

profile init now reports the documented error for absolute and ~/
extends entries instead of "not found". Saving to a file target is
covered by a test of the extracted prepare step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
…ary (nolabs-ai#2065)

Integration tests for profile-file path entries, sibling lookup from a
path base, every documented classification error, CLI --extends resolving
against the cwd, and profile init with relative bases. NonoTest's
hermetic command is now public so profile subcommands without a typed
builder can run under the same isolated environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
…nolabs-ai#2065)

The save-target menu loop and the g/s/Enter question loop now take their
line I/O through a small PromptLines trait. Production passes TtyPrompt,
which calls the same prompt_write/prompt_println/read_input_line as
before, so text, termios handling and EOF semantics are unchanged.
Tests drive both loops with scripted input: help line on invalid input,
Enter picks file 1, EOF and skip cancel, a lone base still gets the
menu, and the question maps g/s/Enter and re-prompts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
…host patch (nolabs-ai#2065)

A top-level pack profile records one Pack source file and offers no
writable save target; a .jsonc user profile records a top-level User
source; load_profile_extends_resolved drops entries that fail to
classify and keeps the rest; the CST writer creates a missing open_urls
section for allow_localhost and agrees with merge_profile_patch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

PR Review Summary

Size

Metric Value
Lines added +3830
Lines removed -275
Total changed 4105
Classification Large (> 300 lines)

Affected crates

  • crates/nono-cli — CLI changes. Verify argument parsing, flag documentation, and UX behaviour across supported platforms.

Blast radius — Moderate

This PR touches: source code,documentation


Updated automatically on each push to this PR.

@nogent-nolabs-ai nogent-nolabs-ai Bot 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.

nogent code review

1 high-severity security issue found regarding missing target verification/hardening before writing.

Findings (not tied to a changed line):

  • 🔒 [HIGH · security] crates/nono-cli/src/profile_save_runtime.rs:862 — The target path verification and regular-file check before writing are missing. The PR description/design spec states that 'just before writing, the target must still canonicalize to the exact file recorded at load, and it must be a regular file' to prevent sandboxed agents from planting symlinks or hijacking paths. However, write_profile and atomic_write perform no such verification. To resolve this, canonicalize the path just before writing, compare it to the expected canonicalized file recorded at load time, and verify that the target is a regular file using std::fs::metadata (or similar POSIX checks) to prevent TOCTOU and symlink exploitation.

Automated code + security review. CI already covers clippy, rustfmt, tests, cargo-audit and commit-lint.

@ranguard

ranguard commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

I've left all my docs and specs and the individual commits in for reference - happy for feedback and I can then remove those and squash down if / when approved

The write-time check ran only in prepare_profile_save_to_file, leaving a
window before the write and letting any other caller of write_profile skip
it. write_profile now repeats the check for updates. Raised in PR review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Leo Lapworth <leo@cuckoo.org>
@ranguard

ranguard commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the nogent finding in 475159d: the target check (re-canonicalize, must equal the path recorded at load, must be a regular file) already ran when the save was prepared, but write_profile itself didn't repeat it, so there was a window between prepare and write, and any other caller could skip it. write_profile now repeats the check for updates, with a test that swaps the file for a symlink between prepare and write.

On the failing Integration (ubuntu-latest) job: the child tool boundaries suite dies at the start of its Command Sandbox section without printing PASS/FAIL. The same failure at the same point happened on #2056 (a Cargo.lock-only dependabot bump), and the suite passes when run on Linux locally with this branch, so it looks like a pre-existing intermittent failure rather than something this PR introduces.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Profiles: relative paths in extends, and save-on-exit to an extended profile

1 participant