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
4 changes: 4 additions & 0 deletions crates/contextful-cli/src/admit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ pub enum AdmitError {
#[cfg_attr(not(feature = "read-plane"), allow(dead_code))]
#[error("StdioCredentialMissing: {0}")]
StdioCredentialMissing(String),
/// (`surface.package.owner-flag`)
#[cfg(feature = "read-plane")]
#[error("OwnerCredentialInvalid: {0}")]
OwnerCredentialInvalid(String),
/// (`authority.revoke.ledger-unavailable`)
#[error("KeySetLedgerUnavailable: {0}")]
KeySetLedgerUnavailable(String),
Expand Down
2 changes: 1 addition & 1 deletion crates/contextful-cli/src/differential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,7 @@ fn gen_verify(rng: &mut Rng, pools: &Pools) -> Value {
expires_at: At::from_unix_secs(expires).expect("a fixed instant"),
lifetime_secs: lifetime,
};
let claims = MintClaims { confirmation: rng.chance(10).then(|| "holder-thumbprint".to_string()), epoch: 0 };
let claims = MintClaims { confirmation: rng.chance(10).then(|| "holder-thumbprint".to_string()), epoch: 0, ..MintClaims::default() };
let mut seed = [0u8; 32];
for chunk in seed.chunks_mut(8) {
chunk.copy_from_slice(&rng.next().to_le_bytes());
Expand Down
13 changes: 12 additions & 1 deletion crates/contextful-cli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! unanchored (`disclosure.record.read-chain`), and hands them to the tool server. Every value is resolved before the first protocol line is written, so a
//! process that cannot serve exits with nothing on standard output.

use crate::admit::{face, AdmitArgs};
use crate::admit::{face, AdmitArgs, AdmitError};
use crate::project::locate;
use crate::clock::SystemClock;
use anyhow::Result;
Expand All @@ -18,6 +18,9 @@ use std::path::PathBuf;

#[derive(clap::Args)]
pub struct McpArgs {
/// Admit the selected local project under a verified signed owner claim.
#[arg(long)]
owner: bool,
/// The project whose store root is `.contextful/context/<project>/` under the working
/// directory; absent, the nearest `contextful.toml` upward names it.
#[arg(long)]
Expand All @@ -33,6 +36,14 @@ pub struct McpArgs {
pub fn run(args: McpArgs) -> Result<()> {
let (authority, revocation) = args.admit.admit(args.project.as_deref(), "the tool server")?;
let located = locate(args.project.as_deref(), args.declaration)?;
let authority = if args.owner {
let identity = crate::project::owner_identity(&located.project)
.map_err(|_| AdmitError::OwnerCredentialInvalid("the selected local store has no readable UUID".into()))?;
authority.activate_owner(&identity)
.ok_or_else(|| AdmitError::OwnerCredentialInvalid("the verified credential does not own the selected local store".into()))?
} else {
authority
};
crate::sync::pull_before_run(&located)?;
let face = face(&located)?;
let audit = AuditLog::unanchored(located.project.audit_dir())?;
Expand Down
10 changes: 10 additions & 0 deletions crates/contextful-cli/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ pub struct Located {
pub declaration: PathBuf,
}

/// The synced UUID binds an owner credential to one store instance and its clones.
pub(crate) fn owner_identity(project: &Project) -> Result<String> {
Ok(contextful_context::project::store_id(&project.store_root())?)
}

/// Legacy stores acquire the synced UUID before minting their first owner claim.
pub(crate) fn mint_owner_identity(project: &Project) -> Result<String> {
Ok(contextful_context::project::ensure_store_id(&project.store_root())?)
}

/// Resolve the project from `--project` or by discovery, and the declaration from
/// `--declaration` or beside the project (`store.init.default-declaration`).
pub fn locate(project: Option<&str>, declaration: Option<PathBuf>) -> Result<Located> {
Expand Down
22 changes: 20 additions & 2 deletions crates/contextful-cli/src/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ pub enum TokenCmd {
/// credential's confirmation claim (`authority.verify.possession-binding`).
#[arg(long)]
holder: Option<String>,
/// Sign a credential for explicit local owner admission of the discovered project.
#[arg(long)]
owner: bool,
/// Issue instant (RFC 3339); absent reads the system clock.
#[arg(long)]
now: Option<String>,
Expand Down Expand Up @@ -275,6 +278,7 @@ pub fn run(cmd: TokenCmd) -> Result<()> {
ttl,
audience,
holder,
owner,
now,
} => {
let subject = Subject { on_behalf_of, agent, host, task, zone, incognito };
Expand All @@ -289,7 +293,7 @@ pub fn run(cmd: TokenCmd) -> Result<()> {
max_response_bytes,
};
let lifetime = ttl.map_or(Lifetime::Default, Lifetime::Requested);
let token = mint_one(issuer_key.as_deref(), subject, grant, lifetime, audience, holder, now.as_deref())?;
let token = mint_one(issuer_key.as_deref(), subject, grant, lifetime, audience, holder, owner, now.as_deref())?;
println!("{token}");
Ok(())
}
Expand Down Expand Up @@ -582,6 +586,7 @@ fn mint_one(
lifetime: Lifetime,
audience: Option<String>,
holder: Option<String>,
owner: bool,
now: Option<&str>,
) -> Result<String> {
let root = Root::find()?;
Expand All @@ -595,14 +600,27 @@ fn mint_one(
let clock = FixedClock(instant_or_now(now)?);
let ctx = MintContext { node: NodeRole::Primary, signer: &signer as &dyn SigningPort, clock: &clock as &dyn Clock };
let plan = policy.check(&request, &ctx)?;
let owner_project = if owner {
#[cfg(feature = "read-plane")]
{
let project = crate::project::locate(None, None)?.project;
let identity = crate::project::mint_owner_identity(&project)?;
if !contextful_policy::verify::owner_grant(&plan.grants) {
bail!("an owner credential requires an unrestricted read grant over `*`");
}
Some(identity)
}
#[cfg(not(feature = "read-plane"))]
bail!("an owner credential requires the read-plane build profile");
} else { None };
if let Some(jkt) = holder.as_deref() {
let alphabet = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_';
if jkt.len() != 43 || !jkt.chars().all(alphabet) {
bail!("`--holder` is the 43-character base64url RFC 7638 thumbprint of an Ed25519 public key, not `{jkt}`");
}
}
let epoch = mint_epoch(&plan, &root.ledger().read()?.current_epochs());
Ok(mint(&plan, &MintClaims { confirmation: holder, epoch }, &signer)?)
Ok(mint(&plan, &MintClaims { confirmation: holder, epoch, owner_project }, &signer)?)
}

fn instant_or_now(text: Option<&str>) -> Result<Instant> {
Expand Down
127 changes: 125 additions & 2 deletions crates/contextful-cli/tests/integration/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ fn project() -> (tempfile::TempDir, String, String) {
let p = dir.path();
std::fs::create_dir_all(p.join(".contextful")).unwrap();
std::fs::write(p.join(".contextful/issuance.toml"), format!("default_audience = \"{AUD}\"\nmax_lifetime_secs = 3600\n")).unwrap();
std::fs::write(p.join("contextful.toml"), "authoring_posture = \"per_request\"\n[[pipeline.tables]]\nname = \"research/notes\"\n").unwrap();
std::fs::write(p.join("contextful.toml"), "authoring_posture = \"per_request\"\n[project]\nname = \"research\"\n[[pipeline.tables]]\nname = \"research/notes\"\n").unwrap();
std::fs::write(p.join("notes.jsonl"), "{\"note_id\":\"n1\",\"title\":\"Solar battery storage\"}\n").unwrap();
stdout(&run(p, &["context", "land", "research/notes", "--project", "research", "--rows", "notes.jsonl", "--run-id", "run-0001", "--site-id", "site-a"]));
let public = stdout(&run(p, &["token", "keygen", "--out", ".contextful/issuer.seed"]));
Expand Down Expand Up @@ -92,7 +92,7 @@ fn a_server_with_no_admissible_credential_writes_no_framing() {
assert!(!unmanifested.status.success() && unmanifested.stdout.is_empty());
}

/// Over the process transport a credential is mandatory, a capability token or an explicit owner flag; an unset one raises `StdioCredentialMissing` and does not resolve to the owner context.
/// The process transport requires a verified capability token; `--owner` selects its signed owner claim. An absent token raises `StdioCredentialMissing` before protocol framing and never resolves to owner context.
#[test]
fn a_server_with_no_credential_raises_stdio_credential_missing() {
let (dir, public, _token) = project();
Expand All @@ -114,6 +114,129 @@ fn a_server_with_no_credential_raises_stdio_credential_missing() {
}
}

/// A process `--owner` admits the local owner context only with a verified signed claim bound to the selected store and an unrestricted read grant; any other credential raises `OwnerCredentialInvalid` before protocol framing.
// spec: surface.package.owner-flag@2d754480
#[test]
fn explicit_owner_requires_a_signed_credential_for_the_selected_store() {
let (dir, public, ordinary) = project();
let root = dir.path();
let owner = stdout(&run(root, &[
"token", "mint", "--issuer-key", ".contextful/issuer.seed", "--on-behalf-of", "user://dana@acme.example",
"--zone", "on-prem:hq", "--action", "read", "--table", "*", "--ttl", "600", "--owner",
]));
let args = ["mcp", "--owner", "--project", "research", "--public-key", &public, "--audience", AUD];
let read = [json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "context.query", "arguments": { "sql": "SELECT note_id FROM \"research/notes\"" } } })];
for token in [None, Some(ordinary.as_str())] {
let refused = serve(root, &args, token, &read);
assert!(!refused.status.success() && refused.stdout.is_empty(), "ordinary or absent credentials write no framing");
let error = String::from_utf8_lossy(&refused.stderr);
assert!(error.contains(if token.is_none() { "StdioCredentialMissing" } else { "OwnerCredentialInvalid" }), "{error}");
}
let admitted = serve(root, &args, Some(&owner), &read);
assert!(admitted.status.success(), "{}", String::from_utf8_lossy(&admitted.stderr));
let answer: Value = serde_json::from_str(String::from_utf8_lossy(&admitted.stdout).trim()).unwrap();
assert_eq!(answer["result"]["structuredContent"]["rows"], json!([["n1"]]));

stdout(&run(root, &["context", "land", "research/notes", "--project", "other", "--rows", "notes.jsonl", "--run-id", "run-other", "--site-id", "site-a"]));
let other = ["mcp", "--owner", "--project", "other", "--public-key", &public, "--audience", AUD];
let refused = serve(root, &other, Some(&owner), &read);
assert!(!refused.status.success() && refused.stdout.is_empty(), "an owner credential selects one store");
assert!(String::from_utf8_lossy(&refused.stderr).contains("OwnerCredentialInvalid"));
}

#[test]
fn owner_credential_still_applies_row_zone_and_column_policies() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".contextful")).unwrap();
std::fs::write(root.join(".contextful/issuance.toml"), format!("default_audience = \"{AUD}\"\nmax_lifetime_secs = 3600\n")).unwrap();
std::fs::write(root.join("contextful.toml"), r#"authoring_posture = "per_request"
[project]
name = "research"

[[pipeline.tables]]
name = "research/notes"
[pipeline.tables.policy.rows]
predicate = "owner = subject.on_behalf_of"
[pipeline.tables.policy.columns]
title = { strategy = "drop" }

[[pipeline.tables]]
name = "research/vendor"
[pipeline.tables.policy.zone]
allow = ["public-cloud:*"]
"#).unwrap();
std::fs::write(root.join("notes.jsonl"), concat!(
"{\"note_id\":\"n1\",\"owner\":\"user://dana@acme.example\",\"title\":\"private one\"}\n",
"{\"note_id\":\"n2\",\"owner\":\"user://other@acme.example\",\"title\":\"private two\"}\n",
)).unwrap();
std::fs::write(root.join("vendor.jsonl"), "{\"vendor_id\":\"v1\"}\n").unwrap();
for (table, rows, run_id) in [("research/notes", "notes.jsonl", "run-notes"), ("research/vendor", "vendor.jsonl", "run-vendor")] {
stdout(&run(root, &["context", "land", table, "--project", "research", "--rows", rows, "--run-id", run_id, "--site-id", "site-a"]));
}
let public = stdout(&run(root, &["token", "keygen", "--out", ".contextful/issuer.seed"]));
let owner = stdout(&run(root, &[
"token", "mint", "--issuer-key", ".contextful/issuer.seed", "--on-behalf-of", "user://dana@acme.example",
"--zone", "on-prem:hq", "--action", "read", "--table", "*", "--ttl", "600", "--owner",
]));
let args = ["mcp", "--owner", "--project", "research", "--public-key", &public, "--audience", AUD];
let query = |id, sql| json!({ "jsonrpc": "2.0", "id": id, "method": "tools/call", "params": { "name": "context.query", "arguments": { "sql": sql } } });
let out = serve(root, &args, Some(&owner), &[
query(1, "SELECT note_id, title FROM \"research/notes\" ORDER BY note_id"),
query(2, "SELECT vendor_id FROM \"research/vendor\""),
]);
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let answers: Vec<Value> = String::from_utf8_lossy(&out.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert_eq!(answers.len(), 2, "{answers:?}");
assert_eq!(answers[0]["result"]["structuredContent"]["rows"], json!([["n1", ""]]), "row predicate and column mask hold under owner");
assert_eq!(answers[1]["result"]["structuredContent"]["rows"], json!([]), "zone exclusion holds under owner");
}

#[test]
fn owner_credential_does_not_follow_a_recreated_store_at_the_same_path() {
let (dir, public, _) = project();
let root = dir.path();
let owner = stdout(&run(root, &[
"token", "mint", "--issuer-key", ".contextful/issuer.seed", "--on-behalf-of", "user://dana@acme.example",
"--zone", "on-prem:hq", "--action", "read", "--table", "*", "--ttl", "600", "--owner",
]));
let identity_path = root.join(".contextful/context/research/store-id");
let first_identity = std::fs::read_to_string(&identity_path).unwrap_or_default();
std::fs::remove_dir_all(root.join(".contextful/context/research")).unwrap();
stdout(&run(root, &["init", "research"]));
stdout(&run(root, &["context", "land", "research/notes", "--project", "research", "--rows", "notes.jsonl", "--run-id", "run-0002", "--site-id", "site-a"]));
let second_identity = std::fs::read_to_string(&identity_path).unwrap_or_default();
assert!(!first_identity.is_empty() && !second_identity.is_empty() && first_identity != second_identity,
"a recreated store receives a new persistent identity");
let args = ["mcp", "--owner", "--project", "research", "--public-key", &public, "--audience", AUD];
let read = [json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "context.query", "arguments": { "sql": "SELECT note_id FROM \"research/notes\"" } } })];
let refused = serve(root, &args, Some(&owner), &read);
assert!(!refused.status.success() && refused.stdout.is_empty());
assert!(String::from_utf8_lossy(&refused.stderr).contains("OwnerCredentialInvalid"));
}

#[test]
fn owner_credential_does_not_cross_independent_stores_with_one_issuer() {
let (first, public, _) = project();
let owner = stdout(&run(first.path(), &[
"token", "mint", "--issuer-key", ".contextful/issuer.seed", "--on-behalf-of", "user://dana@acme.example",
"--zone", "on-prem:hq", "--action", "read", "--table", "*", "--ttl", "600", "--owner",
]));
let second = tempfile::tempdir().unwrap();
let root = second.path();
std::fs::create_dir_all(root.join(".contextful")).unwrap();
for path in ["contextful.toml", "notes.jsonl", ".contextful/issuance.toml", ".contextful/issuer.seed"] {
std::fs::copy(first.path().join(path), root.join(path)).unwrap();
}
stdout(&run(root, &["init", "research"]));
stdout(&run(root, &["context", "land", "research/notes", "--project", "research", "--rows", "notes.jsonl", "--run-id", "run-0001", "--site-id", "site-a"]));
let args = ["mcp", "--owner", "--project", "research", "--public-key", &public, "--audience", AUD];
let read = [json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "context.query", "arguments": { "sql": "SELECT note_id FROM \"research/notes\"" } } })];
let refused = serve(root, &args, Some(&owner), &read);
assert!(!refused.status.success() && refused.stdout.is_empty());
assert!(String::from_utf8_lossy(&refused.stderr).contains("OwnerCredentialInvalid"));
}

