Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .github/codeql/extensions/kin-store-roots/codeql-pack.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: kinlab-ai/kin-store-roots
version: 0.0.1
library: true
extensionTargets:
codeql/rust-all: '*'
dataExtensions:
- models/*.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Identifiers that cleartext-logging analysis may treat as public.
#
# CleartextLogging.ql flags values whose names look sensitive: anything
# spelled `uid`, `session_id` or `cert`. The functions below return public
# identifiers that Kin prints for people and agents on purpose: workspace,
# operation, entity and session ids (the daemon's bearer token, not a session
# id, is what authenticates a caller), a parse-coverage certificate's detail,
# which is evidence about a parse and not a cryptographic certificate, the
# supervisor endpoint a CLI talks to, and the keyring account name derived as a
# SHA-256 of the hosted URL. None of them is a credential, so their results are
# barriers for log-injection, the barrier kind CleartextLogging.ql honours.
extensions:
- addsTo:
pack: codeql/rust-all
extensible: barrierModel
data:
- ["<kin_model::ids::WorkspaceId>::from_uuid", "ReturnValue", "log-injection", "manual"]
- ["<kin_model::ids::OperationId>::from_uuid", "ReturnValue", "log-injection", "manual"]
- ["<kin_index::coverage_certificate::CertificateDetail>::from_certificate", "ReturnValue", "log-injection", "manual"]
- ["kin_mcp::handlers::common::parse_session_id", "ReturnValue", "log-injection", "manual"]
- ["kin_daemon::api::parse_session_id", "ReturnValue", "log-injection", "manual"]
- ["kin_daemon::api::parse_uuid", "ReturnValue", "log-injection", "manual"]
- ["kin_cli::daemon_client::trusted_supervisor_endpoint", "ReturnValue", "log-injection", "manual"]
- ["kin_cli::commands::auth::account_key", "ReturnValue", "log-injection", "manual"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Store roots that path-injection analysis may trust.
#
# CodeQL's axum model treats every handler parameter as remote input,
# including the `State` extractor that hands a handler the server's own
# state. Kin's servers build that state at startup from their own store, so a
# path reached through it was chosen by the server and not by a request. Each
# accessor below returns that store root or working tree, a constant name
# inside it, or (for the hosted text index) a hex digest of the repository
# id. None of them can carry caller input, so their results are barriers. A
# component a caller joins onto one of these roots is still tracked: only the
# root itself is trusted.
extensions:
- addsTo:
pack: codeql/rust-all
extensible: barrierModel
data:
- ["<kin_core::layout::KinLayout>::root", "ReturnValue", "path-injection", "manual"]
- ["<kin_core::layout::KinLayout>::working_dir", "ReturnValue", "path-injection", "manual"]
- ["<kin_core::layout::KinLayout>::kindb_dir", "ReturnValue", "path-injection", "manual"]
- ["<kin_core::layout::KinLayout>::kindb_head_generation_path", "ReturnValue", "path-injection", "manual"]
- ["<kin_core::layout::KinLayout>::kindb_hydration_semantics_path", "ReturnValue", "path-injection", "manual"]
- ["<kin_core::layout::KinLayout>::kindb_embedding_coverage_marker_path", "ReturnValue", "path-injection", "manual"]
- ["<kin_core::layout::KinLayout>::hosted_text_index_dir", "ReturnValue", "path-injection", "manual"]
- ["<kin_blobs::BlobStore>::root", "ReturnValue", "path-injection", "manual"]
- ["<kin_daemon::state::CoordinationEventLog>::path", "ReturnValue", "path-injection", "manual"]
- ["<kin_daemon::state::CoordinationEventLog>::failure_marker", "ReturnValue", "path-injection", "manual"]
30 changes: 15 additions & 15 deletions crates/kin-blobs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ impl BlobStore {
let hash = digest(data);
let hex = hash.to_string();
let shard_name = &hex[..2];
let shard_dir = self.root.join(shard_name);
let shard_dir = self.root().join(shard_name);
let blob_path = shard_dir.join(&hex[2..]);

// Deduplication: if the blob already exists, skip writing.
Expand Down Expand Up @@ -396,7 +396,7 @@ impl BlobStore {
let hash = digest(data);
hashes.push(hash);
let hex = hash.to_string();
let shard_dir = self.root.join(&hex[..2]);
let shard_dir = self.root().join(&hex[..2]);
let blob = shard_dir.join(&hex[2..]);
if blob.exists() || !staging.insert(hash) {
continue;
Expand Down Expand Up @@ -509,7 +509,7 @@ impl BlobStore {
// that names are not becoming durable on schedule.
if let Err(e) = self.sync() {
warn!(
root = %self.root.display(),
root = %self.root().display(),
error = %e,
"deferred directory barrier failed on the write drain; \
affected directories stay pending for a later sync"
Expand Down Expand Up @@ -567,18 +567,18 @@ impl BlobStore {
let mut root_unsynced = false;

if root {
match self.barrier_dir(&self.root) {
match self.barrier_dir(self.root()) {
Ok(()) => {}
// A removed root has no entry left to make durable.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
first_error = Some(BlobError::io(&self.root, e));
first_error = Some(BlobError::io(self.root(), e));
root_unsynced = true;
}
}
}
for (shard, renames) in shards {
let shard_dir = self.root.join(&shard);
let shard_dir = self.root().join(&shard);
match self.barrier_dir(&shard_dir) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !root_unsynced => {
Expand All @@ -588,14 +588,14 @@ impl BlobStore {
// does not owe a root-creation barrier, so validate the
// root lazily before forgiving the missing child even
// when `root` was false at the start of this drain.
match validate_dir_type(&self.root) {
match validate_dir_type(self.root()) {
Ok(()) => {}
// The store root itself is gone, so the child has no
// namespace entry left to make durable.
Err(root_error) if root_error.kind() == std::io::ErrorKind::NotFound => {}
Err(root_error) => {
if first_error.is_none() {
first_error = Some(BlobError::io(&self.root, root_error));
first_error = Some(BlobError::io(self.root(), root_error));
}
root_unsynced = true;
unsynced.push((shard, renames));
Expand Down Expand Up @@ -757,17 +757,17 @@ impl BlobStore {
/// that are not 2-hex-char shard directories are ignored, and a missing root
/// yields an empty result rather than an error.
pub fn list_hashes(&self) -> Result<Vec<Hash256>> {
let entries = match fs::read_dir(&self.root) {
let entries = match fs::read_dir(self.root()) {
Ok(entries) => entries,
// A store that has never been written to (or whose root was reaped by
// a prior compaction) simply holds nothing.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(BlobError::io(&self.root, e)),
Err(e) => return Err(BlobError::io(self.root(), e)),
};

let mut hashes = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| BlobError::io(&self.root, e))?;
let entry = entry.map_err(|e| BlobError::io(self.root(), e))?;
let shard = entry.file_name();
let shard = shard.to_string_lossy();
// Only real 2-hex-char shard dirs hold blobs. This skips `.corrupt`
Expand Down Expand Up @@ -867,7 +867,7 @@ impl BlobStore {
/// never removed. Errors are intentionally swallowed — a non-empty or
/// concurrently-repopulated shard simply stays, which is harmless.
fn prune_empty_shards(&self) {
let entries = match fs::read_dir(&self.root) {
let entries = match fs::read_dir(self.root()) {
Ok(entries) => entries,
Err(_) => return,
};
Expand Down Expand Up @@ -905,7 +905,7 @@ impl BlobStore {
if !blob_path.exists() {
return Ok(None);
}
let corrupt_dir = self.root.join(".corrupt");
let corrupt_dir = self.root().join(".corrupt");
fs::create_dir_all(&corrupt_dir).map_err(|e| BlobError::io(&corrupt_dir, e))?;
let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let dest = corrupt_dir.join(format!("{}-{}-{}", hash, std::process::id(), seq));
Expand Down Expand Up @@ -941,7 +941,7 @@ impl BlobStore {
/// Layout: `{root}/{hash[0..2]}/{hash[2..]}` (Git-style sharding).
fn blob_path(&self, hash: &Hash256) -> PathBuf {
let hex = hash.to_string();
self.root.join(&hex[..2]).join(&hex[2..])
self.root().join(&hex[..2]).join(&hex[2..])
}

/// Write one blob body to `path`, issuing the content barrier this store's
Expand Down Expand Up @@ -1006,7 +1006,7 @@ impl Drop for BlobStore {
fn drop(&mut self) {
if let Err(e) = self.sync() {
warn!(
root = %self.root.display(),
root = %self.root().display(),
error = %e,
"deferred directory barrier failed on the drop drain; \
renames in the affected directories may not be durable"
Expand Down
34 changes: 17 additions & 17 deletions crates/kin-core/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,17 +162,17 @@ impl KinLayout {

/// `.kin/config.toml`
pub fn config_path(&self) -> PathBuf {
self.root.join("config.toml")
self.root().join("config.toml")
}

/// `.kin/manifest.json`
pub fn manifest_path(&self) -> PathBuf {
self.root.join("manifest.json")
self.root().join("manifest.json")
}

/// `.kin/kindb/` — KinDB snapshot and index directory.
pub fn kindb_dir(&self) -> PathBuf {
self.root.join("kindb")
self.root().join("kindb")
}

/// `.kin/kindb/ingest-cas/` — non-authoritative blob staging for explicit
Expand Down Expand Up @@ -351,52 +351,52 @@ impl KinLayout {

/// `.kin/stashes/` — Named overlay snapshots.
pub fn stashes_dir(&self) -> PathBuf {
self.root.join("stashes")
self.root().join("stashes")
}

/// `.kin/backups/` — Graph snapshot backups.
pub fn backups_dir(&self) -> PathBuf {
self.root.join("backups")
self.root().join("backups")
}

/// `.kin/projections/` — Generated file/doc projections.
pub fn projections_dir(&self) -> PathBuf {
self.root.join("projections")
self.root().join("projections")
}

/// `.kin/docs/` — Living docs (AGENTS.md, ARCHITECTURE.md, etc.).
pub fn docs_dir(&self) -> PathBuf {
self.root.join("docs")
self.root().join("docs")
}

/// `.kin/bench/` — Benchmark traces and reports.
pub fn bench_dir(&self) -> PathBuf {
self.root.join("bench")
self.root().join("bench")
}

/// `.kin/runs/` — Validation run evidence.
pub fn runs_dir(&self) -> PathBuf {
self.root.join("runs")
self.root().join("runs")
}

/// `.kin/logs/` — Daemon logs.
pub fn logs_dir(&self) -> PathBuf {
self.root.join("logs")
self.root().join("logs")
}

/// `.kin/adapters/` — Assistant adapter config.
pub fn adapters_dir(&self) -> PathBuf {
self.root.join("adapters")
self.root().join("adapters")
}

/// `.kin/shallow/` — Persisted C2 shallow-syntax metadata.
pub fn shallow_dir(&self) -> PathBuf {
self.root.join("shallow")
self.root().join("shallow")
}

/// `.kin/version` — layout schema version marker.
pub fn version_path(&self) -> PathBuf {
self.root.join("version")
self.root().join("version")
}

/// Read the schema version from the `.kin/version` file.
Expand Down Expand Up @@ -489,29 +489,29 @@ impl KinLayout {

/// `.kin/sync_state.json` — persisted sync state per remote.
pub fn sync_state_path(&self) -> PathBuf {
self.root.join("sync_state.json")
self.root().join("sync_state.json")
}

/// `.kin/merge_state.json` — persisted merge conflict state.
///
/// Written by `kin merge` when conflicts are detected, read by
/// `kin conflicts` and `kin resolve`, cleared on resolution or abort.
pub fn merge_state_path(&self) -> PathBuf {
self.root.join("merge_state.json")
self.root().join("merge_state.json")
}

/// `.kin/pending-release.json` — exact serialized daemon release request
/// retained until the daemon reports durable success or definitive
/// rejection. This lets a later CLI process resume an uncertain timeout or
/// transport failure without constructing a second release marker.
pub fn pending_release_path(&self) -> PathBuf {
self.root.join("pending-release.json")
self.root().join("pending-release.json")
}

/// `.kin/pending-release.lock` — cross-process serialization for release
/// recovery, policy preflight, and marker construction.
pub fn pending_release_lock_path(&self) -> PathBuf {
self.root.join("pending-release.lock")
self.root().join("pending-release.lock")
}

/// All directories that must exist inside `.kin/`.
Expand Down
64 changes: 60 additions & 4 deletions crates/kin-daemon/src/api/tests/session_crash_prefix_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,22 @@ fn configured_control() -> Option<PathBuf> {

/// The exact operation this session was materialized under, read from the
/// retained session base rather than guessed from current state.
fn session_operation(session_dir: &Path) -> Option<OperationId> {
///
/// The hook runs before the publication validates the request, so it reads
/// only a session the publication itself would open: a directory directly
/// inside the store's runs directory once links are resolved. A request that
/// names any other directory installs no observer.
fn session_operation(runs_dir: &Path, session_dir: &Path) -> Option<OperationId> {
let runs = std::fs::canonicalize(runs_dir).ok()?;
let session = std::fs::canonicalize(session_dir).ok()?;
if !session.starts_with(&runs) {
return None;
}
if session.parent() != Some(runs.as_path()) {
return None;
}
let base: serde_json::Value =
serde_json::from_slice(&std::fs::read(session_dir.join(".kin-session/base.json")).ok()?)
serde_json::from_slice(&std::fs::read(session.join(".kin-session/base.json")).ok()?)
.ok()?;
let text = base["reconcile_operation_id"].as_str()?;
Some(OperationId::from_uuid(uuid::Uuid::parse_str(text).ok()?))
Expand All @@ -147,7 +160,7 @@ pub(crate) fn install_child_observers(
session_dir: &Path,
) -> Option<ChildObservers> {
configured_stop()?;
let operation = session_operation(session_dir)?;
let operation = session_operation(&state.layout.runs_dir(), session_dir)?;
// The retained storage capability reports its resolved namespace path, and
// a temporary root reaches that namespace through a symlinked ancestor on
// this platform, so the payload observer has to name the resolved one. The
Expand Down Expand Up @@ -436,7 +449,8 @@ async fn fixture_with_completion(label: &str, evidence: Evidence, complete: bool
let base_generation = state
.snapshot_generation
.load(std::sync::atomic::Ordering::SeqCst);
let operation = session_operation(&session).expect("materialized session base");
let operation =
session_operation(&layout.runs_dir(), &session).expect("materialized session base");

drop(app);
drop(runtime);
Expand Down Expand Up @@ -495,6 +509,48 @@ async fn install_never_committed_lead(state: &Arc<DaemonState>) -> kin_model::Re
relation.id
}

/// A request names its session directory, and the crash hook reads that
/// directory before the publication validates it, so the hook holds itself to
/// the publication's own rule: a session base is read only from a direct child
/// of the runs directory, never from a path that climbs out with `..`, an
/// absolute path elsewhere, a link that resolves outside, or a deeper entry.
#[test]
fn the_crash_hook_reads_a_session_base_only_inside_the_runs_directory() {
let store = tempfile::tempdir().unwrap();
let runs = store.path().join("runs");
let outside = store.path().join("outside");
let operation = uuid::Uuid::new_v4();
let base = serde_json::json!({ "reconcile_operation_id": operation.to_string() });
for dir in [
runs.join("session-a"),
runs.join("session-a/nested"),
outside.clone(),
] {
std::fs::create_dir_all(dir.join(".kin-session")).unwrap();
std::fs::write(dir.join(".kin-session/base.json"), base.to_string()).unwrap();
}
std::os::unix::fs::symlink(&outside, runs.join("session-link")).unwrap();

assert_eq!(
session_operation(&runs, &runs.join("session-a")),
Some(OperationId::from_uuid(operation))
);
for refused in [
runs.join("session-a/../../outside"),
outside.clone(),
runs.join("session-link"),
runs.join("session-a/nested"),
runs.clone(),
] {
assert_eq!(
session_operation(&runs, &refused),
None,
"{}",
refused.display()
);
}
}

// ---------------------------------------------------------------------------
// The child
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading