diff --git a/SPEC.md b/SPEC.md index 4909ca5b..63323bf7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -99,22 +99,31 @@ If these become slow: # Secrets **Status:** partly built. The store is carried as ephemeral run context and -resolved client-side; injection (gated by the double-check below), superset -matching over path-only readers, the entropy/`secret-hash` cache-isolation tag, -the output-scrub assertion, log masking, and the `caos secrets` entropy tooling -all exist. **Cache isolation is now complete for the eval path**: the running +resolved client-side; constrained partial-ArgTree readers, injection (gated by +the double-check below), the entropy/`secret-hash` cache-isolation tag, the +output-scrub assertion, log masking, and the `caos secrets` entropy tooling all +exist. **Cache isolation is now complete for the eval path**: the running worker, eval-path's `curry` returns, and — via the eval-path stripping rule — a worker embedded through a `:@=` arg, which makes its embedder per-user too. Builds on `.caos-expr` (eval-path, deep-deps) and map-then (server-mediated worker starts). -**Since the ambient-`std` removal landed** (design/caos-expr.md, "Landed: -ambient `/std` is gone"), a reader is a **tree path and nothing else** — there -is no `/std/` to name, so the two reader forms collapsed into one, which -is what this note always wanted. It also briefly *widened* the -caller-propagation gap: eval-path used to mark a `/std/` `:@=` target, and -that was the only `:@=` marking there was. Closing it properly covers all of -`:@=` and needs no `/std` special case at all. +Every `reader=` is one physical line declaring a partial ArgTree. Its value is +the typed argument list accepted after a `.caos-expr` `curry` command, but is +not itself a command and therefore has no verb. It must contain an explicit +typed `--base`. The rest uses the same argument parser and resolution rules as +`.caos-expr` curry commands; notably, `:@=` paths resolve against the secret +store's pinned source tree. The assembled reader is never run. It is unwrapped +to the existing name → oid map and sent to the server, whose authorization +remains a pure subset match. + +This restores the inline pins removed in commit `91866bd94`. Moving constraints +into narrower expression wrappers conflated two policies: content-addressed +worker identity belongs in the source tree, while the secret owner's local +grant constraints belong on that device. Security-sensitive values such as a +credential's destination or permitted repository belong beside the credential; +requiring a wrapper would make that policy repository-owned and needlessly +proliferate expression directories. What remains is not about the eval path: the agent harness carries no store, and `value:@=` is UTF-8 only. See "Remaining work". @@ -130,26 +139,32 @@ Some tools need secrets: the github-push tool needs an auth token, and there wil `.caos-secrets`: - Secrets live in a git-ignored .caos-secrets directory -- Each secret file contains the secret's value and a list of workers that can read the secret. This is formatted as a repeated-key file. For example: +- Each secret file contains the secret's value and one or more independent + partial ArgTrees that may read it. This is formatted as a repeated-key file. + For example: ``` -# Optional name. Defalts to the name of the file. This is the name that is used in the worker for /secret/ +# Optional name. Defaults to the filename. This is used at /secret/. name= entropy=... # Inline secret value= # External key value:@= -# A reader is a PATH to an expression, without arguments. It is eval-path'd to -# an arg tree -reader=std/github-push -reader=tools/deploy +# Each reader is a curry argument list with its own explicit typed base. +reader=--base:@=DEEP-DEPS/github-push --repo=github.com/me/proj +reader=--base:@=tools/deploy --environment=production ``` -- When a call stack is started, such as `caos-cli run`, we read the current source tree and the list of secrets. Readers in secrets are matched against the tree. Any worker named as a reader is granted access to the secret. These workers have a hash of the names and entropy of all exposed secrets injected into them as /cas/args/secret-hash -- Something is considered to be the same worker (ie, to have access to the secret) if it its arg tree is a superset of the reader's arg tree and secret-hash matches the set of secrets that the server computes for it +- When a call stack is started, such as `caos-cli run`, the client reads the + pinned source tree and the secret files. Each reader is assembled through the + normal argument/expression code, without executing the assembled request. +- A worker has access when its ArgTree is a superset of any one independently + assembled reader and its `secret-hash` matches the exact set of secrets the + server computes for that ArgTree. Arguments omitted from a reader remain + unconstrained. - Each granted secret contributes its (worker-visible name, entropy) to a `secret-hash` entry folded into the worker's arg tree (visible at `/cas/args/secret-hash`). This makes two users with different secrets see - different cache keys — but keps the secret's *value* out (so rotating a value + different cache keys — but keeps the secret's *value* out (so rotating a value doesn't bust the cache), and stores the *digest* of the entropy, never the entropy itself (the entropy is a bearer capability for the cache: knowing it reconstructs the key of any run that used it). The name is included because a @@ -197,9 +212,6 @@ Note that this means that the server sees all secrets. We can revisit if this be - **Binary `value:@=`.** Read but kept UTF-8 (binary/multiline later). -- **`run`-form `.caos-expr` grants** are deliberately unresolved (a grant must - never trigger compute); likely permanent. - - **Shared-server exposure.** Carrying the whole store means a shared server sees values it never injects (sub-runs aren't known ahead of time, so the client can't pre-filter to the granted subset). Moot for a per-user/local diff --git a/crates/caos/src/eval.rs b/crates/caos/src/eval.rs index 83023a4f..8a4b863a 100644 --- a/crates/caos/src/eval.rs +++ b/crates/caos/src/eval.rs @@ -503,6 +503,39 @@ fn eval_command( request_compute(&server, &arg_tree, &secret_store_header(store)) } +/// Assemble a `.caos-secrets` `reader=` value as a partial ArgTree. +/// +/// A reader is a declaration rather than a command, so its physical line has +/// no verb. Its value is otherwise parsed and resolved as the ordinary +/// `.caos-expr` curry argument list. In particular, `:@=` paths are looked up +/// in the pinned source tree, never in the host filesystem. The empty secret +/// store is intentional: resolving a grant must not recursively mark that +/// grant. +pub(crate) fn assemble_reader( + t: &dyn Transport, + input_tree: &str, + reader: &str, +) -> Result { + // Apply the pinned tree's root expression first, exactly as the old + // path-only reader walk did. This is what exposes generated mounts such as + // `DEEP-DEPS/` while keeping every subsequent `:@=` inside that same pinned + // source snapshot. + let (input_kind, input_tree) = eval_path(t, input_tree, "", &[])?; + if input_kind != "tree" { + return Err(format!( + "reader source evaluated to a {input_kind}, expected a tree" + )); + } + let command = format!("curry {reader}"); + let (kind, oid) = eval_command(t, &input_tree, &command, &HashMap::new(), &[])?; + if kind != "tree" { + return Err(format!( + "reader assembly returned a {kind}, expected a partial ArgTree" + )); + } + Ok(oid) +} + /// Resolve a command's `--base` arg to an image ref string, dispatched on its /// explicit type (never sniffed from the value's shape): `$VAR` (an object a /// prior line produced), `:@=` (a path naming an image tree in diff --git a/crates/caos/src/lib.rs b/crates/caos/src/lib.rs index 055d50ef..bc434efc 100644 --- a/crates/caos/src/lib.rs +++ b/crates/caos/src/lib.rs @@ -3521,8 +3521,9 @@ pub fn cli_run( let (bty, bval, kvs) = split_base_arg("run", kvs)?; let image = resolve_base(t, None, bty, bval)?; // Build the ephemeral secrets store from the caller's `.caos-secrets` - // (design/secrets.md), resolving each reader here — where eval-path is - // available — so the server never evals. Empty when there's no store. + // (SPEC.md, Secrets), assembling each constrained reader here — where the + // pinned-tree expression grammar is available — so the server never evals. + // Empty when there's no store. let store = build_secret_store(t)?; let (kind, result) = run_request(t, &image, None, trace, &kvs, &store)?; @@ -3827,6 +3828,20 @@ fn curry_from_entries( let (base, mut bound) = unwrap_curry(t, arg_tree)?; + // Reject duplicate new bindings before merging them. `merge_entries` is + // deliberately last-wins for run-time overlays, but curry is strict: two + // occurrences on one command line are as much a typo as rebinding an arg + // already present in the base ArgTree. + let mut new_names = std::collections::BTreeSet::new(); + for entry in &new { + if !new_names.insert(entry.filename.to_vec()) { + return Err(format!( + "curry: arg {:?} was provided more than once", + String::from_utf8_lossy(&entry.filename) + )); + } + } + // UNBIND first: drop the named args so they can be rebound. Currying is // otherwise strict (below), so carrying a whole ArgTree forward and changing // a few of its args — the self-recurry case — needs an explicit release. An @@ -4118,10 +4133,10 @@ pub(crate) struct ClientSecret { readers: Vec>, } -/// Read and resolve the caller's `.caos-secrets` store (design/secrets.md): -/// each reader resolved HERE (via eval-path, against the store's pinned tree) -/// to a partial arg tree of name → oid — so the server only subset-matches, -/// never evals. Empty when there is no store. +/// Read and resolve the caller's `.caos-secrets` store (SPEC.md, Secrets): each +/// constrained reader is assembled HERE through the curry/expression grammar, +/// against the store's pinned tree, into a partial name → oid ArgTree. The +/// server only subset-matches and never evals. Empty when there is no store. pub(crate) fn build_secret_store(t: &dyn Transport) -> Result, String> { let dir = Path::new(SECRETS_DIR); if !dir.is_dir() { @@ -4148,7 +4163,10 @@ pub(crate) fn build_secret_store(t: &dyn Transport) -> Result, let (name, value, entropy, readers) = parse_local_secret(&file_name, &path)?; let readers = readers .iter() - .map(|r| resolve_reader_client(t, &pinned, r)) + .map(|reader| { + resolve_reader_client(t, &pinned, reader) + .map_err(|error| format!("secret {file_name}: reader={reader}: {error}")) + }) .collect::>()?; store.push(ClientSecret { name, @@ -4345,26 +4363,29 @@ fn parse_local_secret( Ok((name, value, entropy, readers)) } -/// Resolve a reader — a single path/expression, no argument pins -/// (design/secrets.md: a reader names an *expression*; narrow by pointing at a -/// narrower one, not by pinning args here) — to the partial arg tree it stands -/// for: eval-path the path (so a flake/`.caos-expr` tool resolves to the same -/// arg tree the run uses), unwrap any curry layers, and take its entries. That -/// tree already carries whatever the expression bakes in (e.g. a curried -/// `worker1` script), so it is as specific as the expression is. +/// Assemble one `reader=` line into the partial name -> oid ArgTree used by the +/// existing subset matcher. The line declares a partial ArgTree using the +/// ordinary curry argument list and requires an explicit typed `--base`. +/// Resolution happens against the store's pinned source tree and never runs +/// the assembled request. Curry layers are unwrapped only after assembly, so an +/// arg already bound by the base is rejected by the shared strict-curry checks. fn resolve_reader_client( t: &dyn Transport, pinned: &str, reader: &str, ) -> Result, String> { - if reader.split_whitespace().count() != 1 { + let partial = eval::assemble_reader(t, pinned, reader)?; + let (base, bound) = unwrap_curry(t, &partial)?; + if is_hex_hash(&base) { + let (kind, _) = t.get_object(&base)?; + if kind != "tree" { + return Err(format!("reader base {base} is a {kind}, not an image tree")); + } + } else if !base.starts_with(DOCKER_SCHEME) { return Err(format!( - "reader {reader:?} must be a single path (argument pins are not supported — \ - point at a narrower expression instead)" + "reader base {base:?} is not a git image or docker reference" )); } - let image = resolve_reader_image(t, pinned, reader.trim())?; - let (base, bound) = unwrap_curry(t, &image)?; let mut entries = std::collections::BTreeMap::new(); for entry in bound { entries.insert( @@ -4372,30 +4393,16 @@ fn resolve_reader_client( entry.oid.to_string(), ); } - // The image entry wins over any like-named bound arg, mirroring assembly. - entries.insert("base".to_string(), base); + // Store the base entry's object id, like every other partial entry. Docker + // refs therefore match their blob oid rather than leaking a representation + // exception into the server's pure oid-equality matcher. + entries.insert( + "base".to_string(), + base_arg_entry(t, &base)?.oid.to_string(), + ); Ok(entries) } -/// Resolve a reader's image token: a bare hash, or a path in the pinned tree -/// (via eval-path — so a flake/`.caos-expr` tool resolves to the same oid the -/// run uses). -/// -/// A path only: there is no ambient library to name, so a reader says -/// `std/github-push` and it is read out of the tree, exactly as an expression -/// reaches a dependency. That is also why it converges with the run's own -/// resolution — the root `.caos-expr` deepens the tree, and the entry a reader -/// descends to is the same node a `DEEP-DEPS/` mount points at. -fn resolve_reader_image(t: &dyn Transport, pinned: &str, expr: &str) -> Result { - if is_hex_hash(expr) { - return Ok(expr.to_string()); - } - // Empty store: a reader's own resolution must not be marked (its arg tree is - // what the match compares against; marking it would be circular). - let (_, oid) = eval::eval_path(t, pinned, expr, &[])?; - Ok(oid) -} - fn request_compute(base: &str, arg_tree: &str, secrets: &str) -> Result<(String, String), String> { let url = run_url(base, arg_tree, None); request_compute_url(&url, secrets) diff --git a/crates/server/src/secrets.rs b/crates/server/src/secrets.rs index eda3847a..baf484af 100644 --- a/crates/server/src/secrets.rs +++ b/crates/server/src/secrets.rs @@ -7,10 +7,11 @@ //! //! The store is **carried with the run as ephemeral context** (like the run //! stack), not sourced on the server: the client reads its own git-ignored -//! `.caos-secrets`, resolves each reader with eval-path (the same evaluator the -//! run uses, so the resolved image oids match what the job carries — the server -//! must never eval), and sends the result in the `X-Caos-Secrets` header on -//! `GET /run`. The server parses it into [`Grant`]s, threads them through +//! `.caos-secrets`, assembles each constrained reader with the normal curry and +//! expression grammar against the store's pinned source tree, and sends the +//! resulting partial name → oid map in the `X-Caos-Secrets` header on `GET +//! /run`. The assembled reader is never executed and the server never evals. +//! The server parses the maps into [`Grant`]s, threads them through //! promise resolution to every sub-run's dispatch, and at each dispatch does //! the cheap subset-match + injection. So a sub-worker is entitled by matching //! *its own* arg tree, never by inheritance (the no-delegation invariant). @@ -198,27 +199,36 @@ mod tests { fn grant_requires_the_matching_secret_hash() { let grants = parse_header( r#"[{"name":"tok","value":"s3cr3t","entropy":"E","readers":[ - {"base":"aa"}, + {"base":"aa","destination":"trusted"}, {"base":"bb","repo":"cc"} ]}]"#, ); // A worker that matches a reader but carries NO secret-hash is refused // (it wasn't produced by eval with this store). - assert!(grant(&grants, &map(&[("base", "aa")])).is_empty()); + assert!(grant(&grants, &map(&[("base", "aa"), ("destination", "trusted")])).is_empty()); // With the matching secret-hash present, the value is injected. The - // entry is the blob-oid of the digest (how it rides in a real tree). - let mut job = map(&[("base", "aa"), ("salt", "z")]); + // entry is the blob-oid of the digest (how it rides in a real tree), and + // unpinned args do not interfere with the subset match. + let mut job = map(&[ + ("base", "aa"), + ("destination", "trusted"), + ("extra", "unconstrained"), + ]); let digest = secret_hash(&grants, &job).unwrap(); - job.insert( - caos_world::SECRET_HASH_ARG.to_string(), - blob_oid(digest.as_bytes()), - ); + let copied_hash = blob_oid(digest.as_bytes()); + job.insert(caos_world::SECRET_HASH_ARG.to_string(), copied_hash.clone()); assert_eq!( grant(&grants, &job), vec![("tok".to_string(), "s3cr3t".to_string())] ); + // Copying the valid hash to the same worker with a different pinned + // destination cannot manufacture a grant: the reader no longer + // matches, so the server expects no isolation hash at all. + let mut copied = map(&[("base", "aa"), ("destination", "attacker")]); + copied.insert(caos_world::SECRET_HASH_ARG.to_string(), copied_hash); + assert!(grant(&grants, &copied).is_empty()); // A wrong secret-hash is refused. - let mut forged = map(&[("base", "aa")]); + let mut forged = map(&[("base", "aa"), ("destination", "trusted")]); forged.insert( caos_world::SECRET_HASH_ARG.to_string(), "deadbeef".to_string(), diff --git a/tests/secrets/cli.sh b/tests/secrets/cli.sh index 3735827f..66411018 100644 --- a/tests/secrets/cli.sh +++ b/tests/secrets/cli.sh @@ -2,11 +2,12 @@ # Runs cwd'd into a client repo with $CAOS_CLI set, INSIDE a test stack. # # Exercises secret injection with the store carried as ephemeral run context -# (design/secrets.md): the client reads its own git-ignored `.caos-secrets`, -# resolves each reader with eval-path, and sends the result to the server, -# which subset-matches and injects at `/secret/`. Covers: a granted vs a -# denied secret, the output-leak assertion, log masking, and a grant naming a -# repo-local tool by path. +# (SPEC.md, Secrets): the client reads its own git-ignored `.caos-secrets`, +# assembles each constrained reader against the pinned source tree, and sends +# the partial ArgTree to the server, which subset-matches and injects at +# `/secret/`. Covers constrained grants and denial, copied secret hashes, +# extra unpinned args, path/hash equivalence, multiple readers, configuration +# errors, the output-leak assertion, log masking, and caller propagation. set -euo pipefail fail() { echo "FAIL: $*" >&2; exit 1; } @@ -23,8 +24,8 @@ if [ -r /secret/token ] && [ "$(cat /secret/token)" = "SEKRET-abc-123" ]; then else v="token-bad" fi -# deploytok's reader (mytool) is a DIFFERENT expression, so it must NOT be -# granted to this plain bash worker. +# deploytok's readers require either mytool+environment or bash+role, so it must +# NOT be granted to an otherwise matching token request. [ -e /secret/deploytok ] && v="$v deploy-leaked" || v="$v deploy-absent" echo "$v" > /tmp/out/verdict caos put /tmp/out /cas/out @@ -91,6 +92,11 @@ cat > embedder/.caos-expr <<'EOF' curry --base:@=bash --worker1:@=run.sh --pusher:@=mytool EOF +# A pinned tree argument used to prove that reader-side `:@=` and request-side +# path/hash references resolve to the same oid. +mkdir policy +echo allow-production > policy/destination + echo '.caos-secrets/' > .gitignore commit "secrets fixtures" test_run_id="$(date +%s%N)-$$-$RANDOM" @@ -101,26 +107,95 @@ git push -q caos "HEAD:refs/heads/${test_run_id}-secrets-test" \ mkdir -p .caos-secrets # Pin readers' resolution to this committed tree (excludes .caos-secrets itself). git rev-parse "HEAD^{tree}" > .caos-secrets/.tree -cat > .caos-secrets/token <<'EOF' +trusted_destination=production +bash_image=$("$CAOS_CLI" eval-path DEEP-DEPS/bash) || fail "resolving bash" +bash_image=${bash_image##* } +policy_tree=$(git rev-parse HEAD:policy) || fail "resolving policy tree" +cat > .caos-secrets/token < .caos-secrets/deploytok <<'EOF' +cat > .caos-secrets/deploytok < .caos-secrets/propagation <<'EOF' +value=unused-propagation-value +entropy=00112233445566778899aabbccddeeff +reader=--base:@=mytool EOF -echo "== a granted secret is injected; a non-matching one is not ==" >&2 -out=$("$CAOS_CLI" run got --base:@=DEEP-DEPS/bash --worker1:@=check.sh) || fail "run failed: $out" +echo "== matching constraints grant; extra unpinned arguments remain free ==" >&2 +matching_request=$("$CAOS_CLI" prepare-request \ + --base:@=DEEP-DEPS/bash --worker1:@=check.sh \ + --destination="$trusted_destination" --policy:@=policy \ + --extra-a=free --extra-b=also-free) \ + || fail "preparing matching request" +"$CAOS_CLI" run got --base:hash="$matching_request" >/dev/null \ + || fail "running matching request" verdict=$(cat got/verdict) [ "$verdict" = "token-ok deploy-absent" ] \ || fail "verdict: $verdict (expected 'token-ok deploy-absent')" -echo " ok: /secret/token granted, the differently-scoped /secret/deploytok denied" >&2 +echo " ok: exact destination granted despite extra unpinned arguments" >&2 + +echo "== the same worker at another destination is denied ==" >&2 +"$CAOS_CLI" run wrong-destination --base:@=DEEP-DEPS/bash --worker1:@=check.sh \ + --destination=attacker --policy:@=policy >/dev/null \ + || fail "running mismatched destination" +verdict=$(cat wrong-destination/verdict) +[ "$verdict" = "token-bad deploy-absent" ] \ + || fail "wrong-destination verdict: $verdict" +echo " ok: changing only the pinned destination removes the grant" >&2 + +echo "== a copied secret-hash cannot authorize a mismatched request ==" >&2 +copied_secret_hash=$(git rev-parse "$matching_request:secret-hash") \ + || fail "reading matching secret-hash" +forged_request=$("$CAOS_CLI" prepare-request \ + --base:@=DEEP-DEPS/bash --worker1:@=check.sh \ + --destination=attacker --policy:@=policy \ + --secret-hash:hash="$copied_secret_hash") \ + || fail "preparing copied-hash request" +"$CAOS_CLI" run forged --base:hash="$forged_request" >/dev/null \ + || fail "running copied-hash request" +verdict=$(cat forged/verdict) +[ "$verdict" = "token-bad deploy-absent" ] \ + || fail "copied-hash verdict: $verdict" +echo " ok: copied isolation metadata does not override the reader mismatch" >&2 + +echo "== path and hash forms resolve to the same base and tree args ==" >&2 +"$CAOS_CLI" run hash-forms --base:hash="$bash_image" --worker1:@=check.sh \ + --destination="$trusted_destination" --policy:hash="$policy_tree" >/dev/null \ + || fail "running hash-form equivalent" +verdict=$(cat hash-forms/verdict) +[ "$verdict" = "token-ok deploy-absent" ] \ + || fail "hash-form verdict: $verdict" +"$CAOS_CLI" run reader-hash-forms --base:@=DEEP-DEPS/bash --worker1:@=check.sh \ + --policy:@=policy --reader-form=hash >/dev/null \ + || fail "running reader hash-form equivalent" +verdict=$(cat reader-hash-forms/verdict) +[ "$verdict" = "token-bad deploy-leaked" ] \ + || fail "reader hash-form verdict: $verdict" +echo " ok: reader and request path/tree/hash forms share curry oid identity" >&2 + +echo "== multiple readers keep their own bases and constraints ==" >&2 +tool=$("$CAOS_CLI" eval-path mytool) || fail "eval-path mytool failed: $tool" +"$CAOS_CLI" run tool-reader --base:hash="${tool##* }" \ + --environment=production >/dev/null || fail "running mytool reader" +[ "$(cat tool-reader/verdict)" = "deploytok-ok" ] \ + || fail "mytool reader did not receive deploytok" +"$CAOS_CLI" run role-reader --base:@=DEEP-DEPS/bash --worker1:@=check.sh \ + --role=auditor >/dev/null || fail "running role reader" +[ "$(cat role-reader/verdict)" = "token-bad deploy-leaked" ] \ + || fail "role reader did not independently receive deploytok" +echo " ok: each reader matches only its own base and pinned arguments" >&2 echo "== a worker that leaks a secret into its output is refused ==" >&2 -if "$CAOS_CLI" run leaked --base:@=DEEP-DEPS/bash --worker1:@=leak.sh >/dev/null 2>leak.err; then +if "$CAOS_CLI" run leaked --base:@=DEEP-DEPS/bash --worker1:@=leak.sh \ + --destination="$trusted_destination" --policy:@=policy >/dev/null 2>leak.err; then fail "run leaking the secret should have failed" fi grep -qi "secret" leak.err || fail "leak error should mention a secret: $(cat leak.err)" @@ -128,7 +203,8 @@ if grep -q "SEKRET-abc-123" leak.err; then fail "the error must NOT echo the sec echo " ok: leaking the value fails the run, without echoing it" >&2 echo "== a secret printed to the log is masked ==" >&2 -if "$CAOS_CLI" run shouted --base:@=DEEP-DEPS/bash --worker1:@=shout.sh >/dev/null 2>shout.err; then +if "$CAOS_CLI" run shouted --base:@=DEEP-DEPS/bash --worker1:@=shout.sh \ + --destination="$trusted_destination" --policy:@=policy >/dev/null 2>shout.err; then fail "the shouting worker should have failed" fi if grep -q "SEKRET-abc-123" shout.err; then @@ -139,8 +215,8 @@ grep -q "redacted secret" shout.err \ echo " ok: the printed secret is masked" >&2 echo "== a grant can name a repo-local tool by path ==" >&2 -tool=$("$CAOS_CLI" eval-path mytool) || fail "eval-path mytool failed: $tool" -out=$("$CAOS_CLI" run tgot --base:hash="${tool##* }") || fail "run mytool failed: $out" +out=$("$CAOS_CLI" run tgot --base:hash="${tool##* }" --environment=production) \ + || fail "run mytool failed: $out" verdict=$(cat tgot/verdict) [ "$verdict" = "deploytok-ok" ] \ || fail "current-tree grant verdict: $verdict (expected 'deploytok-ok')" @@ -152,7 +228,7 @@ echo "== eval-path folds secret-hash, so a worker's callers become per-user ==" # exactly what makes anything embedding mytool per-user. withsecret=$("$CAOS_CLI" eval-path mytool) || fail "eval-path mytool failed" emb_with=$("$CAOS_CLI" eval-path embedder) || fail "eval-path embedder failed" -rm .caos-secrets/deploytok +rm .caos-secrets/propagation without=$("$CAOS_CLI" eval-path mytool) || fail "eval-path mytool (no secret) failed" emb_without=$("$CAOS_CLI" eval-path embedder) || fail "eval-path embedder (no secret) failed" [ "$withsecret" != "$without" ] \ @@ -167,14 +243,41 @@ echo "== caller-propagation: embedding a granted worker isolates the EMBEDDER == || fail "eval-path embedder should depend on the store (got '$emb_with' both times)" echo " ok: the embedder is per-user via its :@= worker, reading no secret itself" >&2 +echo "== malformed and unsafe reader configurations fail clearly ==" >&2 +expect_reader_error() { #