Skip to content

[stack 2/8] fix(security): harden session and autonomous execution boundaries - #1159

Open
sethkarten wants to merge 9 commits into
stack/external-01-verificationfrom
stack/external-02-security
Open

[stack 2/8] fix(security): harden session and autonomous execution boundaries#1159
sethkarten wants to merge 9 commits into
stack/external-01-verificationfrom
stack/external-02-security

Conversation

@sethkarten

@sethkarten sethkarten commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Stack 2/8 — fix(security): harden session and autonomous execution boundaries

Active review snapshot — do not merge yet. The complete stack is open for architecture/design review, while final cumulative audit, CI, Cursor Bug Bot, and Macroscope findings are being remediated. Branches will be force-updated after validation.

Base: stack/external-01-verification
Review order: merge only after the preceding stack layer is accepted. This PR is not intended to merge independently out of order.

Stack navigation

  1. #1158 — ci: harden verification and release compatibility
  2. #1159 — fix(security): harden session and autonomous execution boundaries
  3. #1160 — fix(coding-agent): make persisted state crash-safe
  4. #1161 — fix(daemon): fence worker and supervisor lifecycle state
  5. #1162 — fix(coding-agent): repair queued and archived session lifecycle
  6. #1163 — fix(coding-agent): complete Windows kernel and daemon startup
  7. #1164 — fix(providers): harden MCP OAuth and Codex transports
  8. #1165 — fix(runtime): bound transcript and autonomous recovery

Summary

  • Contain session, artifact, snapshot, auth, editor, export, harness, and debug-file paths with private atomic storage.
  • Cancel admitted RLM work when its kernel host is disposed and bound clipboard fallback behavior.
  • Keep --no-session descendants in memory and document the actual autonomous-execution isolation boundary.
  • Pin workflow actions by digest and disable persisted checkout credentials.

Validation

  • npm run check; focused Vitest 184/184; Python harness 36/36; workflow digest and documentation-link validation.
  • Residual/non-blocking: dill still executable for valid snapshots; cooperative cancellation; broader sensitive sinks; no full built-in sandbox

Provenance

  • Authored independently from upstream/main using issue reports and PR descriptions/comments only.
  • No external contributor branch, diff, commit, implementation code, or test code was fetched, inspected, copied, or reused.
  • The implementation and regression tests in this stack are maintainer-owned.

Linked-item disposition

Fixed on merge

Independently superseded pull requests

Partial/distinct overlap — remains open

Reviewer notes

  • Please review this layer against its immediate stack base, not against main, to avoid cumulative duplicate diffs.
  • No merge is requested; the complete stack is being left for human review.

appendFileSync(newSessionFile, `${JSON.stringify(out)}\n`);
forkedEntries.push(out);
}
writePrivateFileAtomic(newSessionFile, `${forkedEntries.map((entry) => JSON.stringify(entry)).join("\n")}\n`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High core/session-manager.ts:2280

forkFrom builds the entire forked session as one in-memory string via forkedEntries.map(...).join("\n") before writing, so forking a sufficiently large session can exhaust the Node heap and fail. Previously each entry was appended to the file one line at a time, avoiding the full-session allocation. Consider writing entries to a temporary file incrementally and then atomically renaming, instead of constructing the complete JSONL payload in memory.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/core/session-manager.ts around line 2280:

`forkFrom` builds the entire forked session as one in-memory string via `forkedEntries.map(...).join("\n")` before writing, so forking a sufficiently large session can exhaust the Node heap and fail. Previously each entry was appended to the file one line at a time, avoiding the full-session allocation. Consider writing entries to a temporary file incrementally and then atomically renaming, instead of constructing the complete JSONL payload in memory.

this.persist = persist;
if (persist && sessionDir && !existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
if (persist && sessionDir) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High core/session-manager.ts:1221

SessionManager.open(path) derives sessionDir from dirname(path), so opening an arbitrary user-selected session file (e.g. --resume /shared/project/session.jsonl) runs ensurePrivateDirectory on /shared/project, chmodding an existing shared directory to 0700 and revoking access for every other user. The constructor now calls ensurePrivateDirectory(sessionDir) unconditionally whenever persist is true, even for directories the caller already owns and intends to remain shared. Only directories newly created by the agent should be chmodded; opening an existing file in an arbitrary location must not mutate its parent directory's permissions.

Also found in 1 other location(s)

packages/coding-agent/src/utils/private-files.ts:75

ensurePrivateDirectory unconditionally changes an existing directory to mode 0700. Callers pass user-selected locations such as --session-dir and the parent of a file supplied to --resume; if that directory is intentionally shared or contains unrelated files, merely opening/creating a session removes access for every other user and can break unrelated workflows. Only directories created by the agent should be chmodded automatically, or the private session storage should live in a dedicated child directory.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/core/session-manager.ts around line 1221:

`SessionManager.open(path)` derives `sessionDir` from `dirname(path)`, so opening an arbitrary user-selected session file (e.g. `--resume /shared/project/session.jsonl`) runs `ensurePrivateDirectory` on `/shared/project`, chmodding an existing shared directory to `0700` and revoking access for every other user. The constructor now calls `ensurePrivateDirectory(sessionDir)` unconditionally whenever `persist` is true, even for directories the caller already owns and intends to remain shared. Only directories newly created by the agent should be chmodded; opening an existing file in an arbitrary location must not mutate its parent directory's permissions.

Also found in 1 other location(s):
- packages/coding-agent/src/utils/private-files.ts:75 -- `ensurePrivateDirectory` unconditionally changes an existing directory to mode `0700`. Callers pass user-selected locations such as `--session-dir` and the parent of a file supplied to `--resume`; if that directory is intentionally shared or contains unrelated files, merely opening/creating a session removes access for every other user and can break unrelated workflows. Only directories created by the agent should be chmodded automatically, or the private session storage should live in a dedicated child directory.

}
}

