Skip to content

feat(share): add explicit parent-root grants for project admission - #1336

Draft
skygunner wants to merge 6 commits into
yyjeqhc:mainfrom
skygunner:contrib/share-parent-root-grants-20261028
Draft

skygunner wants to merge 6 commits into
yyjeqhc:mainfrom
skygunner:contrib/share-parent-root-grants-20261028

Conversation

@skygunner

@skygunner skygunner commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A project-scoped share (webcodex share --auth oauth) is fenced to the exact Project path that was registered. A repository cloned after the share starts, or a sibling repository the user wants to open, is denied with project_registry_scope_denied and has to be registered out of band. Widening the Runner's filesystem policy does not solve this: Runner execution roots must not become Project-registry authority.

User-visible change

webcodex share --auth oauth now accepts repeatable --project-roots <DIR> (up to 8) and --no-project-roots.

  • New Projects on demand: an existing directory under a granted canonical parent root can be opened through the normal work_on_project entry point, including managed worktrees, without per-project registration.
  • Durable updates: the grant is persisted next to the protected share OAuth client state. Re-sending the same list is a no-op, a changed list atomically replaces the previous one (a failed update preserves the old value), omitting the flag restores the saved list, and --no-project-roots removes it so removed roots cannot revive after a restart.
  • Default unchanged: without the flag, project-scoped credentials keep the exact-path fence and existing behaviour.

Authority and binding model

  • The grant lives on the project-share OAuth subject (AuthContext::project_root_scopes), populated from the Server's WEBCODEX_OAUTH2_PROJECT_SHARE_ROOTS; it is never a Runner filesystem policy.
  • Roots must be absolute, existing directories, are canonicalized at share start, deduplicated and bounded to 8. No grant entry is created.
  • Server-side containment is component-wise on the canonical target: .. spellings that resolve inside are fine, while textual-prefix siblings (<root>-extra), symlink escapes, relative/empty entries and missing directories are denied. The Runner's allowed_roots stays an independent execution constraint.
  • The Server forwards the canonical target plus the granted canonical roots. Before its first registry write or Git side effect, the Runner re-derives containment from its own filesystem resolution of that committed target. A target that resolved differently at the Server fence and at the Runner write boundary fails closed.
  • After a successful project op, the Server re-verifies the Runner's returned path against the grant. state_changed follows the Runner's registered flag; missing or malformed effect metadata returns operation_indeterminate rather than claiming no change.
  • For managed worktrees the grant binds the source only. The derived sibling destination is a Runner-owned namespace governed by allowed_roots. Source identity anchors (project id plus root fingerprint) are validated before resume is considered, so retries and resumes neither widen nor duplicate authority.
  • Malformed, empty or relative project_root_scopes arrays fail closed in both request handlers. No parallel project-registration protocol is introduced; admission uses the existing idempotent project-op path.

Compatibility with credentials that have no grant

  • No grant: the resolve-or-register payload is exactly {"path": ...}, and managed-worktree payloads keep their 4- or 6-field shapes.
  • Every non-project-share credential class carries an empty root-scope list, so exact-path admission, registry-mutation denials and read-only resolve_workspace are unchanged.
  • OAuth restart persistence is intentionally not part of this change: grants stay fenced to one share launch, as on main. The E2E therefore re-authorizes each launch instead of reusing a previous token.

Validation

Linux x86_64, rustc 1.99.1. Tested commit: aaa6e8e74964846a88af6b890f2e569c7d0c130d.

Library filters (cargo test --locked -p webcodex --lib <filter>):

Filter Result
project_root_scope 3 passed, 0 failed
project_share_root_scope 3 passed, 0 failed
resolve_or_register_project 2 passed, 0 failed
verify_runner_path 1 passed, 0 failed
managed_worktree_project_grant_admission 1 passed, 0 failed
project_grant_authority_is_identical 1 passed, 0 failed
share_project_roots_ 11 passed, 0 failed

Runner filters (cargo test --locked -p webcodex-runner <filter>):

Filter Result
managed_worktree_share_grant 3 passed, 0 failed
resolve_or_register_project_ 10 passed, 0 failed
grant_scopes 2 passed, 0 failed
managed_worktree_expected_source_identity 4 passed, 0 failed
managed_worktree_without_share_grant 1 passed, 0 failed

Other gates:

  • cargo fmt --all -- --check -> exit 0
  • git diff --check -> exit 0
  • cargo build --locked --profile dogfood -p webcodex -p webcodex-cli -p webcodex-runner -> exit 0
  • python3 scripts/e2e_project_share_roots.py --bin-dir target/dogfood -> E2E_PROJECT_SHARE_ROOTS=PASS, lifecycle_launches=9 client_id_stable=true. The loopback E2E runs real Server and Runner processes behind a real share, completes the OAuth Authorization Code + PKCE flow, exercises on-demand admission, concurrency, prefix/symlink/missing-path boundaries, grant replacement, restoration and clearing across restarts.

Environment lanes not run:

  • Windows native: NOT_RUN (no Windows host).
  • macOS native: NOT_RUN (no macOS host).
  • ARM64: NOT_RUN (tested host is x86_64).
  • noexec temporary filesystems: NOT_RUN.
  • Cross-host/remote OAuth: NOT_RUN (the E2E is loopback-only by design).

Design questions for maintainers

  1. Is a separate owner-only project-roots file next to the share OAuth client state the right persistence home, or would you prefer this grant on the project config? The separate file keeps project config stable and makes --no-project-roots a clear delete, but it is one more piece of share state to protect.
  2. Should managed-worktree destinations eventually be grant-scoped as well? This change deliberately binds only the source because the destination is a Runner-owned namespace under allowed_roots; a stricter rule may be preferable if you want the grant to bound the whole worktree lifecycle.

CI follow-up: Windows runner containment

The Windows webcodex-runner job failed on resolve_or_register_project_accepts_target_inside_granted_roots_idempotently with project_path_outside_granted_roots: the Runner compared its canonical target (a verbatim \\?\ path on Windows) against the root text it received, so a text-form difference decided admission.

The grant check now resolves each granted root on the Runner before comparing, using the same path_is_within helper the existing allowed_roots boundary uses (component-wise, case-insensitive on Windows). A root the Runner cannot resolve stays denied, and the target is still resolved locally, so the commit-boundary binding is unchanged. Both the exact-path and managed-worktree grant checks use it.

Regression: resolve_or_register_project_accepts_unresolved_granted_root grants <root>/../<root> (accepted) and a missing root (denied), independent of the platform's canonical text form.

vincent added 6 commits October 28, 2026 08:05
Add an optional canonical parent-root grant to project-share OAuth
subjects. The grant is carried on AuthContext, populated from the
Server's WEBCODEX_OAUTH2_PROJECT_SHARE_ROOTS environment for
project-share OAuth tokens only, and is bounded to eight roots.

Roots are absolute, canonicalized, existing directories; relative,
missing, non-directory and symlinked spellings resolve to the real
target (or are dropped, which only removes authority). Every other
credential class keeps an empty list, preserving the exact-path fence.

Test fixtures construct the new field as empty.
…oundary

Accept an optional project_root_scopes field on project resolution and
managed-worktree preparation. Malformed, empty or relative scopes fail
closed; ordinary requests keep their previous exact payload shapes.

Before any registration or worktree side effect, the Runner re-derives
containment from its own canonical resolution of the committed target
against the granted roots. A source that resolved differently at
Server-fence time than at the Runner write boundary is rejected. The
managed-worktree grant binds the source only; the derived sibling
destination remains governed by allowed_roots.

Move resume handling after source identity validation so resume is
anchored to the registered source identity as well as its stored path.
Add repeatable --project-roots and --no-project-roots to
share --auth oauth, pass the grant to the spawned Server, and persist it
next to the protected share OAuth client state. An explicit list
replaces the stored grant, re-sending the same list is a no-op,
omitting the flag restores the last grant, and --no-project-roots
removes it so removed roots cannot revive on a later restart.

The grant file is replaced atomically in the same protected directory
with owner-only permissions; failures never destroy the previous value
or write through a planted symlink. --project-roots requires OAuth and
an existing absolute directory.
…ath fence

For project-scoped credentials, an exact registered path keeps its
previous behavior. When the share carries parent-root grants, an
existing canonical directory inside one of them may now resolve or
register through the ordinary idempotent project-op path instead of
being denied before the Runner sees it.

The Server forwards the canonical target plus the granted roots, and
re-verifies the Runner's returned path against the grant, reporting
state_changed from the Runner's registered flag. Missing or malformed
effect metadata stays operation_indeterminate rather than claiming no
change. Symlink escapes, textual-prefix siblings, relative paths and
missing directories remain denied and are never created.

Credentials without a grant keep the exact previous payload and fence.
Document --project-roots and --no-project-roots in both CLI peers.

Add a loopback-only E2E that runs real dogfood Server and Runner
processes behind a real share: OAuth authorization, on-demand admission,
concurrency, symlink and prefix boundaries, grant replacement,
restoration and clearing across nine launches. Because this branch
intentionally excludes OAuth restart-persistence, each launch
re-authorizes and exercises only its own fresh token.
The Windows runner job rejected a target inside an explicitly granted root:
the grant check compared the Runner's canonical target against the root text it
received, and a Windows verbatim namespace prefix (or any unresolved component)
made the component comparison fail, so admission was denied with
project_path_outside_granted_roots.

Resolve each granted root on the Runner before comparing, using the same
component-wise, case-insensitive path_is_within helper the existing allowed_roots
boundary uses. A root this filesystem cannot resolve stays denied, and the
target is still resolved locally, so the commit-boundary binding is unchanged.
Both the exact-path and the managed-worktree grant checks use it.

Regression: resolve_or_register_project_accepts_unresolved_granted_root grants
"<root>/../<root>" (accepted) and a missing root (denied), independent of the
platform's canonical text form.
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.

1 participant