/// The server admits over the stdio pipe it inherited: a credential binding no key admits
/// through that pipe, and one binding a holder key and presenting no holder proof refuses.
#[test]
Expand Down
40 changes: 39 additions & 1 deletion crates/contextful-context/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

use crate::error::{IoPath, Result};
use contextful_core::issue::AuthoringPosture;
use contextful_core::store::lay_out::{is_path_segment, store_root};
use contextful_core::store::lay_out::{is_path_segment, store_root, STORE_ID_FILE};
use contextful_core::store::StoreError;
use std::fs;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -46,6 +46,43 @@ impl Project {
}
}

/// Read one persistent UUID from the synced store root.
pub fn store_id(root: &Path) -> Result<String> {
let path = root.join(STORE_ID_FILE);
let raw = fs::read_to_string(&path).at(&path)?;
let id = raw.trim();
let valid = id.len() == 36 && id.bytes().enumerate().all(|(i, b)| match i {
8 | 13 | 18 | 23 => b == b'-',
14 => b == b'4',
19 => matches!(b, b'8' | b'9' | b'a' | b'b'),
_ => b.is_ascii_digit() || (b'a'..=b'f').contains(&b),
});
if !valid {
return Err(StoreError::StoreIdentityInvalid(format!("`{}` holds no canonical version-4 UUID", path.display())).into());
}
Ok(id.to_string())
}