export function readPrivateFile(path: string, encoding: BufferEncoding): string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Low utils/private-files.ts:104

readPrivateFile calls setPrivateFileMode before entering the try/finally that closes fd, so if fchmodSync/chmodSync throws (e.g. on a read-only or permission-restricted filesystem), the file descriptor leaks. Callers that catch the error and retry can exhaust file descriptors. Move setPrivateFileMode inside the try block.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/utils/private-files.ts around line 104:

`readPrivateFile` calls `setPrivateFileMode` before entering the `try/finally` that closes `fd`, so if `fchmodSync`/`chmodSync` throws (e.g. on a read-only or permission-restricted filesystem), the file descriptor leaks. Callers that catch the error and retry can exhaust file descriptors. Move `setPrivateFileMode` inside the `try` block.

path.chmod(mode)


def _ensure_private_directory(path: Path) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High rlm/harness.py:102

On Windows, _ensure_private_directory calls path.chmod(0o700) to restrict the harness directory, but Windows chmod() only toggles the read-only flag and ignores Unix permission bits. The directory therefore keeps its inherited/default ACL, so other local users can read or replace the supposedly private harness state. Python only added restrictive ACL handling for mkdir(mode=...) in 3.13, so this is broken on the project's supported 3.10–3.12 range. Consider applying an explicit restrictive Windows ACL (e.g. via win32security or icacls) for both creation and existing-directory verification instead of chmod.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @prime-agent-runtime/src/rlm/harness.py around line 102:

On Windows, `_ensure_private_directory` calls `path.chmod(0o700)` to restrict the harness directory, but Windows `chmod()` only toggles the read-only flag and ignores Unix permission bits. The directory therefore keeps its inherited/default ACL, so other local users can read or replace the supposedly private harness state. Python only added restrictive ACL handling for `mkdir(mode=...)` in 3.13, so this is broken on the project's supported 3.10–3.12 range. Consider applying an explicit restrictive Windows ACL (e.g. via `win32security` or `icacls`) for both creation and existing-directory verification instead of `chmod`.

