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
2 changes: 2 additions & 0 deletions docs/ai-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
153 changes: 110 additions & 43 deletions node/__test__/index.test.mjs
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -8,68 +8,100 @@ 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}`

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.
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'],
})
}

// 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 }
}

// 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-'))

runMurk(dir, ['init', '--vault', '.murk'], 'agentowner\n')
const opKey = readKeyFromDotenv(dir)

const opEnv = { MURK_KEY: opKey }
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,
)
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 = () =>
runMurk(dir, ['policy', 'set', '--allow-tag', 'prod', '--vault', '.murk'], '', opEnv)

return { dir, opKey, agentKey, tightenPolicy }
}

let testDir, testKey

// Setup
Expand Down Expand Up @@ -165,8 +197,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)
14 changes: 12 additions & 2 deletions node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,31 @@
/** 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<String>` 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
/**
* 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<String>`.
*
* 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<string, string>
/** List all key names. */
Expand Down
35 changes: 28 additions & 7 deletions node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
murk_cli::get_secret(&self.murk, &key, &self.pubkey).map(str::to_string)
pub fn get(&self, key: String) -> napi::Result<Option<String>> {
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<String>`.
///
/// 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<String, String> {
murk_cli::resolve_secrets(&self.vault, &self.murk, &self.pubkey, &[])
pub fn export(&self) -> napi::Result<HashMap<String, String>> {
let resolved = murk_cli::resolve_secrets(&self.vault, &self.murk, &self.pubkey, &[]);
let keys: Vec<String> = 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.
Expand Down Expand Up @@ -86,13 +107,13 @@ pub fn load(vault_path: Option<String>) -> napi::Result<Vault> {
/// One-liner: load the vault and get a single key.
#[napi]
pub fn get(key: String, vault_path: Option<String>) -> napi::Result<Option<String>> {
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<String>) -> napi::Result<HashMap<String, String>> {
Ok(load(vault_path)?.export())
load(vault_path)?.export()
}

/// Check if a MURK_KEY is available in the environment.
Expand Down
4 changes: 4 additions & 0 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
62 changes: 62 additions & 0 deletions python/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Loading
Loading