/// Keep an existing UUID, or publish a new one by exclusive creation.
pub fn ensure_store_id(root: &Path) -> Result<String> {
match store_id(root) {
Ok(id) => return Ok(id),
Err(crate::ContextError::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
if !root.is_dir() {
return Err(StoreError::StoreIdentityInvalid(format!("`{}` is not an initialized store root", root.display())).into());
}
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).map_err(|e| crate::ContextError::Invalid(format!("store UUID entropy unavailable: {e}")))?;
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
let hex = |slice: &[u8]| slice.iter().map(|b| format!("{b:02x}")).collect::<String>();
let id = format!("{}-{}-{}-{}-{}", hex(&bytes[..4]), hex(&bytes[4..6]), hex(&bytes[6..8]), hex(&bytes[8..10]), hex(&bytes[10..]));
crate::store::create_new_file(&root.join(STORE_ID_FILE), format!("{id}\n").as_bytes())?;
store_id(root)
}

/// What an init did to the directory.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Initialized {
Expand Down Expand Up @@ -131,6 +168,7 @@ pub fn init(dir: &Path, name: &str) -> Result<Initialized> {
};
let root = dir.join(store_root(name));
fs::create_dir_all(&root).at(&root)?;
ensure_store_id(&root)?;
Ok(outcome)
}

Expand Down
Loading