Skip to content
Closed
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
184 changes: 108 additions & 76 deletions .aoci/baseline.json

Large diffs are not rendered by default.

26 changes: 15 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable public changes to AOCI-CODE will be documented in this file.

## Unreleased

- Host-configuration backups are owner-only. Optional model clients reject
redirects and credential-bearing base URLs, preserve caller-owned HTTP
clients, and expose status/category diagnostics without endpoint error
bodies or transport details. No MCP or formal-asset schema changes.
- The loopback status page protects repository data with a per-process
capability carried in the readiness URL fragment and sent by the browser
in Authorization headers. The static shell contains no capability or
repository data. Registrations keep their existing shape; legacy pages can
be stopped but must be stopped before a new detach command replaces them.

- Every `aoci` invocation built its command tree by decoding the embedded
localization manifest once per localized flag, several hundred times, about
half a second before the first byte of output. The decoded manifest is now
Expand Down Expand Up @@ -90,17 +100,11 @@ All notable public changes to AOCI-CODE will be documented in this file.
exists, rendered "Repair only the reported of this candidate"; each now
has its own action naming the identity and the create/update alternative,
and no field-less finding renders a blank.
- Governed CAS writes work on filesystems that refuse `RENAME_EXCHANGE`,
such as WSL DrvFs (`/mnt/*`, 9p), where every Entry batch and Managed Scope
write failed with "原子交换目标失败: invalid argument" while `init` and
`scan` worked (#116). The primitive is probed and, when refused, emulated
with a hard link and two renames that end in the primitive's exact
post-state; crash states converge through the existing recovery. On such
a filesystem the replacement window is link-to-rename rather than zero: a
writer outside aoci that replaces the target by rename in that instant is
overwritten rather than detected, while in-place modification is still
caught; every aoci writer is serialized by the CAS lock. Verified on a real
DrvFs mount.
- Governed CAS writes fail closed on filesystems that refuse native
`RENAME_EXCHANGE`, including WSL DrvFs (#116). The unreleased link/rename
fallback was removed because it could overwrite a non-cooperating writer's
replacement. Existing capture/intent artifacts keep their recovery reader;
recover on a filesystem with native exchange if rollback needs it.
- Strict decoders of governance and protocol artifacts (receipts, run
manifests, previews, checkpoints, onboarding sessions, Curation documents,
Host-Agent stage requests) reject two keys that differ only by letter case:
Expand Down
13 changes: 13 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,20 @@ remediation and disclosure, and publish guidance when disclosure is safe.
- MCP stdio stdout is reserved for JSON-RPC; logs and diagnostics use stderr.
- Configuration stores credential environment-variable names, not values.
- Optional model access uses only an explicitly configured endpoint.
- Model HTTP clients refuse redirects, including caller-supplied clients.
Endpoint error bodies and transport details are not formatted into errors
that callers may log or persist. Endpoint URLs cannot contain credentials,
query strings, or fragments.
- Host-configuration backups use owner-only file permissions. This protects
configuration content independently of Git ignore rules; on Windows,
restrict access to the containing directory with the operating system ACL.
- The optional loopback status page requires a fresh per-process capability
for repository data. Its printed URL and private user-cache registration
carry that capability; do not share them or put them into public logs.
- Formal asset updates use validation, locking, CAS, atomic writes, and recovery.
A filesystem without native atomic exchange cannot start a governed CAS
replacement. Use a filesystem supporting that primitive; cooperating-writer
locks alone cannot protect against an external editor's atomic replacement.
- Database Evidence collectors are limited to schema/catalog metadata and do
not issue DDL or DML.
- Checksums establish artifact integrity but do not identify the publisher;
Expand Down
30 changes: 15 additions & 15 deletions aoci.code.txt

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,3 +477,35 @@ it:
aoci ui --detach --json
aoci ui --stop
```

Open the complete link printed by the current process: the fragment carries
the page's access capability. Repository API requests without it return 401.
The browser stores it for the current tab, never in persistent local storage,
and removes it from the address bar when session storage is available. Do not
share the readiness link. A legacy page can still be stopped, but a new detach
command refuses to replace its registration while it is live: run
`aoci ui --stop` for that repository first. Only authenticated pages are reused.
Before downgrading, stop the page
using the same binary that started it.

## Governed writes on filesystems without atomic exchange

On Linux, a governed replacement requires native `RENAME_EXCHANGE`. If the
filesystem refuses it, the operation returns `ErrAtomicCASUnavailable` without
replacing the target; move the checkout to a filesystem supporting atomic
exchange, such as the Linux filesystem inside WSL, rather than a DrvFs mount.
The former link/rename compatibility path is no longer used for new writes
because it cannot preserve a concurrent replacement by an external editor.
Older capture/intent artifacts retain their format and recovery reader. If a
recovery needs an exchange the current filesystem cannot provide, preserve the
artifacts and recover on a supported filesystem. Do not delete recovery files
or accept a weakened replacement to clear the error.

## Model endpoint refusals

Configure the final HTTP(S) completion base URL directly. The optional model
client does not follow redirects, and base URLs with user information, query
strings or fragments are refused. Authentication remains in the configured
credential environment variable. Error output keeps the HTTP status and error
category, but omits the endpoint's arbitrary error text and transport detail;
diagnose those details in the provider's own protected logs.
5 changes: 5 additions & 0 deletions internal/cli/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ func stopUI(cmd *cobra.Command, registry, root string) error {
// otherwise starts the same command as a detached child.
func detachUI(cmd *cobra.Command, registry, root string, port int, host string, discover bool, also []string, open bool) error {
if reg, ok := ui.Registered(registry, root); ok && ui.Live(reg, time.Second) {
if !ui.HasAccessToken(reg.URL) {
// Keep the old registration reachable by --stop. Starting another
// page would overwrite it and leave the unprotected process running.
return &ExitError{Code: ExitConfig, Msg: cliMessage("ui.error.legacy_page")}
}
reportDetached(cmd, reg.URL, reg.PID, -1, true)
if open {
openBrowser(reg.URL)
Expand Down
45 changes: 45 additions & 0 deletions internal/cli/ui_security_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package cli

import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"

"github.com/spf13/cobra"

"github.com/aoci-spec/aoci-code/internal/ui"
)

func TestDetachPreservesLiveLegacyPageUntilExplicitStop(t *testing.T) {
root, registry := t.TempDir(), t.TempDir()
legacy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/repos" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode([]map[string]string{{"root": root}})
}))
defer legacy.Close()
reg := ui.Registration{URL: legacy.URL, Root: root, PID: 42}
if err := ui.Register(registry, reg); err != nil {
t.Fatal(err)
}
var output bytes.Buffer
command := &cobra.Command{}
command.SetOut(&output)
err := detachUI(command, registry, root, 0, "127.0.0.1", false, nil, false)
var exit *ExitError
if !errors.As(err, &exit) || exit.Code != ExitConfig {
t.Fatalf("live legacy page was not refused: %v", err)
}
if output.Len() != 0 {
t.Fatal("legacy page was reported as a usable panel")
}
got, ok := ui.Registered(registry, root)
if !ok || got.URL != reg.URL || got.PID != reg.PID {
t.Fatal("legacy registration was replaced instead of preserved for --stop")
}
}
7 changes: 7 additions & 0 deletions internal/fs/atomic.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ func AtomicWrite(targetPath string, data []byte) error {
return atomicWrite(targetPath, data)
}

// AtomicWriteMode publishes with an explicit permission ceiling, including
// when replacing an existing file. Callers storing private backups must not
// inherit the generic new-file mode or a prior backup's broader permissions.
func AtomicWriteMode(targetPath string, data []byte, mode os.FileMode) error {
return atomicWriteWithPerm(targetPath, data, &mode)
}

// AtomicCreateCAS publishes complete bytes only when targetPath is absent at
// the atomic publication instant. It never falls back to an overwrite rename.
func AtomicCreateCAS(targetPath string, data []byte) error {
Expand Down
70 changes: 4 additions & 66 deletions internal/fs/atomic_exchange_linux.go
Original file line number Diff line number Diff line change
@@ -1,53 +1,17 @@
// Linux原子路径交换 —— CAS必须捕获交换瞬间的真实preimage。
//
// renameat2 with RENAME_EXCHANGE is the primitive, but like RENAME_NOREPLACE
// next door it is a filesystem capability, not a kernel one: WSL's DrvFs (9p)
// answers EINVAL, so every governed CAS write under /mnt/c failed with
// "原子交换目标失败: invalid argument" while `aoci init` on the same path had
// worked since the no-replace path learned to probe (#116). The flag is now
// probed, not assumed. When it is refused the exchange is emulated with a hard
// link and two renames, which DrvFs honours, and the emulation ends in the
// exact post-state the primitive produces: first holds second's old inode and
// second holds first's old inode, so AtomicWriteCAS's preimage check and
// rollbackAtomicCAS's swap-back run unchanged on top of either.
//
// What the emulation cannot give is a zero-width replacement window. The
// primitive swaps both names in one step; the emulation holds the target's
// inode under a capture link, renames second over first, then renames the
// capture into second. A non-cooperating writer that replaces the target by
// rename between the link and the first rename is overwritten rather than
// detected, because the exchange never sees its inode. A writer that modifies
// the target in place during that window is still detected, because the
// capture link shares the inode and the modified bytes arrive in the swap as
// the captured preimage. Every cooperating aoci writer is serialized by the
// cross-process CAS lock AtomicWriteCAS takes on the target before any of
// this runs, so the window is only ever visible to writers outside aoci.
//
// A crash inside the emulation leaves the capture link beside the swap;
// normalizeAtomicExchangeArtifacts converges both states before recovery
// judges digests, so the next call finds the same two assets it would have
// found after a crashed primitive exchange.
// Linux CAS requires native atomic exchange. Link/rename emulation can
// overwrite a non-cooperating writer's replacement and therefore fails closed.
// Old capture artifacts remain readable for recovery after an upgrade.
package fs

import (
"errors"
"fmt"
"os"

"golang.org/x/sys/unix"
)

func exchangeAtomicPaths(first, second string) error {
err := renameat2Exchange(first, second)
if err == nil || !renameat2FlagUnsupported(err) {
return err
}
if fallbackErr := exchangeAtomicPathsByLink(first, second); fallbackErr != nil {
// Name both, or a future reader sees only the second failure and cannot
// tell that the primitive was refused before the fallback was tried.
return fmt.Errorf("renameat2 refused the exchange flag (%w) and the link fallback failed: %w", err, fallbackErr)
}
return nil
return renameat2Exchange(first, second)
}

// renameat2Exchange is the primitive behind exchangeAtomicPaths. It is a
Expand All @@ -71,32 +35,6 @@ func atomicExchangeCapturePath(second string) string {
return second + ".capture"
}

// exchangeAtomicPathsByLink is the exchange built from link and rename.
//
// link(first, capture) capture and first now name the same inode
// rename(second, first) first names second's inode; the old inode lives on in capture
// rename(capture, second) second names the old inode; capture is gone
//
// A failure before the second rename takes effect is undone by unlinking the
// capture, which is redundant with first. A failure after it leaves first
// already replaced and the old inode only under the capture; that is state (b)
// of normalizeAtomicExchangeArtifacts and is left for it rather than reversed
// here, since the reverse would be two more renames that can fail in turn.
func exchangeAtomicPathsByLink(first, second string) error {
capture := atomicExchangeCapturePath(second)
if err := unix.Link(first, capture); err != nil {
return &linkError{op: "link", err: err}
}
if err := unix.Rename(second, first); err != nil {
_ = unix.Unlink(capture)
return &linkError{op: "rename second over first", err: err}
}
if err := unix.Rename(capture, second); err != nil {
return &linkError{op: "rename capture to second", err: err}
}
return nil
}

// normalizeAtomicExchangeArtifacts converges the two states a crash inside the
// link emulation can leave, so recoverAtomicCAS only ever judges the target and
// swap digests it already understands. It runs on every filesystem, because an
Expand Down
Loading
Loading