Repository navigation
feat(core): add checkpoint namespaces - #142
kaihao-zhao wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
2 issues found.
About Unblocked
Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.
📖 Documentation — Learn more in our docs.
💬 Ask questions — Mention @unblocked-local-kaihao to request a review or summary, or ask follow-up questions.
👍 Give feedback — React to comments with 👍 or 👎 to help us improve.
⚙️ Customize — Adjust settings in your preferences.
| public RunnableConfig withCheckpointNamespace(String checkpointNamespace) { | ||
| if (Objects.equals(this.threadId, checkpointNamespace)) { | ||
| return this; | ||
| } | ||
| return RunnableConfig.builder(this) | ||
| .checkpointNamespace(checkpointNamespace) | ||
| .build(); | ||
| } |
There was a problem hiding this comment.
The early-return guard compares the wrong field: this.threadId is compared against the new checkpointNamespace instead of this.checkpointNamespace. This causes two correctness problems:
- When the existing
threadIdhappens to equal the requested namespace, the method returnsthiswithout changing the namespace at all (silent no-op). - When the namespace is already set to the requested value but differs from the threadId, the method needlessly builds a new instance instead of returning
this.
This mirrors the correct pattern in withCheckPointId (line 152), which compares this.checkPointId against the argument. The field should be this.checkpointNamespace.
public RunnableConfig withCheckpointNamespace(String checkpointNamespace) {
if (Objects.equals(this.checkpointNamespace, checkpointNamespace)) {
return this;
}
return RunnableConfig.builder(this)
.checkpointNamespace(checkpointNamespace)
.build();
}| private Path getNamespaceFolder(RunnableConfig config) { | ||
| return config.checkpointNamespace() | ||
| .map(targetFolder::resolve) | ||
| .orElse(targetFolder); | ||
| } |
There was a problem hiding this comment.
getNamespaceFolder passes the raw checkpointNamespace directly into targetFolder::resolve. A namespace containing path separators (e.g. ../sensitive or /etc) resolves to a location outside targetFolder, and serialize (line 90) then calls Files.createDirectories(outFilePath.getParent()) and writes the checkpoint file there. Because releaseCheckpoints lists and writes backup files in that same folder, an attacker-controlled namespace can read/write arbitrary paths reachable by the process.
The API documentation added in this PR (core-library.md) explicitly promises that "Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores," but no validation or trimming exists anywhere in the code path. Either the documented contract must be enforced (reject/sanitize names containing /, \, .., or non-allowed characters before resolving), or the docs should be corrected. At minimum, normalize the namespace and verify the resolved path stays within targetFolder:
private Path getNamespaceFolder(RunnableConfig config) {
var ns = config.checkpointNamespace().map(String::trim).orElse(null);
if (ns == null || ns.isEmpty() || !ns.matches("[A-Za-z0-9_-]+")) {
return targetFolder;
}
var folder = targetFolder.resolve(ns).normalize();
if (!folder.startsWith(targetFolder.normalize())) {
throw new IllegalArgumentException("Invalid checkpoint namespace: " + ns);
}
return folder;
}
Risk AssessmentFileSystemSaver uses the configured namespace directly as a filesystem path without the trimming or validation claimed by the documentation, so path-like namespace values can escape the target folder and undermine isolation. Also, withCheckpointNamespace compares the requested namespace to threadId, which can incorrectly prevent an update. |
There was a problem hiding this comment.
9 issues found.
About Unblocked
Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.
📖 Documentation — Learn more in our docs.
💬 Ask questions — Mention @dev-unblocked to request a review or summary, or ask follow-up questions.
👍 Give feedback — React to comments with 👍 or 👎 to help us improve.
⚙️ Customize — Adjust settings in your preferences.
| */ | ||
| public static class Builder extends HasMetadata.Builder<Builder> { | ||
| private String threadId; | ||
| private String checkpointNamespace; |
There was a problem hiding this comment.
The new checkpointNamespace field was added to Builder, but the copy constructor Builder(RunnableConfig config) (RunnableConfig.java:285) was not updated — it copies only threadId, checkPointId, nextNode, and streamMode. As a result, every config copy made through that constructor silently loses the namespace. This affects updateMetadata, removeMetadata, withCheckPointId, and withStreamMode.
Critically, CompiledGraph reassigns the config on every node step via config = updateRunnableConfigMetadata(config, ...) (CompiledGraph.java:953), which goes through updateMetadata → new RunnableConfig.Builder(this). So during a normal invoke/stream run with a namespaced config, the namespace is dropped before the first checkpoint is saved (addCheckpoint → saver.put(config, cp)), and all checkpoints land in the default namespace — tenant isolation silently fails in the primary execution path, and the new tests never exercise a full graph run so they don't catch it.
Fix: add the field to the copy constructor.
Builder( RunnableConfig config ) {
super( requireNonNull(config, "config cannot be null!").metadata );
this.threadId = config.threadId;
this.checkpointNamespace = config.checkpointNamespace;
this.checkPointId = config.checkPointId;
this.nextNode = config.nextNode;
this.streamMode = config.streamMode;
}| * @return a configuration using the requested namespace | ||
| */ | ||
| public RunnableConfig withCheckpointNamespace(String checkpointNamespace) { | ||
| if (Objects.equals(this.threadId, checkpointNamespace)) { |
There was a problem hiding this comment.
The short-circuit guard compares the wrong field: this.threadId against the new namespace instead of this.checkpointNamespace. Whenever the thread ID happens to equal the requested namespace (e.g. config.withCheckpointNamespace("conversation-42") on a config whose threadId is "conversation-42"), the method returns this and the namespace is silently not applied — checkpoints continue in the old namespace. It also rebuilds a new instance even when the namespace is already equal. Compare the field being replaced, as withCheckPointId does:
| if (Objects.equals(this.threadId, checkpointNamespace)) { | |
| if (Objects.equals(this.checkpointNamespace, checkpointNamespace)) { |
| private Path getNamespaceFolder(RunnableConfig config) { | ||
| return config.checkpointNamespace() | ||
| .map(targetFolder::resolve) | ||
| .orElse(targetFolder); | ||
| } |
There was a problem hiding this comment.
getNamespaceFolder feeds the raw, unvalidated namespace string into targetFolder::resolve. A namespace containing .. segments (e.g. "../../shared") or an absolute path resolves outside targetFolder, so serialize (via the new Files.createDirectories) will create directories and write checkpoint files anywhere the process can write, and deleteFile can delete files outside the checkpoint root. Nothing in Builder.checkpointNamespace trims or validates the value (the docs in core-library.md even claim such validation exists — see the separate doc comment). Validate the namespace before using it as a path segment, e.g. reject anything not matching [A-Za-z0-9_-]+ in the builder (or at least here), which also matches the documented contract.
| final var checkpointKey = checkpointKey(config); | ||
| return new Tag( threadId(config), _checkpointsByThread.remove( checkpointKey ) ); |
There was a problem hiding this comment.
MemorySaver now namespaces its map via checkpointKey, and VersionedMemorySaver delegates get/list/put/release to it — so those paths became tenant-isolated by this change. But VersionedMemorySaver.release (VersionedMemorySaver.java:178) records the released Tag into _checkpointsHistoryByThread keyed by the raw threadId only. Two tenants using the same thread ID in different namespaces therefore share one version history: tenant A's released checkpoints become visible in tenant B's history via getCheckpointsByVersion/versionsByThreadId (studio time-travel support). The class is now internally inconsistent — current checkpoints isolated, released history not. Key the history by checkpointKey(config) as well.
| default String checkpointKey(RunnableConfig config) { | ||
| return "%s:%s".formatted(checkpointNamespace(config), threadId(config)); |
There was a problem hiding this comment.
checkpointKey joins namespace and thread ID with a plain ":" into a flat string key. Since neither component is restricted from containing :, distinct pairs collide: namespace "a" + threadId "b:c" yields the same key "a:b:c" as namespace "a:b" + threadId "c" (and the default namespace "a" with threadId "$default:b" collides with namespace "$default" + threadId "a:b"-style combinations). In MemorySaver a collision means one tenant reads or removes another tenant's checkpoint list. Use a separator that cannot appear in either component (e.g. URL-encode the parts, or key the map by a namespace/threadId record).
| return config.checkpointNamespace() | ||
| .map(targetFolder::resolve) | ||
| .orElse(targetFolder); |
There was a problem hiding this comment.
getNamespaceFolder reads config.checkpointNamespace() directly instead of the interface's checkpointNamespace(config) default, which maps an absent namespace to CHECKPOINT_NAMESPACE_DEFAULT ("$default"). So an omitted namespace stores at targetFolder/ root (backward compatible), but a config that explicitly sets .checkpointNamespace("$default") — using the public constant this same PR introduces — stores under targetFolder/$default/. The two configs are the same logical namespace per BaseCheckpointSaver.checkpointNamespace, yet FileSystemSaver puts them in two different physical locations, and MemorySaver (which does use checkpointKey) treats them identically. Either treat the explicit default the same as omitted (map "$default" to targetFolder), or document that the constant must not be set explicitly.
| /** | ||
| * Returns the namespace used to partition checkpoints. | ||
| * | ||
| * @return the configured namespace, or {@code $default} when no namespace is set |
There was a problem hiding this comment.
The accessor's contract says "@return the configured namespace, or {@code $default} when no namespace is set", but the implementation returns ofNullable(checkpointNamespace) — an empty Optional when unset. The $default fallback only exists on BaseCheckpointSaver.checkpointNamespace(config). Correct the javadoc to match the actual shape:
| * @return the configured namespace, or {@code $default} when no namespace is set | |
| * @return the configured namespace, or an empty {@code Optional} when no namespace is set |
| | Attribute | Type | Description | | ||
| | --------- | ---- | ----------- | | ||
| | **threadId** | `String` | A unique identifier for the execution thread/session. Essential for checkpoint-based persistence, as it groups related executions together. Allows resuming interrupted graphs or maintaining conversation history. | | ||
| | **checkpointNamespace** | `String` | Optional partition for checkpoint data. Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores. | |
There was a problem hiding this comment.
The new table row states "Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores", but Builder.checkpointNamespace (RunnableConfig.java:314) assigns the raw string with no trimming and no charset check, and no saver validates it either. The doc promises a guarantee the code does not provide — and the missing validation is what lets a namespace like "../.." escape the checkpoint folder in FileSystemSaver (see the security comment there). Either implement the documented validation in the builder or remove the claim from the docs.
| @Override | ||
| protected Tag releaseCheckpoints(RunnableConfig config, LinkedList<Checkpoint> checkpoints) throws Exception { | ||
| final var currentPath = getPath(config); | ||
| final var namesapceFolder = getNamespaceFolder(config); |
There was a problem hiding this comment.
The new local variable is spelled namesapceFolder (transposed letters). It is used in three places below; rename to namespaceFolder for consistency with getNamespaceFolder.
| final var namesapceFolder = getNamespaceFolder(config); | |
| final var namespaceFolder = getNamespaceFolder(config); |
Risk Assessment
|
Adds checkpoint namespace support to RunnableConfig and applies namespace isolation to the built-in memory and filesystem checkpoint savers. Includes API documentation, backward-compatible default behavior, and focused tests for tenant isolation. Test command: ./mvnw -pl langgraph4j-core test.