From ddb16599f65aa7f62b2eea416a3bb88475f93aca Mon Sep 17 00:00:00 2001 From: Mickey Scherrer <5324300+iicky@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:57:19 -0400 Subject: [PATCH 1/3] enforce agent policy in python and node bindings --- node/README.md | 4 ++ node/__test__/index.test.mjs | 94 ++++++++++++++++++++++++++++++++++++ node/index.d.ts | 14 +++++- node/src/lib.rs | 35 +++++++++++--- python/README.md | 4 ++ python/tests/conftest.py | 62 ++++++++++++++++++++++++ python/tests/test_murk.py | 34 +++++++++++++ src/lib.rs | 2 +- src/policy.rs | 93 ++++++++++++++++++++++++++++++++++- src/python.rs | 44 +++++++++++++---- 10 files changed, 365 insertions(+), 21 deletions(-) diff --git a/node/README.md b/node/README.md index 59dd242..b25c9da 100644 --- a/node/README.md +++ b/node/README.md @@ -78,6 +78,10 @@ Check if a `MURK_KEY` is available in the environment. Scoped (per-user) overrides are applied automatically — if you have a scoped value for a key, it takes priority over the shared value. +## Agent policy + +When the loaded key is an agent grant (minted with `murk agent grant`), the vault's agent policy is enforced on read, the same way the CLI enforces it at `murk agent exec`: `get()` and `export()` throw if the policy forbids a key. Operator keys are unaffected. This makes a policy vault strict from every entry point — though an agent already cannot decrypt out-of-scope secrets at all, since its ephemeral key is not a recipient of them. + ## Requirements - Node.js >= 16 diff --git a/node/__test__/index.test.mjs b/node/__test__/index.test.mjs index 557ae43..e789f0e 100644 --- a/node/__test__/index.test.mjs +++ b/node/__test__/index.test.mjs @@ -70,6 +70,65 @@ function setupVault() { return { dir, murkKey } } +// Build a vault with an agent policy and a granted agent identity, so we can +// prove the bindings enforce the same policy the CLI applies at `agent exec`. +function setupAgentVault() { + const dir = mkdtempSync(join(tmpdir(), 'murk-node-agent-')) + const run = (cmd, input, extraEnv) => + execSync(cmd, { + cwd: dir, + input, + env: { + ...process.env, + PATH: `${join(process.cwd(), '..', 'target', 'release')}:${process.env.PATH}`, + ...extraEnv, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + + run(`${murkBin} init --vault .murk`, 'agentowner\n') + + // Read operator key from .env. + const dotenv = readFileSync(join(dir, '.env'), 'utf8') + let opKey + for (const line of dotenv.split('\n')) { + if (line.startsWith('export MURK_KEY_FILE=')) { + const keyFile = line + .split('=')[1] + .trim() + .replace(/^['"]|['"]$/g, '') + opKey = readFileSync(keyFile, 'utf8').trim() + break + } + if (line.startsWith('export MURK_KEY=')) { + opKey = line + .split('=')[1] + .trim() + .replace(/^['"]|['"]$/g, '') + break + } + } + + const opEnv = { MURK_KEY: opKey } + run(`${murkBin} add AGENT_DB --vault .murk`, 'postgres://agent\n', opEnv) + run(`${murkBin} add PROD_DB --vault .murk`, 'postgres://prod\n', opEnv) + run(`${murkBin} describe AGENT_DB "agent db" --tag agents --vault .murk`, '', opEnv) + run(`${murkBin} describe PROD_DB "prod db" --tag prod --vault .murk`, '', opEnv) + run(`${murkBin} policy set --allow-tag agents --vault .murk`, '', opEnv) + run( + `${murkBin} agent grant --name codex --only AGENT_DB --out agent.key --vault .murk`, + '', + opEnv, + ) + const agentKey = readFileSync(join(dir, 'agent.key'), 'utf8').trim() + + // Tightening the policy to drop the `agents` tag leaves the agent's scoped + // ciphertext in place — the crypto still works, but policy should now refuse. + const tightenPolicy = () => run(`${murkBin} policy set --allow-tag prod --vault .murk`, '', opEnv) + + return { dir, opKey, agentKey, tightenPolicy } +} + let testDir, testKey // Setup @@ -165,8 +224,43 @@ test('load with missing vault throws', () => { assert.throws(() => load('/nonexistent/.murk')) }) +// Agent policy enforcement: a granted agent identity is gated by the vault's +// policy from the binding, just like the CLI gates it at `agent exec`. +console.log('\nSetting up agent policy vault...') +const agent = setupAgentVault() +const agentVault = join(agent.dir, '.murk') + +test('agent reads an in-scope, policy-allowed key', () => { + process.env.MURK_KEY = agent.agentKey + assert.strictEqual(get('AGENT_DB', agentVault), 'postgres://agent') +}) + +test('agent cannot decrypt an out-of-scope key (crypto boundary)', () => { + process.env.MURK_KEY = agent.agentKey + assert.strictEqual(get('PROD_DB', agentVault), null) +}) + +test('agent export returns only its scoped, allowed keys', () => { + process.env.MURK_KEY = agent.agentKey + const secrets = load(agentVault).export() + assert.deepStrictEqual(Object.keys(secrets), ['AGENT_DB']) + assert.strictEqual(secrets.AGENT_DB, 'postgres://agent') +}) + +test('tightening the policy retroactively blocks the agent get', () => { + agent.tightenPolicy() + process.env.MURK_KEY = agent.agentKey + assert.throws(() => get('AGENT_DB', agentVault), /policy forbids/) +}) + +test('tightening the policy retroactively blocks the agent export', () => { + process.env.MURK_KEY = agent.agentKey + assert.throws(() => load(agentVault).export(), /policy forbids/) +}) + // Cleanup rmSync(testDir, { recursive: true, force: true }) +rmSync(agent.dir, { recursive: true, force: true }) console.log(`\n${passed} passed, ${failed} failed`) if (failed > 0) process.exit(1) diff --git a/node/index.d.ts b/node/index.d.ts index a7fb159..f4da497 100644 --- a/node/index.d.ts +++ b/node/index.d.ts @@ -3,14 +3,19 @@ /** A loaded and decrypted murk vault. */ export declare class Vault { /** - * Get a single decrypted secret value. - * Returns the scoped override if one exists, otherwise the shared value. + * Get a single decrypted secret value. Resolution order: a personal scoped + * override, then a named-group value we can read, then the shared value. * * Internally, vault state stores values in `Zeroizing` so plaintext * is wiped from memory when dropped. Crossing the napi boundary into a * JavaScript `String` requires copying the plaintext into a regular Rust * `String`; the V8 garbage collector owns it from there and zeroize cannot * follow. This is a known leak in the JS bindings — see THREAT_MODEL.md. + * + * When the loaded identity is a granted agent, the vault's agent policy is + * enforced before the value is returned — the same gate the CLI applies at + * `agent exec`. Throws if policy forbids the key. For an operator identity + * this is a no-op. */ get(key: string): string | null /** @@ -18,6 +23,11 @@ export declare class Vault { * * See `get` for the zeroize caveat — the returned `HashMap` holds plain * `String` plaintext, not `Zeroizing`. + * + * For a granted agent, the vault's agent policy is enforced over the full + * key set first (mirroring `murk agent exec`): if any resolvable key is + * outside the policy, the whole export throws rather than returning a + * partial object. For an operator identity this is a no-op. */ export(): Record /** List all key names. */ diff --git a/node/src/lib.rs b/node/src/lib.rs index 5e0ad8f..53c9c07 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -31,21 +31,42 @@ impl Vault { /// JavaScript `String` requires copying the plaintext into a regular Rust /// `String`; the V8 garbage collector owns it from there and zeroize cannot /// follow. This is a known leak in the JS bindings — see THREAT_MODEL.md. + /// + /// When the loaded identity is a granted agent, the vault's agent policy is + /// enforced before the value is returned — the same gate the CLI applies at + /// `agent exec`. Throws if policy forbids the key. For an operator identity + /// this is a no-op. #[napi] - pub fn get(&self, key: String) -> Option { - murk_cli::get_secret(&self.murk, &key, &self.pubkey).map(str::to_string) + pub fn get(&self, key: String) -> napi::Result> { + let value = murk_cli::get_secret(&self.murk, &key, &self.pubkey).map(str::to_string); + // Only enforce when there is a value to hand back: a key the agent + // cannot decrypt is already inaccessible, so policy is moot. + if value.is_some() { + murk_cli::enforce_agent_policy(&self.vault, &self.murk, &self.pubkey, &[key]) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + } + Ok(value) } /// Export all secrets as an object. Scoped values override shared values. /// /// See `get` for the zeroize caveat — the returned `HashMap` holds plain /// `String` plaintext, not `Zeroizing`. + /// + /// For a granted agent, the vault's agent policy is enforced over the full + /// key set first (mirroring `murk agent exec`): if any resolvable key is + /// outside the policy, the whole export throws rather than returning a + /// partial object. For an operator identity this is a no-op. #[napi] - pub fn export(&self) -> HashMap { - murk_cli::resolve_secrets(&self.vault, &self.murk, &self.pubkey, &[]) + pub fn export(&self) -> napi::Result> { + let resolved = murk_cli::resolve_secrets(&self.vault, &self.murk, &self.pubkey, &[]); + let keys: Vec = resolved.keys().cloned().collect(); + murk_cli::enforce_agent_policy(&self.vault, &self.murk, &self.pubkey, &keys) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(resolved .into_iter() .map(|(k, v)| (k, v.to_string())) - .collect() + .collect()) } /// List all key names. @@ -86,13 +107,13 @@ pub fn load(vault_path: Option) -> napi::Result { /// One-liner: load the vault and get a single key. #[napi] pub fn get(key: String, vault_path: Option) -> napi::Result> { - Ok(load(vault_path)?.get(key)) + load(vault_path)?.get(key) } /// One-liner: load the vault and export all secrets as an object. #[napi] pub fn export_all(vault_path: Option) -> napi::Result> { - Ok(load(vault_path)?.export()) + load(vault_path)?.export() } /// Check if a MURK_KEY is available in the environment. diff --git a/python/README.md b/python/README.md index e3e3da2..d014cfb 100644 --- a/python/README.md +++ b/python/README.md @@ -80,6 +80,10 @@ Check if a `MURK_KEY` is available in the environment. Scoped (per-user) overrides are applied automatically — if you have a scoped value for a key, it takes priority over the shared value. +## Agent policy + +When the loaded key is an agent grant (minted with `murk agent grant`), the vault's agent policy is enforced on read, the same way the CLI enforces it at `murk agent exec`: `get()` and `export()` raise `RuntimeError` if the policy forbids a key. Operator keys are unaffected. This makes a policy vault strict from every entry point — though an agent already cannot decrypt out-of-scope secrets at all, since its ephemeral key is not a recipient of them. + ## Environment Set one of: diff --git a/python/tests/conftest.py b/python/tests/conftest.py index 0da3b70..f9ad8fa 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -65,3 +65,65 @@ def vault_dir(): ) yield {"path": tmpdir, "key": murk_key} + + +@pytest.fixture() +def agent_vault_dir(): + """A vault with an agent policy and a granted agent identity. + + Lets tests prove the bindings enforce the same policy the CLI applies at + `agent exec`. Yields the agent's key, the vault path, and a ``tighten`` + callable that drops the agent's tag from the policy (the agent's scoped + ciphertext lingers, so the crypto still works but policy should now refuse). + """ + murk_bin = Path(__file__).resolve().parents[2] / "target" / "release" / "murk" + if not murk_bin.exists(): + pytest.skip("murk binary not found — run cargo build --release first") + + with tempfile.TemporaryDirectory() as tmpdir: + env = {**os.environ, "PATH": f"{murk_bin.parent}:{os.environ['PATH']}"} + env.pop("MURK_KEY", None) + env.pop("MURK_KEY_FILE", None) + + def run(args, stdin=""): + subprocess.run( + [str(murk_bin), *args, "--vault", ".murk"], + input=stdin, + capture_output=True, + text=True, + cwd=tmpdir, + env=env, + check=True, + ) + + run(["init"], "agentowner\n") + + dot_env = Path(tmpdir) / ".env" + for line in dot_env.read_text().splitlines(): + if line.startswith("export MURK_KEY_FILE="): + key_file = line.split("=", 1)[1].strip().strip("'\"") + op_key = Path(key_file).read_text().strip() + break + elif line.startswith("export MURK_KEY="): + op_key = line.split("=", 1)[1].strip().strip("'\"") + break + else: + pytest.fail("Could not find MURK_KEY in .env") + + env["MURK_KEY"] = op_key + run(["add", "AGENT_DB"], "postgres://agent\n") + run(["add", "PROD_DB"], "postgres://prod\n") + run(["describe", "AGENT_DB", "agent db", "--tag", "agents"]) + run(["describe", "PROD_DB", "prod db", "--tag", "prod"]) + run(["policy", "set", "--allow-tag", "agents"]) + run(["agent", "grant", "--name", "codex", "--only", "AGENT_DB", "--out", "agent.key"]) + agent_key = (Path(tmpdir) / "agent.key").read_text().strip() + + def tighten(): + run(["policy", "set", "--allow-tag", "prod"]) + + yield { + "vault": str(Path(tmpdir) / ".murk"), + "agent_key": agent_key, + "tighten": tighten, + } diff --git a/python/tests/test_murk.py b/python/tests/test_murk.py index b8f14dd..fe1538f 100644 --- a/python/tests/test_murk.py +++ b/python/tests/test_murk.py @@ -2,6 +2,8 @@ import os +import pytest + import murk @@ -146,6 +148,38 @@ def test_repr(self, vault_dir): assert "1 recipients" in r +class TestAgentPolicy: + def test_agent_reads_allowed_key(self, agent_vault_dir): + os.environ["MURK_KEY"] = agent_vault_dir["agent_key"] + vault = murk.load(agent_vault_dir["vault"]) + assert vault.get("AGENT_DB") == "postgres://agent" + + def test_agent_cannot_read_out_of_scope_key(self, agent_vault_dir): + # Not a recipient of PROD_DB — the crypto boundary returns None. + os.environ["MURK_KEY"] = agent_vault_dir["agent_key"] + vault = murk.load(agent_vault_dir["vault"]) + assert vault.get("PROD_DB") is None + + def test_agent_export_only_scoped_allowed_keys(self, agent_vault_dir): + os.environ["MURK_KEY"] = agent_vault_dir["agent_key"] + vault = murk.load(agent_vault_dir["vault"]) + assert vault.export() == {"AGENT_DB": "postgres://agent"} + + def test_tightened_policy_blocks_get(self, agent_vault_dir): + agent_vault_dir["tighten"]() + os.environ["MURK_KEY"] = agent_vault_dir["agent_key"] + vault = murk.load(agent_vault_dir["vault"]) + with pytest.raises(RuntimeError, match="policy forbids"): + vault.get("AGENT_DB") + + def test_tightened_policy_blocks_export(self, agent_vault_dir): + agent_vault_dir["tighten"]() + os.environ["MURK_KEY"] = agent_vault_dir["agent_key"] + vault = murk.load(agent_vault_dir["vault"]) + with pytest.raises(RuntimeError, match="policy forbids"): + vault.export() + + class TestHasKey: def test_has_key_true(self, vault_dir): os.environ["MURK_KEY"] = vault_dir["key"] diff --git a/src/lib.rs b/src/lib.rs index 5b7d6ac..058e92a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -69,7 +69,7 @@ pub use groups::{ pub use info::{InfoEntry, VaultInfo, format_info_lines, lifecycle_segment, vault_info}; pub use init::{DiscoveredKey, InitStatus, check_init_status, create_vault, discover_existing_key}; pub use merge::{MergeDriverOutput, run_merge_driver}; -pub use policy::check_agent_keys; +pub use policy::{check_agent_keys, enforce_agent_policy, is_agent_identity}; pub use recipients::{ RecipientEntry, RevokeResult, authorize_recipient, format_recipient_lines, key_type_label, list_recipients, revoke_recipient, truncate_pubkey, diff --git a/src/policy.rs b/src/policy.rs index 78a9b61..b27196d 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -10,7 +10,7 @@ //! policy is set it is default-deny — untagged or wrong-tagged keys are refused. use crate::error::MurkError; -use crate::types::{Policy, Vault}; +use crate::types::{Murk, Policy, Vault}; /// Check that every key in `keys` is permitted to agents by the vault's policy. /// @@ -45,6 +45,42 @@ pub fn check_agent_keys(vault: &Vault, keys: &[String]) -> Result<(), MurkError> ))) } +/// True when `pubkey` identifies a granted agent for this decrypted vault state. +/// +/// Agent grants live in the encrypted meta and are carried into [`Murk::grants`] +/// after decryption, so this is the same "am I an agent" test the CLI makes when +/// it decrypts as an agent (`lib::decrypt_vault`). An operator (or any plain +/// recipient) is not in `grants`, so this returns `false` for them. +pub fn is_agent_identity(murk: &Murk, pubkey: &str) -> bool { + murk.grants.values().any(|g| g.pubkey == pubkey) +} + +/// Apply [`check_agent_keys`], but only when the caller is a granted agent. +/// +/// The library bindings (Python/Node) load a vault and read secrets directly, +/// without the CLI's `agent exec` policy gate. This is that gate for them: when +/// the loaded identity is an agent grant, the same policy the CLI enforces at +/// `agent exec` applies here too — so a policy vault is strict from every entry +/// point. For an operator identity it is a no-op, matching the CLI, where plain +/// `get`/`export` are never policy-gated. +/// +/// The real boundary is cryptographic: an agent's ephemeral key is not a +/// recipient of out-of-scope secrets, so it cannot decrypt them regardless. This +/// check is defense-in-depth, and it makes a later policy or tag change apply to +/// agents retroactively at read time (the agent's old scoped ciphertext lingers, +/// but the binding refuses to hand it over). +pub fn enforce_agent_policy( + vault: &Vault, + murk: &Murk, + pubkey: &str, + keys: &[String], +) -> Result<(), MurkError> { + if is_agent_identity(murk, pubkey) { + check_agent_keys(vault, keys)?; + } + Ok(()) +} + /// True if `key` carries at least one of the policy's allowed tags. fn key_allowed(vault: &Vault, policy: &Policy, key: &str) -> bool { vault.schema.get(key).is_some_and(|entry| { @@ -58,9 +94,24 @@ fn key_allowed(vault: &Vault, policy: &Policy, key: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::types::{Policy, SchemaEntry, Vault}; + use crate::types::{GrantEntry, Murk, Policy, SchemaEntry, Vault}; use std::collections::BTreeMap; + fn agent_murk(pubkey: &str) -> Murk { + let mut grants = BTreeMap::new(); + grants.insert( + "codex".to_string(), + GrantEntry { + pubkey: pubkey.to_string(), + ..Default::default() + }, + ); + Murk { + grants, + ..Default::default() + } + } + fn vault_with(tags: &[(&str, &[&str])], policy: Option) -> Vault { let mut schema = BTreeMap::new(); for (key, key_tags) in tags { @@ -130,4 +181,42 @@ mod tests { let err = check_agent_keys(&v, &["TEST_KEY".into()]).unwrap_err(); assert!(err.to_string().contains("locks agents out")); } + + #[test] + fn is_agent_identity_matches_granted_pubkey() { + let murk = agent_murk("age1agent"); + assert!(is_agent_identity(&murk, "age1agent")); + assert!(!is_agent_identity(&murk, "age1operator")); + assert!(!is_agent_identity(&Murk::default(), "age1agent")); + } + + #[test] + fn enforce_agent_policy_is_noop_for_operator() { + // A policy that would forbid PROD_DB, but the caller is not an agent. + let v = vault_with(&[("PROD_DB", &["production"])], Some(policy(&["agents"]))); + let operator = Murk::default(); + assert!(enforce_agent_policy(&v, &operator, "age1operator", &["PROD_DB".into()]).is_ok()); + } + + #[test] + fn enforce_agent_policy_applies_to_agents() { + let v = vault_with( + &[("PROD_DB", &["production"]), ("TEST_KEY", &["agents"])], + Some(policy(&["agents"])), + ); + let agent = agent_murk("age1agent"); + // Allowed key passes. + assert!(enforce_agent_policy(&v, &agent, "age1agent", &["TEST_KEY".into()]).is_ok()); + // Forbidden key is refused for the agent. + let err = enforce_agent_policy(&v, &agent, "age1agent", &["PROD_DB".into()]).unwrap_err(); + assert!(err.to_string().contains("PROD_DB")); + } + + #[test] + fn enforce_agent_policy_noop_without_policy() { + // No policy set: even an agent reads anything (backward compatible). + let v = vault_with(&[("PROD_DB", &["production"])], None); + let agent = agent_murk("age1agent"); + assert!(enforce_agent_policy(&v, &agent, "age1agent", &["PROD_DB".into()]).is_ok()); + } } diff --git a/src/python.rs b/src/python.rs index 08eee10..015344a 100644 --- a/src/python.rs +++ b/src/python.rs @@ -14,7 +14,7 @@ use std::collections::HashMap; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; -use crate::{env, export, types}; +use crate::{env, export, policy, types}; /// A loaded and decrypted murk vault. #[pyclass] @@ -31,17 +31,43 @@ impl Vault { /// /// The returned `String` is a plain Python-owned copy — once it crosses /// the FFI boundary the plaintext is outside murk's zeroization. - fn get(&self, key: &str) -> Option { - crate::get_secret(&self.decrypted, key, &self.pubkey).map(str::to_string) + /// + /// When the loaded identity is a granted agent, the vault's agent policy is + /// enforced before the value is returned — the same gate the CLI applies at + /// `agent exec`. Raises `RuntimeError` if policy forbids the key. For an + /// operator identity this is a no-op. + fn get(&self, key: &str) -> PyResult> { + let value = crate::get_secret(&self.decrypted, key, &self.pubkey).map(str::to_string); + // Only enforce when there is a value to hand back: a key the agent + // cannot decrypt is already inaccessible, so policy is moot. + if value.is_some() { + policy::enforce_agent_policy( + &self.inner, + &self.decrypted, + &self.pubkey, + &[key.to_string()], + ) + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + } + Ok(value) } /// Export all secrets as a dict. Scoped values override shared values. - fn export(&self) -> HashMap { + /// + /// For a granted agent, the vault's agent policy is enforced over the full + /// key set first (mirroring `murk agent exec`): if any resolvable key is + /// outside the policy, the whole export raises `RuntimeError` rather than + /// returning a partial dict. For an operator identity this is a no-op. + fn export(&self) -> PyResult> { + let resolved = export::resolve_secrets(&self.inner, &self.decrypted, &self.pubkey, &[]); + let keys: Vec = resolved.keys().cloned().collect(); + policy::enforce_agent_policy(&self.inner, &self.decrypted, &self.pubkey, &keys) + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; // Python dicts own plain Strings — zeroization ends at the FFI boundary. - export::resolve_secrets(&self.inner, &self.decrypted, &self.pubkey, &[]) + Ok(resolved .into_iter() .map(|(k, v)| (k, v.to_string())) - .collect() + .collect()) } /// List all key names. @@ -56,7 +82,7 @@ impl Vault { /// Get a value by key (dict-style access). fn __getitem__(&self, key: &str) -> PyResult { - self.get(key) + self.get(key)? .ok_or_else(|| PyRuntimeError::new_err(format!("key not found: {key}"))) } @@ -95,7 +121,7 @@ fn load(vault_path: &str) -> PyResult { #[pyo3(signature = (key, vault_path=".murk"))] fn get(key: &str, vault_path: &str) -> PyResult> { let v = load(vault_path)?; - Ok(v.get(key)) + v.get(key) } /// One-liner: load the vault and export all secrets as a dict. @@ -103,7 +129,7 @@ fn get(key: &str, vault_path: &str) -> PyResult> { #[pyo3(signature = (vault_path=".murk"))] fn export_all(vault_path: &str) -> PyResult> { let v = load(vault_path)?; - Ok(v.export()) + v.export() } /// Resolve the MURK_KEY from the environment without loading a vault. From 6cf224bdcfc228750b909bd30b8dd9d762a2c4f9 Mon Sep 17 00:00:00 2001 From: Mickey Scherrer <5324300+iicky@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:27:18 -0400 Subject: [PATCH 2/3] run murk via execFileSync in node tests, no shell --- node/__test__/index.test.mjs | 143 ++++++++++++++--------------------- 1 file changed, 58 insertions(+), 85 deletions(-) diff --git a/node/__test__/index.test.mjs b/node/__test__/index.test.mjs index e789f0e..43962a8 100644 --- a/node/__test__/index.test.mjs +++ b/node/__test__/index.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert' -import { execSync } from 'node:child_process' +import { execFileSync } from 'node:child_process' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -8,64 +8,50 @@ import { exportAll, get, hasKey, load } from '../index.js' // Find the murk binary. const murkBin = join(process.cwd(), '..', 'target', 'release', 'murk') -function setupVault() { - const dir = mkdtempSync(join(tmpdir(), 'murk-node-test-')) +// Run the murk binary directly with an argv array (no shell), so command +// arguments can never be reinterpreted as shell syntax. +const PATH_WITH_TARGET = `${join(process.cwd(), '..', 'target', 'release')}:${process.env.PATH}` + +function runMurk(dir, args, input = '', extraEnv = {}) { + return execFileSync(murkBin, args, { + cwd: dir, + input, + env: { ...process.env, PATH: PATH_WITH_TARGET, ...extraEnv }, + stdio: ['pipe', 'pipe', 'pipe'], + }) +} - const run = (cmd, input) => - execSync(cmd, { - cwd: dir, - input, - env: { - ...process.env, - PATH: `${join(process.cwd(), '..', 'target', 'release')}:${process.env.PATH}`, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - - // Init vault. - run(`${murkBin} init --vault .murk`, 'testuser\n') - - // Read key from .env. +// Read the operator key murk init wrote to .env (inline MURK_KEY or a key file). +function readKeyFromDotenv(dir) { const dotenv = readFileSync(join(dir, '.env'), 'utf8') - let murkKey for (const line of dotenv.split('\n')) { if (line.startsWith('export MURK_KEY_FILE=')) { const keyFile = line .split('=')[1] .trim() .replace(/^['"]|['"]$/g, '') - murkKey = readFileSync(keyFile, 'utf8').trim() - break + return readFileSync(keyFile, 'utf8').trim() } if (line.startsWith('export MURK_KEY=')) { - murkKey = line + return line .split('=')[1] .trim() .replace(/^['"]|['"]$/g, '') - break } } + throw new Error('could not find MURK_KEY in .env') +} - // Add secrets. - const env = { ...process.env, MURK_KEY: murkKey } - execSync(`${murkBin} add DATABASE_URL --vault .murk`, { - cwd: dir, - input: 'postgres://localhost/mydb\n', - env, - stdio: ['pipe', 'pipe', 'pipe'], - }) - execSync(`${murkBin} add API_KEY --vault .murk`, { - cwd: dir, - input: 'sk-test-123\n', - env, - stdio: ['pipe', 'pipe', 'pipe'], - }) - execSync(`${murkBin} add STRIPE_SECRET --vault .murk`, { - cwd: dir, - input: 'sk_live_abc\n', - env, - stdio: ['pipe', 'pipe', 'pipe'], - }) +function setupVault() { + const dir = mkdtempSync(join(tmpdir(), 'murk-node-test-')) + + runMurk(dir, ['init', '--vault', '.murk'], 'testuser\n') + const murkKey = readKeyFromDotenv(dir) + + const keyEnv = { MURK_KEY: murkKey } + runMurk(dir, ['add', 'DATABASE_URL', '--vault', '.murk'], 'postgres://localhost/mydb\n', keyEnv) + runMurk(dir, ['add', 'API_KEY', '--vault', '.murk'], 'sk-test-123\n', keyEnv) + runMurk(dir, ['add', 'STRIPE_SECRET', '--vault', '.murk'], 'sk_live_abc\n', keyEnv) return { dir, murkKey } } @@ -74,49 +60,35 @@ function setupVault() { // prove the bindings enforce the same policy the CLI applies at `agent exec`. function setupAgentVault() { const dir = mkdtempSync(join(tmpdir(), 'murk-node-agent-')) - const run = (cmd, input, extraEnv) => - execSync(cmd, { - cwd: dir, - input, - env: { - ...process.env, - PATH: `${join(process.cwd(), '..', 'target', 'release')}:${process.env.PATH}`, - ...extraEnv, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - - run(`${murkBin} init --vault .murk`, 'agentowner\n') - - // Read operator key from .env. - const dotenv = readFileSync(join(dir, '.env'), 'utf8') - let opKey - for (const line of dotenv.split('\n')) { - if (line.startsWith('export MURK_KEY_FILE=')) { - const keyFile = line - .split('=')[1] - .trim() - .replace(/^['"]|['"]$/g, '') - opKey = readFileSync(keyFile, 'utf8').trim() - break - } - if (line.startsWith('export MURK_KEY=')) { - opKey = line - .split('=')[1] - .trim() - .replace(/^['"]|['"]$/g, '') - break - } - } + + runMurk(dir, ['init', '--vault', '.murk'], 'agentowner\n') + const opKey = readKeyFromDotenv(dir) const opEnv = { MURK_KEY: opKey } - run(`${murkBin} add AGENT_DB --vault .murk`, 'postgres://agent\n', opEnv) - run(`${murkBin} add PROD_DB --vault .murk`, 'postgres://prod\n', opEnv) - run(`${murkBin} describe AGENT_DB "agent db" --tag agents --vault .murk`, '', opEnv) - run(`${murkBin} describe PROD_DB "prod db" --tag prod --vault .murk`, '', opEnv) - run(`${murkBin} policy set --allow-tag agents --vault .murk`, '', opEnv) - run( - `${murkBin} agent grant --name codex --only AGENT_DB --out agent.key --vault .murk`, + runMurk(dir, ['add', 'AGENT_DB', '--vault', '.murk'], 'postgres://agent\n', opEnv) + runMurk(dir, ['add', 'PROD_DB', '--vault', '.murk'], 'postgres://prod\n', opEnv) + runMurk( + dir, + ['describe', 'AGENT_DB', 'agent db', '--tag', 'agents', '--vault', '.murk'], + '', + opEnv, + ) + runMurk(dir, ['describe', 'PROD_DB', 'prod db', '--tag', 'prod', '--vault', '.murk'], '', opEnv) + runMurk(dir, ['policy', 'set', '--allow-tag', 'agents', '--vault', '.murk'], '', opEnv) + runMurk( + dir, + [ + 'agent', + 'grant', + '--name', + 'codex', + '--only', + 'AGENT_DB', + '--out', + 'agent.key', + '--vault', + '.murk', + ], '', opEnv, ) @@ -124,7 +96,8 @@ function setupAgentVault() { // Tightening the policy to drop the `agents` tag leaves the agent's scoped // ciphertext in place — the crypto still works, but policy should now refuse. - const tightenPolicy = () => run(`${murkBin} policy set --allow-tag prod --vault .murk`, '', opEnv) + const tightenPolicy = () => + runMurk(dir, ['policy', 'set', '--allow-tag', 'prod', '--vault', '.murk'], '', opEnv) return { dir, opKey, agentKey, tightenPolicy } } From 38cc69c192217244aac11f8dc73db13e6f0140d9 Mon Sep 17 00:00:00 2001 From: Mickey Scherrer <5324300+iicky@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:54:47 -0400 Subject: [PATCH 3/3] docs: document binding policy enforcement in ai-agents --- docs/ai-agents.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/ai-agents.md b/docs/ai-agents.md index 4fd01f3..150d0bf 100644 --- a/docs/ai-agents.md +++ b/docs/ai-agents.md @@ -77,6 +77,8 @@ murk policy set --allow-tag agents # default-deny everything else Now `agent exec` and `agent grant` only work for keys tagged `agents`; asking for an untagged or production key fails closed with a clear error — there's no override flag, so a misbehaving agent can't talk its way past it. `agents` is just an example tag; use whatever tags fit your vault (`dev`, `ci`, ...). The policy lives in the vault header (MAC-covered, readable with `murk policy show` even without a key) so it travels with the repo and applies in CI. Note this is a guardrail enforced by the murk binary, not access control — see THREAT_MODEL.md. +A granted agent is held to the policy no matter how it reads — `murk get`, `murk agent exec`, or the Python/Node bindings (`murk-secrets`). `get()` and `export()` from the bindings refuse a forbidden key just like the CLI, so the allow-list is enforced from every entry point. Tightening the policy applies retroactively: drop a tag and the agent loses access on its next read, even though its old grant key still exists. + ## Auditing agent activity There's no separate agent log to consult — **git is the record.** Every admin change to a grant or policy is a commit, so: