Implement SECRETS-WP-0008 unblocked layer-model obligations
Load pep-stance.yaml as the live unreachable-engine gate and record named stance fields on privileged evidence. Classify evidence, queue load-bearing records in a local outbox, and add heartbeat/drain commands that never sit on a mutation path. Publish proposed SSH-CA and secret-use evidence contracts without adding an OpenBao SSH-CA write. T02 (access-engine decision records) and T06 (no standing credential) stay wait on external endpoints. Assistant: grok Assistant-Session: 01a04cea-cb33-7c63-bad7-c1b0f9f0076b
This commit is contained in:
parent
57f6c4fa65
commit
3cd9955ac9
16 changed files with 1041 additions and 77 deletions
35
SCOPE.md
35
SCOPE.md
|
|
@ -193,6 +193,7 @@ secrets-engine route
|
|||
secrets-engine revoke [--dry-run]
|
||||
secrets-engine lifecycle suspend|deactivate|destroy
|
||||
secrets-engine audit <catalog-id> [--json]
|
||||
secrets-engine evidence heartbeat|drain|classify
|
||||
```
|
||||
|
||||
The implemented exec adapters are `exec-env` and `npm-config`. `read-check` is
|
||||
|
|
@ -233,16 +234,17 @@ verification, `approle-login` is auth-capability handoff metadata, and
|
|||
- Management or health verification of ESO, Kubernetes Secrets, deployments,
|
||||
provider accounts, SSH issuance, tunnels, or remote transport.
|
||||
- Any backend other than the local `bao`/`vault` CLI speaking to OpenBao.
|
||||
- An SSH-CA signing engine API. ops-warden still signs through its declared
|
||||
OpenBao gap; this repository has accepted that surface as proposed only.
|
||||
- A secret-use evidence engine API for kings-guard. `route` and `audit` are
|
||||
operator summaries over local JSONL, not an observation surface.
|
||||
- Emission to `audit-core`. Evidence today is local JSONL plus best-effort
|
||||
State Hub progress notes, classified attributive, completeness not claimed.
|
||||
- Named stance-application records (stage, failure mode, decision id present
|
||||
only where rendered). Fail-closed production currently surfaces as a
|
||||
`DecisionError` on the privileged-evidence path.
|
||||
- An SSH-CA signing engine API. The contract is
|
||||
`docs/ssh-ca-signing-contract.md`; ops-warden still signs through its
|
||||
declared OpenBao gap.
|
||||
- A secret-use evidence engine API for kings-guard. The contract is
|
||||
`docs/secret-use-evidence-contract.md`. `route` and `audit` are operator
|
||||
summaries over local JSONL, not that observation surface.
|
||||
- Emission to `audit-core`. Load-bearing records are queued locally; drain
|
||||
requires a sender binding that does not exist yet. Completeness is not
|
||||
claimed.
|
||||
- Security-zone membership as a request claim. PEP scope is catalog stage.
|
||||
- Drain of the load-bearing outbox into a live `audit-core` sender binding.
|
||||
|
||||
## System Boundary
|
||||
|
||||
|
|
@ -299,12 +301,12 @@ Working companion: `net-kingdom/SECURITY-COMPANION.md`.
|
|||
| Obligation | Current | Intended |
|
||||
| --- | --- | --- |
|
||||
| Layer declaration | `INTENT.md` frontmatter + `layer.yaml` | Keep in this repository's own voice |
|
||||
| One decision point | Consumer validator for ActionAuthorization; production live fail-closed | Consume an access-engine decision record before every protected side effect |
|
||||
| PEP stance | `pep-stance.yaml`; prod fail-closed, build/test fail-open relative to access-engine | Published map equals shipped behaviour; stance application recorded by name |
|
||||
| Evidence bound | Attributive local JSONL + best-effort State Hub | Load-bearing vs attributive classified; load-bearing to `audit-core` with cadence |
|
||||
| SSH-CA surface | Proposed; not shipped | Engine API for ops-warden's Staff PEP; lane stewardship stays with ops-warden |
|
||||
| Secret-use evidence | `route` / `audit` over local JSONL | Engine surface of lease/revocation/mount/rotation metadata |
|
||||
| Agent credential | Bootstrap token file still accepted; KeyCape scaffold unwired | Per-task, time-bounded service identity; no standing engine credential |
|
||||
| One decision point | Consumer validator for ActionAuthorization; production live fail-closed from `pep-stance.yaml` | Consume an access-engine decision record before every protected side effect |
|
||||
| PEP stance | Runtime loads `pep-stance.yaml`; named stance fields on privileged evidence | Unchanged map; T02 replaces fail-open residue with a decision record |
|
||||
| Evidence bound | `evidence-classification.yaml`; load-bearing local outbox; heartbeat command | Drain to `audit-core` once that sender is admitted |
|
||||
| SSH-CA surface | Contract at `docs/ssh-ca-signing-contract.md`; not shipped | Engine API after ops-warden assent |
|
||||
| Secret-use evidence | Contract at `docs/secret-use-evidence-contract.md`; not shipped | Engine surface after kings-guard assent |
|
||||
| Agent credential | Bootstrap token file still accepted; KeyCape scaffold unwired to OpenBao | Per-task, time-bounded service identity; no standing engine credential |
|
||||
|
||||
## Where Current Work Lives
|
||||
|
||||
|
|
@ -312,7 +314,8 @@ Working companion: `net-kingdom/SECURITY-COMPANION.md`.
|
|||
- `docs/hardening-backlog.md` tracks exit from bootstrap mode.
|
||||
- `history/` contains dated capability and intent assessments.
|
||||
- `INTENT.md` remains the stable aspirational direction.
|
||||
- `layer.yaml` and `pep-stance.yaml` are the layer-model declaration surface.
|
||||
- `layer.yaml`, `pep-stance.yaml`, and `evidence-classification.yaml` are the
|
||||
layer-model declaration surface.
|
||||
|
||||
## Provided Capabilities
|
||||
|
||||
|
|
|
|||
78
docs/secret-use-evidence-contract.md
Normal file
78
docs/secret-use-evidence-contract.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Secret-use evidence engine contract (proposed)
|
||||
|
||||
Status: **proposed**. `layer.yaml` owner_status remains `proposed` until this
|
||||
surface ships. kings-guard must assent or contest before it is treated as an
|
||||
observation input.
|
||||
|
||||
Standard: NetKingdom Security Layer Model v0.7 §9.6. Companion:
|
||||
`net-kingdom/SECURITY-COMPANION.md` §6.
|
||||
|
||||
## What this is
|
||||
|
||||
A read-only Lifecycle engine surface of **non-secret** lease, revocation,
|
||||
mount, rotation, and delivery-session metadata, so kings-guard can evaluate
|
||||
secret-abuse posture without a vault client.
|
||||
|
||||
`secrets-engine route` and `secrets-engine audit` are operator summaries over
|
||||
local JSONL. They are not this surface.
|
||||
|
||||
## Bound (normative)
|
||||
|
||||
The archive proves the records it holds were not altered or truncated after
|
||||
arrival. It does **not** prove a record was sent. Absence of a record is **not**
|
||||
evidence of non-occurrence. Completeness is not claimed.
|
||||
|
||||
This surface cannot close adversarial omission at a compromised source. Cadence
|
||||
for load-bearing classes is a heartbeat (`secrets-engine evidence heartbeat`),
|
||||
not rate monitoring.
|
||||
|
||||
## Fields (non-secret)
|
||||
|
||||
| Field | Meaning | Freshness |
|
||||
| --- | --- | --- |
|
||||
| `as_of` | UTC timestamp of the snapshot | Snapshot time |
|
||||
| `catalog_id` | Lane id | Catalog load |
|
||||
| `stage` | `build` \| `test` \| `prod` | Catalog load |
|
||||
| `kind` | `kv` \| `auth-capability` | Catalog load |
|
||||
| `mount` | OpenBao mount name | Catalog load |
|
||||
| `path` | KV path or allowlisted auth path, never a value | Catalog load |
|
||||
| `field_names` | Declared field names, never values | Catalog load |
|
||||
| `ready` | All declared fields present (boolean) | Last route/verify |
|
||||
| `session_handle` | Accessor fingerprint only | Last exec/verify |
|
||||
| `revocation_attempted` | Boolean | Last session |
|
||||
| `revocation_succeeded` | Boolean | Last session |
|
||||
| `lifecycle_operation` | `suspend` \| `deactivate` \| `destroy` \| `revoke` if one ran | Last lifecycle |
|
||||
| `decision_id` | Present only where a decision was rendered | Last privileged action |
|
||||
| `stance_stage` / `stance_failure_mode` | Named PEP residue | Last privileged action |
|
||||
| `evidence_kind` | `load-bearing` \| `attributive` \| `heartbeat` | Per record |
|
||||
|
||||
Forbidden in this surface: secret values, tokens, passwords, AppRole
|
||||
`secret_id`, raw accessors, certificate private keys, subprocess argv, and
|
||||
exception prose.
|
||||
|
||||
## Freshness
|
||||
|
||||
Every row carries `as_of`. There is no cached authorization verdict. A consumer
|
||||
must not treat a stale snapshot as a standing allow. Recommended maximum age
|
||||
for posture evaluation is the heartbeat interval declared in
|
||||
`evidence-classification.yaml` (`1d`) until a tighter contract is assented.
|
||||
|
||||
## Destination
|
||||
|
||||
Intended owner of custody is `audit-core`. Today the engine queues load-bearing
|
||||
records in `evidence_dir/outbox/` and writes attributive records to local
|
||||
JSONL. Drain to `audit-core` is best-effort and is never on the mutation path.
|
||||
|
||||
kings-guard reads this engine surface, not OpenBao.
|
||||
|
||||
## Non-responsibilities
|
||||
|
||||
This surface does not decide whether an action is permitted, does not expose an
|
||||
approval-validity query, does not prove omission at source, and does not
|
||||
observe production events kings-guard has not received. Companion §10 still
|
||||
holds: nothing is observed in production until kings-guard reports otherwise.
|
||||
|
||||
## Assent
|
||||
|
||||
A coordination message to kings-guard records this contract. Assent or contest
|
||||
belongs in kings-guard's own voice.
|
||||
90
docs/ssh-ca-signing-contract.md
Normal file
90
docs/ssh-ca-signing-contract.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# SSH-CA signing engine contract (proposed)
|
||||
|
||||
Status: **proposed**. `layer.yaml` owner_status remains `proposed` until this
|
||||
surface ships. ops-warden must assent or contest before any replacement client
|
||||
is written.
|
||||
|
||||
Standard: NetKingdom Security Layer Model v0.7 §3.3 (Lifecycle), §6.4 (PEP).
|
||||
Companion: `net-kingdom/SECURITY-COMPANION.md`.
|
||||
|
||||
## What this is
|
||||
|
||||
A deterministic Lifecycle engine API that asks OpenBao to sign a short-lived
|
||||
SSH user certificate **after** an `access-engine` decision record naming the
|
||||
request. secrets-engine presents no additional authority of its own beyond the
|
||||
decision-bound role. The action reconstructs as the caller's.
|
||||
|
||||
## What this is not
|
||||
|
||||
- Operational ownership of the SSH access lane. `warden sign`, `cert_command`,
|
||||
and host-access stewardship stay with ops-warden (Staff PEP).
|
||||
- A break-glass AppRole. The cataloged standalone `warden-sign` AppRole stays
|
||||
parked.
|
||||
- An authorization decision, an approval store, or a second PDP.
|
||||
- A writer of private keys. The caller supplies the public key; the engine
|
||||
returns only the signed certificate material through a scoped delivery path.
|
||||
|
||||
## Caller
|
||||
|
||||
ops-warden, after it holds an `access-engine` decision for the sign request, or
|
||||
after it has recorded the application of its own published unreachable-engine
|
||||
stance in place of a decision (`pep-stance.yaml` in ops-warden).
|
||||
|
||||
## Request
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `actor_type` | `adm` \| `agt` \| `atm` |
|
||||
| `actor_name` | Attributable principal the certificate is issued for |
|
||||
| `identity` | Certificate key id / identity string |
|
||||
| `principals` | Exact principal list; no wildcards |
|
||||
| `public_key` | Caller-supplied public key (not a private key) |
|
||||
| `ttl` | Requested lifetime; engine enforces the actor-type maximum |
|
||||
| `decision_id` | Access-engine decision record id, present only where a decision was rendered |
|
||||
| `request_digest` | Canonical digest of subject, action, resource, context |
|
||||
| `stance` | Named unreachable-engine residue if no decision was rendered |
|
||||
|
||||
Replay is permitted iff the canonical request digest matches and the decision
|
||||
lifetime holds. A different resource is a different request.
|
||||
|
||||
## Response (non-secret)
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `signed` | Whether OpenBao signed |
|
||||
| `valid_before` | Certificate expiry |
|
||||
| `role` | OpenBao SSH role used (`agt-role` / `adm-role` / `atm-role`) |
|
||||
| `decision_id` | Echoed when present |
|
||||
| `evidence_id` | Local non-secret evidence record id |
|
||||
|
||||
Certificate private material never appears in this response, in evidence, or in
|
||||
argv. Delivery of the signed certificate uses a scoped file or exec path with
|
||||
mode 0600, outside Git, same rules as existing handoff.
|
||||
|
||||
## Lifetime
|
||||
|
||||
Every allow has an explicit end: the certificate TTL, bounded by actor-type
|
||||
maxima already documented on the `warden-sign` lane. No standing grant.
|
||||
|
||||
## Evidence
|
||||
|
||||
Attributive unless a future control branches on the presence of a signing
|
||||
record — today none does. Completeness is not claimed. Records may include
|
||||
actor type, role, `valid_before`, decision id, and stance fields. They must
|
||||
never include a public-key blob if it could identify a live private key
|
||||
handle, a token, or certificate private material.
|
||||
|
||||
Destination today: local JSONL. Intended destination: `audit-core`.
|
||||
|
||||
## Non-responsibilities
|
||||
|
||||
secrets-engine does not choose principals, mint SSH keys, open tunnels, log
|
||||
into hosts, or override ops-warden's zone stance map. If `access-engine` is
|
||||
unreachable, this engine applies **its own** published catalog-stage stance
|
||||
(`pep-stance.yaml` in this repository) and does not consult ops-warden's map.
|
||||
|
||||
## Assent
|
||||
|
||||
A coordination message to ops-warden records this contract. Assent or contest
|
||||
belongs in ops-warden's own voice. Until then no OpenBao SSH-CA write is added
|
||||
to the secrets-engine CLI.
|
||||
62
evidence-classification.yaml
Normal file
62
evidence-classification.yaml
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# secrets-engine — load-bearing vs attributive evidence (§9.6)
|
||||
#
|
||||
# Machine-readable classification next to the layer declaration.
|
||||
# Completeness is not claimed. No control may branch on presence or
|
||||
# absence of a local evidence record.
|
||||
|
||||
schema_version: "0.1"
|
||||
framework: netkingdom-security-layer-model
|
||||
standard_version: "0.7"
|
||||
repository: secrets-engine
|
||||
bound: "§9.6"
|
||||
completeness_claimed: false
|
||||
no_control_branches_on_presence: true
|
||||
intended_destination: audit-core
|
||||
destination_today: "local JSONL; load-bearing also queued in evidence_dir/outbox/"
|
||||
sound_claim: >-
|
||||
The archive proves the records it holds were not altered or truncated
|
||||
after arrival. It does not prove a record was sent, and absence is not
|
||||
evidence of non-occurrence.
|
||||
|
||||
cadence:
|
||||
load_bearing_form: heartbeat
|
||||
interval: 1d
|
||||
action: evidence-heartbeat
|
||||
claim: nothing-to-report
|
||||
command: "secrets-engine evidence heartbeat"
|
||||
|
||||
# First match wins.
|
||||
rules:
|
||||
- id: heartbeat
|
||||
kind: heartbeat
|
||||
actions: [evidence-heartbeat]
|
||||
stages: [build, test, prod, unknown]
|
||||
emission: local-outbox
|
||||
note: >-
|
||||
Positive claim that can itself go missing. Queue depth is metadata,
|
||||
not a permission.
|
||||
|
||||
- id: destroy
|
||||
kind: load-bearing
|
||||
actions: [lifecycle-destroy]
|
||||
stages: [build, test, prod, unknown]
|
||||
emission: local-outbox
|
||||
note: Live destroy is currently disabled; refusals are still classified here.
|
||||
|
||||
- id: production-control-mutation
|
||||
kind: load-bearing
|
||||
actions: [revoke, lifecycle-suspend, lifecycle-deactivate, provision]
|
||||
stages: [prod]
|
||||
emission: local-outbox
|
||||
note: >-
|
||||
Production provision, revoke, and suspend/deactivate once enabled.
|
||||
Production live remains fail-closed until an access-engine decision
|
||||
record is served.
|
||||
|
||||
- id: default-attributive
|
||||
kind: attributive
|
||||
actions: ["*"]
|
||||
stages: [build, test, prod, unknown]
|
||||
emission: local-jsonl
|
||||
trade: >-
|
||||
State Hub progress posts are non-blocking. Completeness is not claimed.
|
||||
33
layer.yaml
33
layer.yaml
|
|
@ -68,10 +68,13 @@ proposed_capabilities:
|
|||
(ops-warden) does not hold a direct VaultCA write.
|
||||
intended_owner: secrets-engine
|
||||
owner_status: proposed
|
||||
contract: docs/ssh-ca-signing-contract.md
|
||||
blocked_on: >-
|
||||
No engine API exists yet. ops-warden continues to sign through its
|
||||
declared §5.3 gap. This repository accepts the engine surface and
|
||||
declines operational ownership of the SSH access lane.
|
||||
Contract published; ops-warden has not assented. No OpenBao SSH-CA
|
||||
write is added to this CLI until that assent. ops-warden continues
|
||||
to sign through its declared §5.3 gap. This repository accepts the
|
||||
engine surface and declines operational ownership of the SSH access
|
||||
lane.
|
||||
review: "2026-11-28"
|
||||
consequence: >-
|
||||
ops-warden's ssh-ca-signing-write gap remains the production path.
|
||||
|
|
@ -83,9 +86,11 @@ proposed_capabilities:
|
|||
exposed as an engine surface for kings-guard secret-abuse posture.
|
||||
intended_owner: secrets-engine
|
||||
owner_status: proposed
|
||||
contract: docs/secret-use-evidence-contract.md
|
||||
blocked_on: >-
|
||||
Local JSONL plus best-effort State Hub notes exist; they are not an
|
||||
engine API and they are not audit-core. Completeness is not claimed.
|
||||
Contract published; kings-guard has not assented. Local JSONL, the
|
||||
load-bearing outbox, and `route`/`audit` are not this observation
|
||||
API. Completeness is not claimed.
|
||||
review: "2026-11-28"
|
||||
consequence: >-
|
||||
kings-guard secret-abuse posture stays fixture-driven.
|
||||
|
|
@ -130,15 +135,29 @@ non_tooling_clients:
|
|||
preflight only and is deliberately not a write into OpenBao. Wiring it
|
||||
as OpenBao JWT login remains blocked on the platform-owned role.
|
||||
|
||||
- id: audit-core-outbox-drain
|
||||
target: audit-core
|
||||
layer: engine
|
||||
modules:
|
||||
- src/secrets_engine/evidence.py
|
||||
operation: "Optional POST /v1/events from secrets-engine evidence drain"
|
||||
write: true
|
||||
note: >-
|
||||
Engine-to-engine emit. Never on the mutation path. Empty URL skips
|
||||
delivery and leaves the local outbox in place. Sender binding in
|
||||
audit-core is not yet admitted.
|
||||
|
||||
# §9.6 — what this repository may claim about evidence it writes today.
|
||||
evidence_bound:
|
||||
kind: attributive
|
||||
classification: evidence-classification.yaml
|
||||
proves:
|
||||
- local JSONL records that were written were not rewritten by this engine
|
||||
- load-bearing records queued in evidence_dir/outbox/ were written
|
||||
does_not_prove:
|
||||
- that a record was ever sent to audit-core
|
||||
- absence of a record as evidence of non-occurrence
|
||||
- completeness of the trail
|
||||
destination_today: "local JSONL plus best-effort State Hub progress notes"
|
||||
destination_today: "local JSONL; load-bearing also queued locally; attributive State Hub notes"
|
||||
intended_destination: audit-core
|
||||
completeness_claimed: false
|
||||
no_control_branches_on_presence: true
|
||||
|
|
|
|||
|
|
@ -11,10 +11,9 @@
|
|||
# Catalog stage is the equivalent scope until security-zone membership arrives
|
||||
# as a claim on the decision.
|
||||
#
|
||||
# The property that makes this worth reading: tests/test_layer_conformance.py
|
||||
# asserts this map against the shipped production fail-closed gate in
|
||||
# secrets_engine.cli._require_lane_approval. A published map free to drift
|
||||
# from the code is worse than none.
|
||||
# Runtime reads this file. tests/test_layer_conformance.py also pins
|
||||
# secrets_engine.pep_stance.SHIPPED_STANCE to this map. A published map free
|
||||
# to drift from that pin is worse than none.
|
||||
|
||||
schema_version: "0.1"
|
||||
framework: netkingdom-security-layer-model
|
||||
|
|
@ -58,20 +57,17 @@ demo_exception:
|
|||
|
||||
on_apply:
|
||||
recorded_fields:
|
||||
- stage
|
||||
- action
|
||||
- stance_stage
|
||||
- stance_failure_mode
|
||||
- stance_demo_exception
|
||||
- stance_decision_id # present only where a decision was resolved
|
||||
- approval_status # rejected when the stance refuses
|
||||
- error_type # DecisionError when fail_closed fires
|
||||
- decision_id # present only where a decision was resolved
|
||||
written_to:
|
||||
- "local JSONL (.evidence/evidence-YYYY-MM-DD.jsonl)"
|
||||
- "best-effort State Hub progress note"
|
||||
- "evidence_dir/outbox/ for load-bearing classes"
|
||||
- "best-effort State Hub progress note (attributive only)"
|
||||
never_recorded: "any secret material, token, password, or raw value"
|
||||
gap: >-
|
||||
Stance application is currently recorded as a DecisionError on the
|
||||
privileged-evidence path, not as named stance fields (stage, failure
|
||||
mode, decision id present only where rendered). Closing that gap is
|
||||
SECRETS-WP-0008-T01.
|
||||
|
||||
# §6.4 obligation 2 — the verdict is never cached. A retry of the same
|
||||
# canonical request digest inside the decision lifetime is the decision
|
||||
|
|
|
|||
|
|
@ -145,6 +145,11 @@ def main() -> int:
|
|||
ok = False
|
||||
print(f"FINDING: pep_shaped but {decl['pep_stance']} is missing (§6.4 / §13.1)")
|
||||
|
||||
classification = decl.get("evidence_bound", {}).get("classification")
|
||||
if classification and not (ROOT / str(classification)).is_file():
|
||||
ok = False
|
||||
print(f"FINDING: evidence classification {classification} is missing (§9.6)")
|
||||
|
||||
found = scan_bao_modules()
|
||||
owned = owned_modules(decl)
|
||||
undeclared = {name: lines for name, lines in found.items() if name not in owned}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ Command surface (FR7):
|
|||
revoke <catalog-id>
|
||||
lifecycle suspend|deactivate|destroy <catalog-id>
|
||||
audit <catalog-id> [--json]
|
||||
evidence heartbeat|drain|classify
|
||||
|
||||
Every privileged action is decision-gated and writes non-secret evidence.
|
||||
`plan` and `apply --dry-run` never mutate OpenBao.
|
||||
|
|
@ -21,10 +22,8 @@ Every privileged action is decision-gated and writes non-secret evidence.
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from secrets_engine import __version__
|
||||
from secrets_engine.apply import apply_plan
|
||||
|
|
@ -33,6 +32,7 @@ from secrets_engine.config import Config, repo_root
|
|||
from secrets_engine.decisions import require_approved, resolve_decision
|
||||
from secrets_engine.errors import DecisionError, SecretsEngineError
|
||||
from secrets_engine.evidence import EvidenceWriter, PrivilegedActionEvidence
|
||||
from secrets_engine.pep_stance import apply_unreachable_engine_stance, with_decision
|
||||
from secrets_engine.openbao import OpenBaoClient
|
||||
from secrets_engine.plan import build_plan
|
||||
from secrets_engine.provision import provision_from_file, provision_generated
|
||||
|
|
@ -85,28 +85,23 @@ def _privileged_evidence(
|
|||
)
|
||||
|
||||
|
||||
def _unsafe_local_demo_enabled(cfg: Config) -> bool:
|
||||
"""Return true only for an explicit, offline, loopback-only demo."""
|
||||
host = (urlparse(cfg.bao_addr).hostname or "").lower()
|
||||
return (
|
||||
os.environ.get("SECRETS_ENGINE_UNSAFE_DEMO") == "1"
|
||||
and not cfg.hub_url
|
||||
and host in {"127.0.0.1", "localhost", "::1"}
|
||||
)
|
||||
def _require_lane_approval(
|
||||
cfg: Config,
|
||||
entry,
|
||||
action: str = "",
|
||||
evidence: PrivilegedActionEvidence | None = None,
|
||||
):
|
||||
"""Apply published PEP stance, then resolve lane approval.
|
||||
|
||||
|
||||
def _require_lane_approval(cfg: Config, entry, action: str = ""):
|
||||
"""Resolve approval for a live action, failing production closed.
|
||||
|
||||
The durable State Hub action-authorization endpoint is not available yet.
|
||||
Production therefore cannot rely on a coarse lane decision. The one narrow
|
||||
exception is an explicit offline demo against a loopback OpenBao instance.
|
||||
Production ``fail_closed`` is read from ``pep-stance.yaml``. The durable
|
||||
access-engine decision record is not served yet, so that row refuses live
|
||||
production work. The three-factor unsafe-demo exception is not a stance
|
||||
row. Build/test ``fail_open`` still requires the existing lane-approval
|
||||
check — a tracked gap until SECRETS-WP-0008-T02.
|
||||
"""
|
||||
if entry.stage == "prod" and not _unsafe_local_demo_enabled(cfg):
|
||||
raise DecisionError(
|
||||
f"production action '{action or 'unknown'}' requires a durable "
|
||||
"State Hub action authorization; live production remains disabled"
|
||||
)
|
||||
stance = apply_unreachable_engine_stance(cfg, entry, action or "unknown")
|
||||
if evidence is not None:
|
||||
evidence.mark_stance(stance)
|
||||
if not entry.approval_required():
|
||||
return None
|
||||
decision = resolve_decision(
|
||||
|
|
@ -115,6 +110,8 @@ def _require_lane_approval(cfg: Config, entry, action: str = ""):
|
|||
decision_ref=entry.approval.get("decision_ref", entry.id),
|
||||
)
|
||||
require_approved(entry, decision)
|
||||
if evidence is not None:
|
||||
evidence.mark_stance(with_decision(stance, decision))
|
||||
return decision
|
||||
|
||||
|
||||
|
|
@ -229,7 +226,7 @@ def cmd_apply(cfg: Config, args) -> int:
|
|||
return 0
|
||||
|
||||
with _privileged_evidence(cfg, entry, "apply") as evidence:
|
||||
decision = _require_lane_approval(cfg, entry, "apply")
|
||||
decision = _require_lane_approval(cfg, entry, "apply", evidence)
|
||||
evidence.mark_approved(decision)
|
||||
plan = build_plan(
|
||||
entry, args.stage, decision_id=decision.id if decision else ""
|
||||
|
|
@ -258,7 +255,7 @@ def cmd_provision(cfg: Config, args) -> int:
|
|||
raise ProvisioningError(
|
||||
f"lane '{entry.id}' is stage '{entry.stage}', not '{args.stage}'"
|
||||
)
|
||||
decision = _require_lane_approval(cfg, entry, "provision")
|
||||
decision = _require_lane_approval(cfg, entry, "provision", evidence)
|
||||
evidence.mark_approved(decision)
|
||||
client = OpenBaoClient.resolve(
|
||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||
|
|
@ -291,7 +288,7 @@ def cmd_verify(cfg: Config, args) -> int:
|
|||
"negative_requested": negative,
|
||||
},
|
||||
) as evidence:
|
||||
decision = _require_lane_approval(cfg, entry, "verify")
|
||||
decision = _require_lane_approval(cfg, entry, "verify", evidence)
|
||||
evidence.mark_approved(decision)
|
||||
client = OpenBaoClient.resolve(
|
||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||
|
|
@ -367,7 +364,7 @@ def cmd_handoff(cfg: Config, args) -> int:
|
|||
raise ProvisioningError(
|
||||
f"lane '{entry.id}' is {entry.kind}; handoff needs auth-capability"
|
||||
)
|
||||
decision = _require_lane_approval(cfg, entry, "handoff")
|
||||
decision = _require_lane_approval(cfg, entry, "handoff", evidence)
|
||||
evidence.mark_approved(decision)
|
||||
client = OpenBaoClient.resolve(
|
||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||
|
|
@ -419,7 +416,7 @@ def cmd_exec(cfg: Config, args) -> int:
|
|||
},
|
||||
) as evidence:
|
||||
# require approval + readiness before running.
|
||||
decision = _require_lane_approval(cfg, entry, "exec")
|
||||
decision = _require_lane_approval(cfg, entry, "exec", evidence)
|
||||
evidence.mark_approved(decision)
|
||||
if not args.command:
|
||||
from secrets_engine.errors import DeliveryError
|
||||
|
|
@ -498,7 +495,7 @@ def cmd_revoke(cfg: Config, args) -> int:
|
|||
with _privileged_evidence(
|
||||
cfg, entry, "revoke", detail={"operation": plan.operation}
|
||||
) as evidence:
|
||||
decision = _require_lane_approval(cfg, entry, "deactivate")
|
||||
decision = _require_lane_approval(cfg, entry, "deactivate", evidence)
|
||||
evidence.mark_approved(decision)
|
||||
client = OpenBaoClient.resolve(
|
||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||
|
|
@ -546,7 +543,7 @@ def cmd_lifecycle(cfg: Config, args) -> int:
|
|||
"live destroy is disabled until an exact-action destruction "
|
||||
"approval contract is available; use --dry-run to inspect targets"
|
||||
)
|
||||
decision = _require_lane_approval(cfg, entry, args.operation)
|
||||
decision = _require_lane_approval(cfg, entry, args.operation, evidence)
|
||||
evidence.mark_approved(decision)
|
||||
client = OpenBaoClient.resolve(
|
||||
cfg.bao_addr, bootstrap_token_file=args.bootstrap_token_file
|
||||
|
|
@ -579,6 +576,65 @@ def cmd_audit(cfg: Config, args) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def cmd_evidence_heartbeat(cfg: Config, args) -> int:
|
||||
"""Write a positive load-bearing heartbeat. Not a permission check."""
|
||||
import json
|
||||
|
||||
from secrets_engine.evidence import write_heartbeat
|
||||
|
||||
record = write_heartbeat(_writer(cfg), stage=args.stage)
|
||||
if args.json:
|
||||
print(json.dumps(record, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(
|
||||
f"heartbeat {record['result']} queued={record.get('outbox_queued')} "
|
||||
f"completeness_claimed={record.get('completeness_claimed')}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_evidence_drain(cfg: Config, args) -> int:
|
||||
"""Best-effort drain of the local load-bearing outbox. Never a gate."""
|
||||
import json
|
||||
|
||||
from secrets_engine.evidence import drain_outbox
|
||||
|
||||
result = drain_outbox(_writer(cfg), audit_core_url=args.audit_core_url)
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(
|
||||
f"outbox drain queued={result['queued']} "
|
||||
f"delivered={result['delivered']} failed={result['failed']} "
|
||||
f"skipped={result['skipped']}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_evidence_classify(cfg: Config, args) -> int:
|
||||
import json
|
||||
|
||||
from secrets_engine.evidence_class import classify
|
||||
|
||||
classified = classify(args.action, args.stage)
|
||||
payload = {
|
||||
"action": classified.action,
|
||||
"stage": classified.stage,
|
||||
"kind": classified.kind,
|
||||
"rule_id": classified.rule_id,
|
||||
"queued_locally": classified.queued_locally,
|
||||
"completeness_claimed": classified.completeness_claimed,
|
||||
}
|
||||
if args.json:
|
||||
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(
|
||||
f"{classified.action}/{classified.stage}: {classified.kind} "
|
||||
f"rule={classified.rule_id} queued={classified.queued_locally}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# -- parser ----------------------------------------------------------------
|
||||
|
||||
|
||||
|
|
@ -700,6 +756,32 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
au.add_argument("--json", action="store_true")
|
||||
au.set_defaults(func=cmd_audit)
|
||||
|
||||
ev = sub.add_parser("evidence", help="load-bearing evidence heartbeat and outbox")
|
||||
evsub = ev.add_subparsers(dest="subcmd", required=True)
|
||||
hb = evsub.add_parser(
|
||||
"heartbeat",
|
||||
help="emit a positive nothing-to-report claim (not a permission check)",
|
||||
)
|
||||
hb.add_argument("--stage", default="prod", choices=("build", "test", "prod"))
|
||||
hb.add_argument("--json", action="store_true")
|
||||
hb.set_defaults(func=cmd_evidence_heartbeat)
|
||||
dr = evsub.add_parser(
|
||||
"drain",
|
||||
help="best-effort drain of the local outbox; never blocks a mutation",
|
||||
)
|
||||
dr.add_argument(
|
||||
"--audit-core-url",
|
||||
default="",
|
||||
help="optional audit-core base URL; empty skips delivery and keeps files",
|
||||
)
|
||||
dr.add_argument("--json", action="store_true")
|
||||
dr.set_defaults(func=cmd_evidence_drain)
|
||||
cl = evsub.add_parser("classify", help="show the §9.6 class for an action/stage")
|
||||
cl.add_argument("action")
|
||||
cl.add_argument("--stage", required=True, choices=("build", "test", "prod"))
|
||||
cl.add_argument("--json", action="store_true")
|
||||
cl.set_defaults(func=cmd_evidence_classify)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ class DecisionError(SecretsEngineError):
|
|||
|
||||
exit_code = 3
|
||||
|
||||
def __init__(self, message: str, *, stance: dict[str, object] | None = None):
|
||||
super().__init__(message)
|
||||
self.stance = dict(stance or {})
|
||||
|
||||
|
||||
class PolicyGuardError(SecretsEngineError):
|
||||
"""A plan violates a safety guard (wildcard, out-of-stage path, root, ...)."""
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
|
||||
from secrets_engine.errors import DecisionError, SecretsEngineError
|
||||
from secrets_engine.evidence_class import KIND_ATTRIBUTIVE, classify
|
||||
from secrets_engine.redact import looks_secret, redact_text
|
||||
|
||||
# Keys that must never carry a value into evidence regardless of nesting.
|
||||
|
|
@ -64,6 +65,21 @@ class EvidenceWriter:
|
|||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(record, sort_keys=True) + "\n")
|
||||
|
||||
def outbox_dir(self) -> Path:
|
||||
path = self.evidence_dir / "outbox"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def _queue_outbox(self, record: dict[str, Any]) -> str:
|
||||
"""Durably queue a load-bearing record. Never talks to audit-core."""
|
||||
record_id = str(record.get("record_id") or uuid.uuid4())
|
||||
path = self.outbox_dir() / f"{record_id}.json"
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(record, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.replace(path)
|
||||
return record_id
|
||||
|
||||
def record(
|
||||
self,
|
||||
action: str,
|
||||
|
|
@ -75,9 +91,18 @@ class EvidenceWriter:
|
|||
detail: dict[str, Any] | None = None,
|
||||
hub: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Append one non-secret evidence record. Returns the stored record."""
|
||||
"""Append one non-secret evidence record. Returns the stored record.
|
||||
|
||||
Load-bearing records are queued locally first. An audit-core outage
|
||||
cannot occur here because this method never contacts audit-core.
|
||||
Completeness is never claimed. Presence or absence of a record is
|
||||
not consulted as a permission.
|
||||
"""
|
||||
record_id = str(uuid.uuid4())
|
||||
hub_requested = bool(hub and self.hub_url)
|
||||
evidence_class = classify(action, stage)
|
||||
hub_requested = bool(
|
||||
hub and self.hub_url and evidence_class.kind == KIND_ATTRIBUTIVE
|
||||
)
|
||||
record = {
|
||||
"record_id": record_id,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
|
|
@ -89,7 +114,13 @@ class EvidenceWriter:
|
|||
"decision_id": decision_id,
|
||||
"detail": _scrub(detail or {}),
|
||||
"hub_delivery_requested": hub_requested,
|
||||
"evidence_kind": evidence_class.kind,
|
||||
"evidence_rule": evidence_class.rule_id,
|
||||
"completeness_claimed": False,
|
||||
}
|
||||
if evidence_class.queued_locally:
|
||||
self._queue_outbox(record)
|
||||
record["outbox_queued"] = True
|
||||
self._append_local(record)
|
||||
if hub_requested:
|
||||
delivery = self._post_hub(
|
||||
|
|
@ -205,6 +236,7 @@ class PrivilegedActionEvidence:
|
|||
decision_id: str = ""
|
||||
approval_status: str = "pending"
|
||||
completed: bool = False
|
||||
stance: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.approval_required:
|
||||
|
|
@ -218,6 +250,8 @@ class PrivilegedActionEvidence:
|
|||
"decision_ref": self.decision_ref,
|
||||
}
|
||||
)
|
||||
if self.stance:
|
||||
merged.update(self.stance)
|
||||
if extra:
|
||||
merged.update(extra)
|
||||
return merged
|
||||
|
|
@ -239,6 +273,17 @@ class PrivilegedActionEvidence:
|
|||
self.decision_id = str(getattr(decision, "id", ""))
|
||||
self.approval_status = "approved"
|
||||
|
||||
def mark_stance(self, stance: object | None) -> None:
|
||||
if stance is None:
|
||||
return
|
||||
if hasattr(stance, "as_evidence"):
|
||||
payload = stance.as_evidence()
|
||||
elif isinstance(stance, dict):
|
||||
payload = stance
|
||||
else:
|
||||
return
|
||||
self.stance = {key: value for key, value in payload.items() if value != ""}
|
||||
|
||||
def finish(
|
||||
self, result: str, *, detail: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
|
|
@ -259,6 +304,8 @@ class PrivilegedActionEvidence:
|
|||
return False
|
||||
if isinstance(exc, DecisionError):
|
||||
self.approval_status = "rejected"
|
||||
if getattr(exc, "stance", None):
|
||||
self.mark_stance(exc.stance)
|
||||
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
||||
result = "interrupted"
|
||||
elif isinstance(exc, SecretsEngineError):
|
||||
|
|
@ -274,3 +321,76 @@ class PrivilegedActionEvidence:
|
|||
detail=self._detail({"error_type": type(exc).__name__}),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def write_heartbeat(writer: EvidenceWriter, *, stage: str = "prod") -> dict[str, Any]:
|
||||
"""Positive claim that can go missing. Not a permission and not silence."""
|
||||
queued = 0
|
||||
outbox = writer.evidence_dir / "outbox"
|
||||
if outbox.is_dir():
|
||||
queued = sum(1 for path in outbox.glob("*.json") if path.is_file())
|
||||
return writer.record(
|
||||
"evidence-heartbeat",
|
||||
result="nothing-to-report",
|
||||
stage=stage,
|
||||
detail={
|
||||
"form": "heartbeat",
|
||||
"outbox_depth": queued,
|
||||
"completeness_claimed": False,
|
||||
},
|
||||
hub=False,
|
||||
)
|
||||
|
||||
|
||||
def drain_outbox(
|
||||
writer: EvidenceWriter,
|
||||
*,
|
||||
audit_core_url: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Best-effort delivery of queued load-bearing records.
|
||||
|
||||
Never called from a mutation path. An audit-core outage leaves files in
|
||||
place and does not raise into a revoke/destroy handler.
|
||||
"""
|
||||
outbox = writer.evidence_dir / "outbox"
|
||||
if not outbox.is_dir():
|
||||
return {
|
||||
"queued": 0,
|
||||
"delivered": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"completeness_claimed": False,
|
||||
}
|
||||
files = sorted(path for path in outbox.glob("*.json") if path.is_file())
|
||||
queued = len(files)
|
||||
if not audit_core_url:
|
||||
return {
|
||||
"queued": queued,
|
||||
"delivered": 0,
|
||||
"failed": 0,
|
||||
"skipped": queued,
|
||||
"completeness_claimed": False,
|
||||
}
|
||||
delivered = 0
|
||||
failed = 0
|
||||
for path in files:
|
||||
try:
|
||||
payload = path.read_text(encoding="utf-8").encode()
|
||||
req = urllib.request.Request(
|
||||
audit_core_url.rstrip("/") + "/v1/events",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=3)
|
||||
path.unlink()
|
||||
delivered += 1
|
||||
except (urllib.error.URLError, OSError, ValueError):
|
||||
failed += 1
|
||||
return {
|
||||
"queued": queued,
|
||||
"delivered": delivered,
|
||||
"failed": failed,
|
||||
"skipped": 0,
|
||||
"completeness_claimed": False,
|
||||
}
|
||||
|
|
|
|||
164
src/secrets_engine/evidence_class.py
Normal file
164
src/secrets_engine/evidence_class.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"""Load-bearing vs attributive evidence classification (§9.6).
|
||||
|
||||
The YAML file is the declaration. ``SHIPPED_RULES`` is the pin that makes
|
||||
drift fail the test. No function here grants or denies an action based on
|
||||
whether a local evidence record exists.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from secrets_engine.config import repo_root
|
||||
from secrets_engine.errors import SecretsEngineError
|
||||
|
||||
KIND_LOAD_BEARING = "load-bearing"
|
||||
KIND_ATTRIBUTIVE = "attributive"
|
||||
KIND_HEARTBEAT = "heartbeat"
|
||||
|
||||
# First match wins. Destroy is always load-bearing; production control
|
||||
# mutations are load-bearing; everything else is attributive.
|
||||
SHIPPED_RULES = (
|
||||
{
|
||||
"id": "heartbeat",
|
||||
"kind": KIND_HEARTBEAT,
|
||||
"actions": ("evidence-heartbeat",),
|
||||
"stages": ("build", "test", "prod", "unknown"),
|
||||
},
|
||||
{
|
||||
"id": "destroy",
|
||||
"kind": KIND_LOAD_BEARING,
|
||||
"actions": ("lifecycle-destroy",),
|
||||
"stages": ("build", "test", "prod", "unknown"),
|
||||
},
|
||||
{
|
||||
"id": "production-control-mutation",
|
||||
"kind": KIND_LOAD_BEARING,
|
||||
"actions": (
|
||||
"revoke",
|
||||
"lifecycle-suspend",
|
||||
"lifecycle-deactivate",
|
||||
"provision",
|
||||
),
|
||||
"stages": ("prod",),
|
||||
},
|
||||
{
|
||||
"id": "default-attributive",
|
||||
"kind": KIND_ATTRIBUTIVE,
|
||||
"actions": ("*",),
|
||||
"stages": ("build", "test", "prod", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class EvidenceClassificationError(SecretsEngineError):
|
||||
"""Classification file missing or malformed."""
|
||||
|
||||
exit_code = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvidenceClass:
|
||||
kind: str
|
||||
rule_id: str
|
||||
action: str
|
||||
stage: str
|
||||
queued_locally: bool
|
||||
|
||||
@property
|
||||
def completeness_claimed(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def classification_path() -> Path:
|
||||
override = os.environ.get("SECRETS_ENGINE_EVIDENCE_CLASSIFICATION", "")
|
||||
if override:
|
||||
return Path(override)
|
||||
return repo_root() / "evidence-classification.yaml"
|
||||
|
||||
|
||||
def _normalize_rules(raw: Any) -> tuple[dict[str, Any], ...]:
|
||||
if not isinstance(raw, list) or not raw:
|
||||
raise EvidenceClassificationError(
|
||||
"evidence-classification.yaml must list at least one rule"
|
||||
)
|
||||
rules: list[dict[str, Any]] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
raise EvidenceClassificationError("each classification rule must be a map")
|
||||
kind = str(item.get("kind", ""))
|
||||
if kind not in {KIND_LOAD_BEARING, KIND_ATTRIBUTIVE, KIND_HEARTBEAT}:
|
||||
raise EvidenceClassificationError(f"unknown evidence kind {kind!r}")
|
||||
actions = tuple(str(a) for a in item.get("actions") or ())
|
||||
stages = tuple(str(s) for s in item.get("stages") or ())
|
||||
if not actions or not stages:
|
||||
raise EvidenceClassificationError(
|
||||
f"rule {item.get('id')!r} needs actions and stages"
|
||||
)
|
||||
rules.append(
|
||||
{
|
||||
"id": str(item.get("id", "")),
|
||||
"kind": kind,
|
||||
"actions": actions,
|
||||
"stages": stages,
|
||||
}
|
||||
)
|
||||
return tuple(rules)
|
||||
|
||||
|
||||
def load_classification_rules(
|
||||
path: Path | None = None,
|
||||
) -> tuple[dict[str, Any], ...]:
|
||||
target = path or classification_path()
|
||||
try:
|
||||
data = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise EvidenceClassificationError(
|
||||
f"unable to load evidence classification {target}: {exc}"
|
||||
) from exc
|
||||
if data.get("completeness_claimed") is not False:
|
||||
raise EvidenceClassificationError(
|
||||
f"{target} must declare completeness_claimed: false"
|
||||
)
|
||||
if data.get("no_control_branches_on_presence") is not True:
|
||||
raise EvidenceClassificationError(
|
||||
f"{target} must declare no_control_branches_on_presence: true"
|
||||
)
|
||||
return _normalize_rules(data.get("rules"))
|
||||
|
||||
|
||||
def classify(
|
||||
action: str,
|
||||
stage: str,
|
||||
*,
|
||||
rules: tuple[dict[str, Any], ...] | None = None,
|
||||
) -> EvidenceClass:
|
||||
"""Return the evidence class for an action/stage. Never a permission."""
|
||||
table = rules if rules is not None else load_classification_rules()
|
||||
stage_key = stage if stage else "unknown"
|
||||
for rule in table:
|
||||
actions = rule["actions"]
|
||||
stages = rule["stages"]
|
||||
if "*" not in actions and action not in actions:
|
||||
continue
|
||||
if stage_key not in stages and "*" not in stages:
|
||||
continue
|
||||
kind = str(rule["kind"])
|
||||
return EvidenceClass(
|
||||
kind=kind,
|
||||
rule_id=str(rule["id"]),
|
||||
action=action,
|
||||
stage=stage_key,
|
||||
queued_locally=kind in {KIND_LOAD_BEARING, KIND_HEARTBEAT},
|
||||
)
|
||||
return EvidenceClass(
|
||||
kind=KIND_ATTRIBUTIVE,
|
||||
rule_id="implicit-attributive",
|
||||
action=action,
|
||||
stage=stage_key,
|
||||
queued_locally=False,
|
||||
)
|
||||
141
src/secrets_engine/pep_stance.py
Normal file
141
src/secrets_engine/pep_stance.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""Published PEP unreachable-engine stance (security-layer-model v0.7 §6.4).
|
||||
|
||||
Runtime reads ``pep-stance.yaml``. ``SHIPPED_STANCE`` is the pin that makes
|
||||
drift between the published map and this module fail the conformance test.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import yaml
|
||||
|
||||
from secrets_engine.config import repo_root
|
||||
from secrets_engine.errors import DecisionError
|
||||
|
||||
SHIPPED_STANCE = {
|
||||
"build": "fail_open",
|
||||
"test": "fail_open",
|
||||
"prod": "fail_closed",
|
||||
"unknown": "fail_closed",
|
||||
}
|
||||
REQUIRED_STAGES = tuple(SHIPPED_STANCE)
|
||||
VALID_MODES = frozenset({"fail_open", "fail_closed"})
|
||||
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StanceApplication:
|
||||
"""Named residue applied when access-engine is unreachable."""
|
||||
|
||||
stage: str
|
||||
failure_mode: str
|
||||
action: str
|
||||
demo_exception: bool = False
|
||||
decision_id: str = ""
|
||||
|
||||
def as_evidence(self) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"stance_stage": self.stage,
|
||||
"stance_failure_mode": self.failure_mode,
|
||||
"stance_demo_exception": self.demo_exception,
|
||||
}
|
||||
if self.decision_id:
|
||||
payload["stance_decision_id"] = self.decision_id
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PepStanceMap:
|
||||
stance: dict[str, str]
|
||||
path: Path
|
||||
|
||||
def for_stage(self, stage: str) -> tuple[str, str]:
|
||||
key = stage if stage in self.stance else "unknown"
|
||||
mode = self.stance.get(key, "")
|
||||
if mode not in VALID_MODES:
|
||||
raise DecisionError(
|
||||
f"pep-stance.yaml has no usable mode for stage {stage!r}"
|
||||
)
|
||||
return key, mode
|
||||
|
||||
|
||||
def pep_stance_path() -> Path:
|
||||
override = os.environ.get("SECRETS_ENGINE_PEP_STANCE", "")
|
||||
if override:
|
||||
return Path(override)
|
||||
return repo_root() / "pep-stance.yaml"
|
||||
|
||||
|
||||
def load_pep_stance(path: Path | None = None) -> PepStanceMap:
|
||||
target = path or pep_stance_path()
|
||||
try:
|
||||
data = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise DecisionError(f"unable to load PEP stance map {target}: {exc}") from exc
|
||||
raw = data.get("stance")
|
||||
if not isinstance(raw, dict):
|
||||
raise DecisionError(f"{target} is missing a stance map")
|
||||
stance = {str(key): str(value) for key, value in raw.items()}
|
||||
missing = [stage for stage in REQUIRED_STAGES if stage not in stance]
|
||||
if missing:
|
||||
raise DecisionError(
|
||||
f"{target} is not total; missing stages {missing}"
|
||||
)
|
||||
unknown_modes = {
|
||||
f"{stage}={mode}"
|
||||
for stage, mode in stance.items()
|
||||
if mode not in VALID_MODES
|
||||
}
|
||||
if unknown_modes:
|
||||
raise DecisionError(f"{target} has invalid modes: {sorted(unknown_modes)}")
|
||||
return PepStanceMap(stance=stance, path=target)
|
||||
|
||||
|
||||
def demo_exception_enabled(cfg: Any) -> bool:
|
||||
"""Three-factor throwaway exception; not a stance row."""
|
||||
host = (urlparse(getattr(cfg, "bao_addr", "")).hostname or "").lower()
|
||||
return (
|
||||
os.environ.get("SECRETS_ENGINE_UNSAFE_DEMO") == "1"
|
||||
and not getattr(cfg, "hub_url", "")
|
||||
and host in LOOPBACK_HOSTS
|
||||
)
|
||||
|
||||
|
||||
def apply_unreachable_engine_stance(
|
||||
cfg: Any,
|
||||
entry: Any,
|
||||
action: str,
|
||||
*,
|
||||
stance_map: PepStanceMap | None = None,
|
||||
) -> StanceApplication:
|
||||
"""Apply the published unreachable-engine residue for a live action.
|
||||
|
||||
``fail_closed`` without the demo exception raises ``DecisionError`` carrying
|
||||
named stance fields. ``fail_open`` is the documented residue: continue to
|
||||
the existing lane-approval check, which is itself a gap until T02.
|
||||
"""
|
||||
loaded = stance_map or load_pep_stance()
|
||||
stage, mode = loaded.for_stage(getattr(entry, "stage", "unknown"))
|
||||
demo = demo_exception_enabled(cfg)
|
||||
applied = StanceApplication(
|
||||
stage=stage,
|
||||
failure_mode=mode,
|
||||
action=action or "unknown",
|
||||
demo_exception=bool(demo and mode == "fail_closed"),
|
||||
)
|
||||
if mode == "fail_closed" and not demo:
|
||||
raise DecisionError(
|
||||
f"production action '{applied.action}' requires a durable "
|
||||
"access-engine decision record; live production remains disabled",
|
||||
stance=applied.as_evidence(),
|
||||
)
|
||||
return applied
|
||||
|
||||
|
||||
def with_decision(stance: StanceApplication, decision: Any) -> StanceApplication:
|
||||
decision_id = str(getattr(decision, "id", "") or "")
|
||||
return replace(stance, decision_id=decision_id)
|
||||
102
tests/test_evidence_class.py
Normal file
102
tests/test_evidence_class.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""§9.6 classification, local outbox, heartbeat, and drain."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
from types import SimpleNamespace
|
||||
|
||||
from secrets_engine.evidence import EvidenceWriter, drain_outbox, write_heartbeat
|
||||
from secrets_engine.evidence_class import classify
|
||||
|
||||
|
||||
def test_load_bearing_record_is_queued_locally_before_jsonl(tmp_path):
|
||||
writer = EvidenceWriter(evidence_dir=tmp_path, hub_url="http://hub.invalid", topic_id="t")
|
||||
record = writer.record(
|
||||
"lifecycle-destroy",
|
||||
result="attempt",
|
||||
catalog_id="lane",
|
||||
stage="prod",
|
||||
detail={"value": "should-not-leak"},
|
||||
hub=True,
|
||||
)
|
||||
assert record["evidence_kind"] == "load-bearing"
|
||||
assert record["completeness_claimed"] is False
|
||||
assert record["outbox_queued"] is True
|
||||
assert record["hub_delivery_requested"] is False
|
||||
queued = list((tmp_path / "outbox").glob("*.json"))
|
||||
assert len(queued) == 1
|
||||
queued_payload = json.loads(queued[0].read_text())
|
||||
assert queued_payload["record_id"] == record["record_id"]
|
||||
assert "should-not-leak" not in queued[0].read_text()
|
||||
jsonl = next(tmp_path.glob("evidence-*.jsonl")).read_text()
|
||||
assert record["record_id"] in jsonl
|
||||
|
||||
|
||||
def test_attributive_record_is_not_queued_and_may_post_hub(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"urllib.request.urlopen",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(status=200, read=lambda: b"{}"),
|
||||
)
|
||||
writer = EvidenceWriter(evidence_dir=tmp_path, hub_url="http://hub.invalid", topic_id="t")
|
||||
record = writer.record("apply", result="applied", catalog_id="lane", stage="prod")
|
||||
assert record["evidence_kind"] == "attributive"
|
||||
assert record["hub_delivery_requested"] is True
|
||||
assert not list((tmp_path / "outbox").glob("*.json"))
|
||||
|
||||
|
||||
def test_heartbeat_is_a_positive_claim_not_a_permission(tmp_path):
|
||||
writer = EvidenceWriter(evidence_dir=tmp_path, hub_url="")
|
||||
record = write_heartbeat(writer, stage="prod")
|
||||
assert record["action"] == "evidence-heartbeat"
|
||||
assert record["result"] == "nothing-to-report"
|
||||
assert record["evidence_kind"] == "heartbeat"
|
||||
assert record["completeness_claimed"] is False
|
||||
assert record["outbox_queued"] is True
|
||||
# Heartbeat must not be used as "no jsonl means nothing happened".
|
||||
assert classify("evidence-heartbeat", "prod").kind == "heartbeat"
|
||||
|
||||
|
||||
def test_drain_without_audit_core_keeps_files(tmp_path):
|
||||
writer = EvidenceWriter(evidence_dir=tmp_path, hub_url="")
|
||||
writer.record("provision", result="from-file", catalog_id="lane", stage="prod")
|
||||
result = drain_outbox(writer, audit_core_url="")
|
||||
assert result["queued"] == 1
|
||||
assert result["skipped"] == 1
|
||||
assert result["delivered"] == 0
|
||||
assert result["completeness_claimed"] is False
|
||||
assert list((tmp_path / "outbox").glob("*.json"))
|
||||
|
||||
|
||||
def test_drain_audit_core_outage_does_not_raise_or_delete(tmp_path, monkeypatch):
|
||||
writer = EvidenceWriter(evidence_dir=tmp_path, hub_url="")
|
||||
writer.record("revoke", result="native-access-deactivated", catalog_id="lane", stage="prod")
|
||||
|
||||
def offline(*_args, **_kwargs):
|
||||
raise urllib.error.URLError("audit-core down")
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", offline)
|
||||
result = drain_outbox(writer, audit_core_url="http://audit-core.invalid")
|
||||
assert result["failed"] == 1
|
||||
assert result["delivered"] == 0
|
||||
assert list((tmp_path / "outbox").glob("*.json"))
|
||||
|
||||
|
||||
def test_successful_drain_removes_only_delivered_files(tmp_path, monkeypatch):
|
||||
writer = EvidenceWriter(evidence_dir=tmp_path, hub_url="")
|
||||
writer.record("provision", result="from-file", catalog_id="lane", stage="prod")
|
||||
monkeypatch.setattr(
|
||||
"urllib.request.urlopen",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(status=202, read=lambda: b"{}"),
|
||||
)
|
||||
result = drain_outbox(writer, audit_core_url="http://audit-core.invalid")
|
||||
assert result["delivered"] == 1
|
||||
assert not list((tmp_path / "outbox").glob("*.json"))
|
||||
|
||||
|
||||
def test_classify_is_not_a_permission_api():
|
||||
"""Missing or unknown actions are attributive labels, never a deny."""
|
||||
unknown = classify("not-a-control", "prod")
|
||||
assert unknown.kind == "attributive"
|
||||
assert unknown.completeness_claimed is False
|
||||
assert "permit" not in dir(classify)
|
||||
assert "deny" not in dir(classify)
|
||||
|
|
@ -17,6 +17,8 @@ import yaml
|
|||
from secrets_engine.catalog import validate_entry
|
||||
from secrets_engine.cli import _require_lane_approval
|
||||
from secrets_engine.errors import DecisionError
|
||||
from secrets_engine.evidence_class import SHIPPED_RULES, classify, load_classification_rules
|
||||
from secrets_engine.pep_stance import SHIPPED_STANCE, load_pep_stance
|
||||
|
||||
from tests.test_catalog import VALID
|
||||
|
||||
|
|
@ -25,6 +27,7 @@ SCRIPT = ROOT / "scripts" / "check_layer_conformance.py"
|
|||
DECL = ROOT / "layer.yaml"
|
||||
STANCE = ROOT / "pep-stance.yaml"
|
||||
INTENT = ROOT / "INTENT.md"
|
||||
CLASSIFICATION = ROOT / "evidence-classification.yaml"
|
||||
|
||||
|
||||
def _decl() -> dict:
|
||||
|
|
@ -73,6 +76,7 @@ def test_proposed_capabilities_carry_gap_record_fields():
|
|||
assert cap.get(field), f"{cap.get('id')} missing {field}"
|
||||
assert cap["state"] == "unowned-capability"
|
||||
assert cap["owner_status"] == "proposed"
|
||||
assert cap.get("contract"), f"{cap.get('id')} missing contract"
|
||||
|
||||
|
||||
def test_stance_map_is_total_over_catalog_stages():
|
||||
|
|
@ -85,19 +89,53 @@ def test_stance_map_is_total_over_catalog_stages():
|
|||
assert _stance()["verdict_caching"] == "none"
|
||||
|
||||
|
||||
def test_published_map_equals_shipped_constant_and_loader():
|
||||
"""Changing the YAML without changing SHIPPED_STANCE fails, and the reverse."""
|
||||
published = _stance()["stance"]
|
||||
loaded = load_pep_stance().stance
|
||||
assert published == SHIPPED_STANCE
|
||||
assert loaded == SHIPPED_STANCE
|
||||
|
||||
|
||||
def test_published_prod_stance_equals_shipped_fail_closed(monkeypatch):
|
||||
"""pep-stance.yaml prod: fail_closed must equal _require_lane_approval."""
|
||||
assert _stance()["stance"]["prod"] == "fail_closed"
|
||||
"""Runtime reads pep-stance.yaml; prod fail_closed must equal the gate."""
|
||||
assert load_pep_stance().stance["prod"] == "fail_closed"
|
||||
entry = validate_entry(dict(VALID, stage="prod", approval={"model": "bootstrap-only"}))
|
||||
cfg = SimpleNamespace(hub_url="http://127.0.0.1:8000", bao_addr="http://127.0.0.1:8200")
|
||||
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
|
||||
with pytest.raises(DecisionError, match="live production remains disabled"):
|
||||
with pytest.raises(DecisionError, match="live production remains disabled") as raised:
|
||||
_require_lane_approval(cfg, entry, "apply")
|
||||
assert raised.value.stance["stance_stage"] == "prod"
|
||||
assert raised.value.stance["stance_failure_mode"] == "fail_closed"
|
||||
assert "stance_decision_id" not in raised.value.stance
|
||||
|
||||
|
||||
def test_yaml_is_the_runtime_source(tmp_path, monkeypatch):
|
||||
"""A published map the pin does not match is a test failure; runtime follows YAML."""
|
||||
path = tmp_path / "pep-stance.yaml"
|
||||
path.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"stance": {
|
||||
"build": "fail_open",
|
||||
"test": "fail_open",
|
||||
"prod": "fail_open",
|
||||
"unknown": "fail_closed",
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("SECRETS_ENGINE_PEP_STANCE", str(path))
|
||||
entry = validate_entry(dict(VALID, stage="prod", approval={"model": "bootstrap-only"}))
|
||||
cfg = SimpleNamespace(hub_url="http://127.0.0.1:8000", bao_addr="http://127.0.0.1:8200")
|
||||
monkeypatch.delenv("SECRETS_ENGINE_UNSAFE_DEMO", raising=False)
|
||||
assert _require_lane_approval(cfg, entry, "apply") is None
|
||||
|
||||
|
||||
def test_build_stage_is_not_the_production_fail_closed_gate(tmp_path, monkeypatch):
|
||||
"""build is fail_open relative to access-engine: lane approval still applies."""
|
||||
assert _stance()["stance"]["build"] == "fail_open"
|
||||
assert load_pep_stance().stance["build"] == "fail_open"
|
||||
(tmp_path / ".decisions").mkdir()
|
||||
(tmp_path / ".decisions" / "x.yaml").write_text(
|
||||
"id: x\ntitle: approved\nstatus: resolved\nsuperseded_by: null\n"
|
||||
|
|
@ -114,4 +152,38 @@ def test_build_stage_is_not_the_production_fail_closed_gate(tmp_path, monkeypatc
|
|||
)
|
||||
)
|
||||
cfg = SimpleNamespace(hub_url="", bao_addr="http://127.0.0.1:8200")
|
||||
assert _require_lane_approval(cfg, entry, "apply").id == "x"
|
||||
decision = _require_lane_approval(cfg, entry, "apply")
|
||||
assert decision.id == "x"
|
||||
|
||||
|
||||
def test_classification_yaml_equals_shipped_rules():
|
||||
loaded = load_classification_rules()
|
||||
assert tuple(rule["id"] for rule in loaded) == tuple(rule["id"] for rule in SHIPPED_RULES)
|
||||
assert tuple(rule["kind"] for rule in loaded) == tuple(rule["kind"] for rule in SHIPPED_RULES)
|
||||
|
||||
|
||||
def test_classify_does_not_grant_permission():
|
||||
prod_provision = classify("provision", "prod")
|
||||
test_provision = classify("provision", "test")
|
||||
destroy = classify("lifecycle-destroy", "build")
|
||||
apply_prod = classify("apply", "prod")
|
||||
heartbeat = classify("evidence-heartbeat", "prod")
|
||||
assert prod_provision.kind == "load-bearing"
|
||||
assert test_provision.kind == "attributive"
|
||||
assert destroy.kind == "load-bearing"
|
||||
assert apply_prod.kind == "attributive"
|
||||
assert heartbeat.kind == "heartbeat"
|
||||
assert prod_provision.completeness_claimed is False
|
||||
assert CLASSIFICATION.exists()
|
||||
|
||||
|
||||
def test_proposed_contracts_exist_and_forbid_secret_material():
|
||||
ssh = (ROOT / "docs/ssh-ca-signing-contract.md").read_text(encoding="utf-8")
|
||||
secret_use = (ROOT / "docs/secret-use-evidence-contract.md").read_text(encoding="utf-8")
|
||||
assert "proposed" in ssh.lower()
|
||||
assert "proposed" in secret_use.lower()
|
||||
assert "warden sign" in ssh
|
||||
assert "private key" in ssh.lower() or "private keys" in ssh.lower()
|
||||
assert "secret values" in secret_use.lower() or "secret value" in secret_use.lower()
|
||||
assert "audit-core" in secret_use
|
||||
assert "completeness is not claimed" in secret_use.lower()
|
||||
|
|
|
|||
|
|
@ -121,3 +121,12 @@ def test_production_handler_fails_closed_before_backend(tmp_path, monkeypatch):
|
|||
"attempt",
|
||||
"failed-DecisionError",
|
||||
]
|
||||
terminal = records[-1]
|
||||
assert terminal["detail"]["stance_stage"] == "prod"
|
||||
assert terminal["detail"]["stance_failure_mode"] == "fail_closed"
|
||||
assert terminal["detail"]["approval_status"] == "rejected"
|
||||
assert "stance_decision_id" not in terminal["detail"]
|
||||
assert terminal["completeness_claimed"] is False
|
||||
assert "SUPER-SECRET" not in json.dumps(records)
|
||||
outbox = list((tmp_path / "evidence" / "outbox").glob("*.json"))
|
||||
assert outbox, "production provision refusal is load-bearing and must be queued"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ type: workplan
|
|||
title: "Evolve the Lifecycle engine to the accepted security layer model"
|
||||
domain: infotech
|
||||
repo: secrets-engine
|
||||
status: ready
|
||||
status: active
|
||||
owner: grok
|
||||
topic_slug: custodian
|
||||
created: "2026-08-29"
|
||||
|
|
@ -56,11 +56,16 @@ It adds the layer-model remainder: stance binding, decision-record consumption,
|
|||
|
||||
```task
|
||||
id: SECRETS-WP-0008-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "945735c6-2d3d-5f72-baf0-692e6b210889"
|
||||
```
|
||||
|
||||
Completed 2026-08-29. `_require_lane_approval` loads `pep-stance.yaml`.
|
||||
`SHIPPED_STANCE` is pinned equal to the published map. Fail-closed production
|
||||
evidence records `stance_stage`, `stance_failure_mode`, and omits
|
||||
`stance_decision_id` unless a decision was rendered.
|
||||
|
||||
`pep-stance.yaml` is published and tests pin `prod: fail_closed` to
|
||||
`_require_lane_approval`. The gate is still hardcoded, and a refusal is
|
||||
recorded as a `DecisionError` rather than named stance fields.
|
||||
|
|
@ -111,11 +116,16 @@ Acceptance:
|
|||
|
||||
```task
|
||||
id: SECRETS-WP-0008-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "0b54cedf-9335-5fa0-bdcb-1efa9a9cd98d"
|
||||
```
|
||||
|
||||
Completed 2026-08-29. `evidence-classification.yaml` is the declaration.
|
||||
Load-bearing records are queued in `evidence_dir/outbox/` before JSONL.
|
||||
`secrets-engine evidence heartbeat` emits a positive claim. Drain never sits
|
||||
on a mutation path and an audit-core outage leaves the queue in place.
|
||||
|
||||
Classify each privileged action as load-bearing or attributive. Today's trail
|
||||
is attributive local JSONL plus best-effort State Hub notes; completeness is
|
||||
already not claimed and must stay that way until the destination is
|
||||
|
|
@ -142,11 +152,14 @@ Acceptance:
|
|||
|
||||
```task
|
||||
id: SECRETS-WP-0008-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "581aeee3-3d99-5034-992b-7d42e6542a19"
|
||||
```
|
||||
|
||||
Completed 2026-08-29. Contract published at `docs/ssh-ca-signing-contract.md`.
|
||||
No OpenBao SSH-CA write was added. `layer.yaml` owner_status remains proposed.
|
||||
|
||||
ops-warden's `ssh-ca-signing-write` gap names this engine as intended owner.
|
||||
Accept the **engine API**, decline the Staff lane. Publish a contract
|
||||
ops-warden can call after an `access-engine` decision: sign a short-lived
|
||||
|
|
@ -168,11 +181,15 @@ Acceptance:
|
|||
|
||||
```task
|
||||
id: SECRETS-WP-0008-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "3100d28c-9494-5b21-9d5a-af583cd8aa55"
|
||||
```
|
||||
|
||||
Completed 2026-08-29. Contract published at
|
||||
`docs/secret-use-evidence-contract.md`. `layer.yaml` owner_status remains
|
||||
proposed. `route`/`audit` are still not this surface.
|
||||
|
||||
kings-guard's secret-abuse posture is fixture-driven because no engine exposes
|
||||
lease, revocation, mount, rotation, and delivery-session metadata. `route` and
|
||||
`audit` are operator summaries over local JSONL and are not that surface.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue