Skip to content

Commit 5a30e1f

Browse files
authored
Merge branch 'main' into doctor-v11-mirror-check
2 parents 78e9893 + 5f292e3 commit 5a30e1f

71 files changed

Lines changed: 2940 additions & 2026 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/),
66
and this project adheres to [Semantic Versioning](https://semver.org/).
77

8+
## [0.7.5] - 2026-06-04
9+
10+
### Security
11+
12+
- Closed a path-traversal / arbitrary-file-write vulnerability across the checkpoint, session, and agent-lifecycle paths: identifiers read from the shared `entire/checkpoints/v1` branch or from agent hook input flowed into filesystem paths without validation, so a crafted session ID could overwrite arbitrary files on `entire session resume` / `entire checkpoint rewind`. IDs are now validated at the read/dispatch boundaries, with `os.Root` containment as defense in depth ([#1365](https://github.com/entireio/cli/pull/1365))
13+
14+
### Fixed
15+
16+
- `git-remote-entire` now relays helper-status before checking the send-pack exit code, so per-ref rejections (branch protection, ref-name conflicts, permission denials) surface as `! [remote rejected]` with the real reason instead of a bare `send-pack exited with error: exit status 1` ([#1364](https://github.com/entireio/cli/pull/1364))
17+
18+
## [0.7.4] - 2026-06-04
19+
20+
### Added
21+
22+
- `git-remote-entire` now silently re-mints expired login tokens from a stored refresh token instead of forcing a re-login, with rotation serialized across processes so concurrent invocations (e.g. recursive submodule fetch) don't replay a single-use refresh token ([#1337](https://github.com/entireio/cli/pull/1337))
23+
- `ENTIRE_TOKEN` env override for `git-remote-entire`, for CI and workload-identity use where an interactive login isn't possible ([#1321](https://github.com/entireio/cli/pull/1321))
24+
- `git-remote-entire` gained `--version` and `--help` flags ([#1354](https://github.com/entireio/cli/pull/1354))
25+
- `entire auth use` now tab-completes context names ([#1358](https://github.com/entireio/cli/pull/1358))
26+
- Generic slash-command skill invocations are now captured as skill events ([#1333](https://github.com/entireio/cli/pull/1333))
27+
- Checkpoints v1.1 (work in progress): committed-read support extended to rewind, review, activity, and session resume, plus v1-ref mirroring for v1.1 reads and a committed-ref topology seam ([#1316](https://github.com/entireio/cli/pull/1316), [#1329](https://github.com/entireio/cli/pull/1329), [#1330](https://github.com/entireio/cli/pull/1330), [#1331](https://github.com/entireio/cli/pull/1331), [#1332](https://github.com/entireio/cli/pull/1332), [#1335](https://github.com/entireio/cli/pull/1335))
28+
29+
### Changed
30+
31+
- Simplified the `entire auth` surface: dropped support for the sunset `ent_` PATs (removing `auth list` and `auth revoke`), made `auth status` show your identity, active context, and the active sessions on that core, and routed `logout`/`logout --all` through entire-core to revoke exactly those sessions — fixing a logout/status bug that previously called entire.io's PAT endpoint and returned HTTP 400 ([#1341](https://github.com/entireio/cli/pull/1341))
32+
- `git-remote-entire` now stamps a versioned `User-Agent` (`git-remote-entire/<version>`) on every outbound HTTP request, so the helper is identifiable in upstream access logs ([#1348](https://github.com/entireio/cli/pull/1348))
33+
- `entire repo mirror create` skips clone polling when the upstream has no refs, printing a short note and returning instead of waiting out the timeout ([#1357](https://github.com/entireio/cli/pull/1357))
34+
- `entire repo mirror create` now explains suspended mirrors with resume guidance instead of a generic error ([#1344](https://github.com/entireio/cli/pull/1344))
35+
- Better alignment of the labs commands ([#1345](https://github.com/entireio/cli/pull/1345))
36+
- Minor cosmetic tweaks to command output ([#1343](https://github.com/entireio/cli/pull/1343))
37+
38+
### Housekeeping
39+
40+
- Auth review follow-ups and a keyring-safe `cli` `TestMain` ([#1360](https://github.com/entireio/cli/pull/1360))
41+
- Made the `GitTerminalPromptOnIsNotAgent` test hermetic ([#1361](https://github.com/entireio/cli/pull/1361))
42+
- Dependency bumps: `actions/checkout`, `github/codeql-action`, and the Go dependencies group ([#1338](https://github.com/entireio/cli/pull/1338), [#1339](https://github.com/entireio/cli/pull/1339), [#1340](https://github.com/entireio/cli/pull/1340))
43+
844
## [0.7.3] - 2026-06-02
945

1046
### Fixed

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ at the canonical group form.
3333
- `configure`: bare prints help and a hint pointing at `entire agent`; flags
3434
manage non-agent settings (telemetry, git-hook installation mode, strategy
3535
options, summary provider). Agent CRUD lives under `entire agent`.
36-
- `auth`: `login`, `logout`, `status`, `list`, `revoke`
36+
- `auth`: `login`, `logout`, `status`, `contexts`, `use`. `logout` takes
37+
`--everywhere` (revoke every session on the active core, not just the
38+
current one) and `--all-contexts` (log out of every saved login)
3739
- `doctor`: bare runs the scan-and-fix flow, plus `trace`, `logs`, `bundle`
3840

3941
Top-level lifecycle and standalone commands: `enable`, `disable`, `status`,
@@ -417,7 +419,6 @@ The `Strategy` interface provides:
417419
- `SaveTaskStep()` - Save subagent task step checkpoint
418420
- `GetRewindPoints()` / `Rewind()` - List and restore to checkpoints
419421
- `GetSessionLog()` / `GetSessionInfo()` - Retrieve session data
420-
- `ListSessions()` / `GetSession()` - Session discovery
421422

422423
#### How It Works
423424

cmd/entire/cli/agent/agent.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ type Agent interface {
6666
GetSessionDir(repoPath string) (string, error)
6767

6868
// ResolveSessionFile returns the path to the session transcript file.
69+
//
70+
// SECURITY CONTRACT: agentSessionID is used to build a filesystem path and
71+
// some implementations use it as a directory component or (Codex/Pi) return
72+
// it verbatim when absolute. Callers that source agentSessionID from
73+
// untrusted data (e.g. checkpoint metadata on the shared
74+
// entire/checkpoints/v1 branch, hook input) MUST validate it with
75+
// validation.ValidateSessionID first. The resume/rewind restore paths do
76+
// this at their choke points (transcript.resolveTranscriptPath and
77+
// strategy.RestoreLogsOnly); do not call this with unvalidated input.
6978
ResolveSessionFile(sessionDir, agentSessionID string) string
7079

7180
// ReadSession reads session data from agent's storage.

cmd/entire/cli/agent/claudecode/transcript.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/entireio/cli/cmd/entire/cli/agent"
1111
"github.com/entireio/cli/cmd/entire/cli/transcript"
12+
"github.com/entireio/cli/cmd/entire/cli/validation"
1213
)
1314

1415
// TranscriptLine is an alias to the shared transcript.Line type.
@@ -254,8 +255,11 @@ func ExtractSpawnedAgentIDs(transcript []TranscriptLine) map[string]string {
254255
}
255256
}
256257

257-
// Look for agentId in the text
258-
if agentID := extractAgentIDFromText(textContent); agentID != "" {
258+
// Look for agentId in the text. Drop any ID that isn't path-safe:
259+
// callers build agent-<id>.jsonl from it and read that file, so this
260+
// is the choke point that keeps the path inside subagentsDir,
261+
// independent of extractAgentIDFromText's character handling.
262+
if agentID := extractAgentIDFromText(textContent); agentID != "" && validation.ValidateAgentID(agentID) == nil {
259263
agentIDs[agentID] = block.ToolUseID
260264
}
261265
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package codex
2+
3+
import (
4+
"testing"
5+
6+
"github.com/entireio/cli/cmd/entire/cli/validation"
7+
)
8+
9+
// TestResolveSessionFile_AbsoluteVerbatim_GuardedByValidator pins the security
10+
// contract for Codex's ResolveSessionFile: an absolute agentSessionID is
11+
// returned verbatim (a deliberate feature for agent-recorded transcript paths),
12+
// which makes it a path-traversal footgun if fed untrusted input. Callers that
13+
// source the ID from untrusted data (checkpoint metadata, hook input) must
14+
// reject it first via validation.ValidateSessionID.
15+
//
16+
// This test fails if either the verbatim behavior changes silently OR the shared
17+
// validator stops rejecting absolute IDs — i.e. it guards the resume/rewind fix
18+
// from regressing out from under this agent.
19+
func TestResolveSessionFile_AbsoluteVerbatim_GuardedByValidator(t *testing.T) {
20+
t.Parallel()
21+
22+
ag := &CodexAgent{}
23+
const abs = "/etc/evil.jsonl"
24+
25+
if got := ag.ResolveSessionFile("/home/u/.codex/sessions", abs); got != abs {
26+
t.Fatalf("ResolveSessionFile returned %q, want verbatim %q (behavior change — re-check the validator guard)", got, abs)
27+
}
28+
if err := validation.ValidateSessionID(abs); err == nil {
29+
t.Fatalf("ValidateSessionID(%q) = nil; the validator MUST reject absolute IDs to guard this footgun", abs)
30+
}
31+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package copilotcli
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
"testing"
7+
8+
"github.com/entireio/cli/cmd/entire/cli/validation"
9+
)
10+
11+
// TestResolveSessionFile_DirComponent_GuardedByValidator pins the security
12+
// contract for Copilot's ResolveSessionFile: it uses agentSessionID as a
13+
// directory component (<dir>/<id>/events.jsonl). A bare ".." therefore escapes
14+
// the session directory even though it contains no path separator, so the
15+
// shared validator must reject it. Callers sourcing the ID from untrusted data
16+
// must validate first.
17+
//
18+
// This test fails if the validator stops rejecting ".." (regressing the
19+
// resume/rewind guard) or if the layout changes such that the ID is no longer a
20+
// directory component without a matching guard update.
21+
func TestResolveSessionFile_DirComponent_GuardedByValidator(t *testing.T) {
22+
t.Parallel()
23+
24+
ag := &CopilotCLIAgent{}
25+
sessionDir := "/home/user/.copilot/session-state"
26+
27+
// A ".." id used as a directory component escapes sessionDir.
28+
escaped := ag.ResolveSessionFile(sessionDir, "..")
29+
rel, err := filepath.Rel(sessionDir, escaped)
30+
if err != nil {
31+
t.Fatalf("filepath.Rel(%q, %q) error: %v", sessionDir, escaped, err)
32+
}
33+
if !strings.HasPrefix(rel, "..") {
34+
t.Fatalf("expected %q to escape %q, but it did not (rel=%q)", escaped, sessionDir, rel)
35+
}
36+
37+
// The shared validator is the guard that prevents that id from reaching here.
38+
if err := validation.ValidateSessionID(".."); err == nil {
39+
t.Fatal(`ValidateSessionID("..") = nil; the validator MUST reject ".." to guard this directory-component footgun`)
40+
}
41+
}

cmd/entire/cli/agent/factoryaidroid/transcript.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/entireio/cli/cmd/entire/cli/agent"
1616
"github.com/entireio/cli/cmd/entire/cli/transcript"
17+
"github.com/entireio/cli/cmd/entire/cli/validation"
1718
)
1819

1920
// TranscriptLine is an alias to the shared transcript.Line type.
@@ -270,8 +271,11 @@ func ExtractSpawnedAgentIDs(transcriptLines []TranscriptLine) map[string]string
270271
}
271272
}
272273

273-
// Look for agentId in the text
274-
if agentID := extractAgentIDFromText(textContent); agentID != "" {
274+
// Look for agentId in the text. Drop any ID that isn't path-safe:
275+
// callers build agent-<id>.jsonl from it and read that file, so this
276+
// is the choke point that keeps the path inside subagentsDir,
277+
// independent of extractAgentIDFromText's character handling.
278+
if agentID := extractAgentIDFromText(textContent); agentID != "" && validation.ValidateAgentID(agentID) == nil {
275279
agentIDs[agentID] = block.ToolUseID
276280
}
277281
}

cmd/entire/cli/agent/pi/lifecycle.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/entireio/cli/cmd/entire/cli/agent"
1616
"github.com/entireio/cli/cmd/entire/cli/logging"
1717
"github.com/entireio/cli/cmd/entire/cli/paths"
18+
"github.com/entireio/cli/cmd/entire/cli/validation"
1819
)
1920

2021
// Hook names — these match Pi's native event names exactly (snake_case),
@@ -286,6 +287,15 @@ func captureTranscript(ctx context.Context, sessionID, piSessionFile string) str
286287
if sessionID == "" || piSessionFile == "" {
287288
return ""
288289
}
290+
// sessionID comes from the hook payload (or the locally cached active
291+
// session) and is used to build dst below, before the lifecycle dispatcher
292+
// validates it. Validate here at the choke point so an unsafe ID cannot
293+
// write the transcript outside the cache directory; "" signals no capture.
294+
if err := validation.ValidateSessionID(sessionID); err != nil {
295+
logging.Warn(ctx, "pi: refusing to capture transcript for unsafe session ID",
296+
slog.String("session_id", sessionID), slog.String("err", err.Error()))
297+
return ""
298+
}
289299
dir := resolveSessionDir(ctx)
290300
if err := os.MkdirAll(dir, 0o750); err != nil {
291301
logging.Warn(ctx, "pi: capture transcript mkdir failed",
@@ -300,7 +310,7 @@ func captureTranscript(ctx context.Context, sessionID, piSessionFile string) str
300310
slog.String("src", piSessionFile), slog.String("err", err.Error()))
301311
return ""
302312
}
303-
//nolint:gosec // G703: dst constructed from validated session ID inside .entire/tmp
313+
//nolint:gosec // G703: dst is sessionID (validated above) under .entire/tmp/pi
304314
if err := os.WriteFile(dst, data, 0o600); err != nil {
305315
logging.Warn(ctx, "pi: capture transcript write failed",
306316
slog.String("dst", dst), slog.String("err", err.Error()))

cmd/entire/cli/agent/pi/lifecycle_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,41 @@ func TestCaptureTranscript_MissingInputs(t *testing.T) {
211211
}
212212
}
213213

214+
// TestCaptureTranscript_RejectsTraversalSessionID verifies that captureTranscript
215+
// refuses an unsafe session ID. captureTranscript runs inside ParseHookEvent,
216+
// before the lifecycle dispatcher validates the ID, so it must guard the
217+
// transcript write itself — otherwise a "../"-laden ID escapes the cache dir.
218+
func TestCaptureTranscript_RejectsTraversalSessionID(t *testing.T) {
219+
// Cannot use t.Parallel — t.Chdir.
220+
dir := t.TempDir()
221+
t.Chdir(dir)
222+
223+
src := filepath.Join(dir, "src.jsonl")
224+
if err := os.WriteFile(src, []byte("payload\n"), 0o600); err != nil {
225+
t.Fatal(err)
226+
}
227+
228+
// A sentinel outside the cache dir that the traversal would target.
229+
victim := filepath.Join(dir, "victim.json")
230+
if err := os.WriteFile(victim, []byte("SAFE"), 0o600); err != nil {
231+
t.Fatal(err)
232+
}
233+
234+
for _, bad := range []string{"../victim", "/etc/passwd", "..", "a/b"} {
235+
if got := captureTranscript(context.Background(), bad, src); got != "" {
236+
t.Errorf("captureTranscript(%q) = %q, want \"\" (unsafe ID must be refused)", bad, got)
237+
}
238+
}
239+
240+
got, err := os.ReadFile(victim)
241+
if err != nil {
242+
t.Fatal(err)
243+
}
244+
if string(got) != "SAFE" {
245+
t.Errorf("sentinel was overwritten via traversal: %q", string(got))
246+
}
247+
}
248+
214249
func TestGetSupportedHooks(t *testing.T) {
215250
t.Parallel()
216251
got := (&PiAgent{}).GetSupportedHooks()
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"net/url"
8+
)
9+
10+
// AuthSession is a single active login session — an OAuth refresh-token family —
11+
// returned by entire-core's session endpoint. One is created per
12+
// `entire login`, across all of a user's devices. Plaintext token values are
13+
// never returned by the server, only metadata. (The list envelope's wire key
14+
// is "tokens"; the rows are sessions.)
15+
type AuthSession struct {
16+
ID string `json:"id"`
17+
UserID string `json:"user_id"`
18+
Name string `json:"name"`
19+
Scope string `json:"scope"`
20+
ExpiresAt string `json:"expires_at"`
21+
LastUsedAt *string `json:"last_used_at"`
22+
CreatedAt string `json:"created_at"`
23+
}
24+
25+
// AuthSessionsResponse is the envelope returned by the list endpoint.
26+
type AuthSessionsResponse struct {
27+
Sessions []AuthSession `json:"tokens"`
28+
}
29+
30+
// errAuthSessionsPathUnset surfaces when a session method is called on a Client
31+
// that wasn't given a base path. Construct via
32+
// NewClientWithBaseURL(...).WithAuthSessionsPath(...).
33+
var errAuthSessionsPathUnset = errors.New("api: auth sessions path is unset (call (*Client).WithAuthSessionsPath before list/revoke)")
34+
35+
func (c *Client) authSessionsBasePath() (string, error) {
36+
if c.authSessionsPath == "" {
37+
return "", errAuthSessionsPathUnset
38+
}
39+
return c.authSessionsPath, nil
40+
}
41+
42+
// ListAuthSessions returns the authenticated user's active login sessions.
43+
func (c *Client) ListAuthSessions(ctx context.Context) ([]AuthSession, error) {
44+
base, err := c.authSessionsBasePath()
45+
if err != nil {
46+
return nil, fmt.Errorf("list sessions: %w", err)
47+
}
48+
resp, err := c.Get(ctx, base)
49+
if err != nil {
50+
return nil, fmt.Errorf("list sessions: %w", err)
51+
}
52+
defer resp.Body.Close()
53+
54+
if err := CheckResponse(resp); err != nil {
55+
return nil, fmt.Errorf("list sessions: %w", err)
56+
}
57+
58+
var out AuthSessionsResponse
59+
if err := DecodeJSON(resp, &out); err != nil {
60+
return nil, fmt.Errorf("list sessions: %w", err)
61+
}
62+
return out.Sessions, nil
63+
}
64+
65+
// RevokeCurrentAuthSession revokes the login session this client is authenticating
66+
// with (the family the current bearer belongs to).
67+
func (c *Client) RevokeCurrentAuthSession(ctx context.Context) error {
68+
base, err := c.authSessionsBasePath()
69+
if err != nil {
70+
return fmt.Errorf("revoke current session: %w", err)
71+
}
72+
resp, err := c.Delete(ctx, base+"/current")
73+
if err != nil {
74+
return fmt.Errorf("revoke current session: %w", err)
75+
}
76+
defer resp.Body.Close()
77+
78+
if err := CheckResponse(resp); err != nil {
79+
return fmt.Errorf("revoke current session: %w", err)
80+
}
81+
return nil
82+
}
83+
84+
// RevokeAuthSession revokes the login session with the given id.
85+
func (c *Client) RevokeAuthSession(ctx context.Context, id string) error {
86+
base, err := c.authSessionsBasePath()
87+
if err != nil {
88+
return fmt.Errorf("revoke session %s: %w", id, err)
89+
}
90+
resp, err := c.Delete(ctx, base+"/"+url.PathEscape(id))
91+
if err != nil {
92+
return fmt.Errorf("revoke session %s: %w", id, err)
93+
}
94+
defer resp.Body.Close()
95+
96+
if err := CheckResponse(resp); err != nil {
97+
return fmt.Errorf("revoke session %s: %w", id, err)
98+
}
99+
return nil
100+
}

0 commit comments

Comments
 (0)