const historyPath = getRefinementHistoryPath(harnessStateDir);
mkdirSync(harnessStateDir, { recursive: true });
appendFileSync(historyPath, `${JSON.stringify(result)}\n`, "utf8");
appendPrivateFile(historyPath, `${JSON.stringify(result)}\n`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High refinement/refinement.ts:357

appendGlobalRefinement calls appendPrivateFile, which checks for file absence before opening with O_CREAT | O_EXCL. When two sessions append the first global refinement concurrently, both observe the missing file, one open fails with EEXIST, and that error propagates to the caller. Because the refinement has already been applied by the time this runs, the command reports failure and skips recording both the global rollback-history entry and the session entry, leaving an applied change with no rollback record. The previous appendFileSync did not have this race because it atomically created-and-appended in one call. Consider ensuring the append path tolerates concurrent first-writer creation (e.g., retry on EEXIST or fall back to a non-exclusive open) so the history entry is always recorded after a refinement is applied.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/core/refinement/refinement.ts around line 357:

`appendGlobalRefinement` calls `appendPrivateFile`, which checks for file absence before opening with `O_CREAT | O_EXCL`. When two sessions append the first global refinement concurrently, both observe the missing file, one open fails with `EEXIST`, and that error propagates to the caller. Because the refinement has already been applied by the time this runs, the command reports failure and skips recording both the global rollback-history entry and the session entry, leaving an applied change with no rollback record. The previous `appendFileSync` did not have this race because it atomically created-and-appended in one call. Consider ensuring the append path tolerates concurrent first-writer creation (e.g., retry on `EEXIST` or fall back to a non-exclusive open) so the history entry is always recorded after a refinement is applied.

@@ -159,7 +179,14 @@ def _prime_agent_restore_state():
return

try:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High kernel/state-snapshot.ts:181

os.lstat validates the path as a regular file, then os.open opens it in a separate call, leaving a TOCTOU window. Between the two calls, a process able to modify the snapshot directory can replace the checked file with a symlink or a different regular file; O_NOFOLLOW does not block the regular-file-replacement case, and _b.getattr(os, "O_NOFOLLOW", 0) silently drops no-follow protection on platforms where the flag is unavailable (e.g. Windows). dill.load then deserializes the replacement payload, executing attacker-controlled code. Consider opening the file first and validating the descriptor with fstat (or os.fstat), and fail closed when O_NOFOLLOW is unavailable rather than defaulting to 0.

Also found in 1 other location(s)

prime-agent-runtime/src/rlm/harness.py:125

_open_private_for_read performs a pathname lstat() and then a separate os.open(), while getattr(os, "O_NOFOLLOW", 0) silently removes the only atomic no-follow protection on platforms where that extension is unavailable (including Windows). An attacker able to modify the state path can swap the checked regular file for a symlink/reparse point between these calls; the code then opens and reads the link target and _chmod_open_file may also chmod that unrelated target. This defeats the private-file boundary and can expose or alter another file. The open must use a platform-specific atomic reparse-point-safe mechanism, or fail closed when no such mechanism exists.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/core/kernel/state-snapshot.ts around line 181:

`os.lstat` validates the path as a regular file, then `os.open` opens it in a separate call, leaving a TOCTOU window. Between the two calls, a process able to modify the snapshot directory can replace the checked file with a symlink or a different regular file; `O_NOFOLLOW` does not block the regular-file-replacement case, and `_b.getattr(os, "O_NOFOLLOW", 0)` silently drops no-follow protection on platforms where the flag is unavailable (e.g. Windows). `dill.load` then deserializes the replacement payload, executing attacker-controlled code. Consider opening the file first and validating the descriptor with `fstat` (or `os.fstat`), and fail closed when `O_NOFOLLOW` is unavailable rather than defaulting to 0.

Also found in 1 other location(s):
- prime-agent-runtime/src/rlm/harness.py:125 -- `_open_private_for_read` performs a pathname `lstat()` and then a separate `os.open()`, while `getattr(os, "O_NOFOLLOW", 0)` silently removes the only atomic no-follow protection on platforms where that extension is unavailable (including Windows). An attacker able to modify the state path can swap the checked regular file for a symlink/reparse point between these calls; the code then opens and reads the link target and `_chmod_open_file` may also chmod that unrelated target. This defeats the private-file boundary and can expose or alter another file. The open must use a platform-specific atomic reparse-point-safe mechanism, or fail closed when no such mechanism exists.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d07e5a8. Configure here.

const childSessionManager = SessionManager.create(this._cwd, options.sessionDir);
const childSessionManager = options.parentSession.sessionManager.isPersisted()
? SessionManager.create(this._cwd, options.sessionDir)
: SessionManager.inMemory(this._cwd, options.sessionDir);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ephemeral children omit RLM depth

Medium Severity

When the parent has no sessionFile (--no-session / in-memory), the whole newSession call is skipped, so child headers never get rlmDepth. newSession already accepts an explicit rlmDepth with a missing parentSession; gating on sessionFile drops that update. Runtime depth still comes from config, but the session header stays at the root default.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d07e5a8. Configure here.

export function appendGlobalRefinement(harnessStateDir: string, result: RefinementResult): string {
const historyPath = getRefinementHistoryPath(harnessStateDir);
mkdirSync(harnessStateDir, { recursive: true });
appendFileSync(historyPath, `${JSON.stringify(result)}\n`, "utf8");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

History load throws on symlink

Medium Severity

loadGlobalRefinementHistory switched to readPrivateFile without a top-level try/catch. A symlinked history file passes existsSync but then throws from readPrivateFile, instead of degrading to an empty list like loadHarnessState does for the same private-read failure mode.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d07e5a8. Configure here.

if (!parentExisted) chmodSync(parent, PRIVATE_DIRECTORY_MODE);
} else {
ensurePrivateDirectory(parent);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Export rejects symlinked parents

Medium Severity

HTML export uses writePrivateFileAtomic with privateParent: false, but that path still refuses when the parent directory is a symlink. User export destinations often live under symlinked trees, so export can fail where plain writeFileSync previously succeeded.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d07e5a8. Configure here.

@avion23

avion23 commented Aug 10, 2026

Copy link
Copy Markdown

Thanks for picking this up. As the reporter of the underlying findings (PR #1105, now closed), here is the scope-coverage confirmation for triage:

Covered by the stack: session-ID/path containment and the dill resume RCE path (#1159), harness.py private modes (#1159), coding-agent auth-storage atomic writes (#1160), snapshot/export/state-snapshot paths (#1159/#1160).

Two findings from the original report are NOT covered by any PR in #1158-#1165:

  1. TUI log file creation modes - packages/tui/src/tui.ts and packages/tui/src/terminal.ts create log files without private modes (world-readable under default umask).
  2. pi-ai CLI auth.json write - packages/ai/src/cli.ts writes auth.json without symlink rejection (O_NOFOLLOW) or atomic exclusive creation.

Separately, our open PR #966 (snapshot-cache invalidation when worker recovery drops operations, daemon-supervisor.ts) overlaps the #1161/#1163 area; we are fine closing it if your stack covers that path.

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.

Add security/sandboxing guidance for long-running autonomous runs Pin workflow actions and minimize release-job secret scope

2 participants