ADR-378/379/380: npm Trusted Publishing, statusline segments, AGNTCY/Outshift runtime integration (#2879)

* docs(adr): ADR-378/379/380 — npm trusted publishing, statusline segments, AGNTCY runtime integration

Renumbered from the original 322/323/324 (chosen when this branch was
based on an older main) — origin/main has since merged real, Accepted
ADRs at those numbers (322=metaharness-flywheel-integration,
323=typed-memory-provenance, 324=agentic-policy-engine-codex-swarm).
Content unchanged from the original commit, only the numbers and
filenames moved to the next free slot after ADR-377.

ADR-378: npm Trusted Publishing (OIDC) for CI/CD release automation,
replacing the standing NPM_TOKEN class with per-run short-lived
tokens; pairs GCP WIF for the helpers-signing-secret fetch instead of
a static service-account key.

ADR-379: optional context/session/week usage segments and extra
statusline tip lines for .claude/helpers/statusline.cjs, following
the existing CONFIG.hideCost toggle precedent; session/week/effort
default OFF pending a stdin-schema verification spike.

ADR-380: AGNTCY/Outshift runtime integration (SLIM transport, CASA
enforcement, IOC Layer 9 coordination events) as an optional,
removable augmentation per ADR-150's pattern. Companion to metaharness
repo ADR-237 (agent/adr-237-agntcy-outshift-integration branch), which
owns the build-time half (identity, OASF export, observability).

* feat(agntcy): ADR-380 scaffolding — CASA envelope, SLIM/IOC CLI verbs, Rust crate

Renumbered from the original ADR-324 (origin/main has since merged a
real, unrelated, Accepted ADR-324 — see the companion docs commit).
Content and behavior unchanged; only the ADR number/filename and every
in-file reference to it moved to ADR-380.

Implements the buildable-now portion of ADR-380 (AGNTCY/Outshift
runtime integration) as optional, removable augmentation per ADR-150's
pattern. No AGNTCY/SLIM/Outshift npm or crates.io packages exist yet
under any plausible name (verified live) — every network touchpoint is
a clearly-logged stub gated behind explicit config, never a fake
success.

- plugins/ruflo-agntcy/: CASA envelope schema (Zod) + deterministic
  compiler + enforcement gate (checkAuthorization, deny-by-default) +
  bypass-attempt tests; Ed25519-signed CASA decision receipts
  (.swarm/casa-receipts.jsonl); AGNTCY OTel span attribute constants
  this repo owns (coordination.episode, authorization.decision).
- v3/@claude-flow/cli/src/commands/agntcy/: `ruflo transport use slim`,
  `ruflo agent publish`, `ruflo swarm join <namespace>` — wired into
  the top-level command registry and into agent.ts/swarm.ts's existing
  subcommand arrays (merged alongside main's concurrently-added
  pheromoneCommand); all exit 0 with a clear message when AGNTCY/SLIM
  isn't configured (RUFLO_AGNTCY_SLIM_ENDPOINT unset).
- v3/crates/ruflo-agntcy/: Rust CasaEnvelope + check_authorization
  mirroring the TS enforcement logic exactly, LocalTransport (real,
  working, in-process) + SlimTransport stub behind a non-default
  `slim` Cargo feature. Added to the workspace; also fixed a
  pre-existing `cargo check` blocker (v3/plugins/gastown-bridge's
  nested [workspace] table conflicting with the parent workspace) by
  moving it from `members` to `exclude`.

Adversarial security review (as ADR-380 §3 requires before trusting
this gate) found and this commit fixes two real bugs in the first
pass:
- enforce.ts trusted the erased TS type instead of validating its own
  input — a non-array `allow`/`deny` collapsed `.includes()` into
  JS's substring-match overload, and a malformed `now`/`expires_at`
  silently skipped the expiry check instead of failing closed.
  checkAuthorization now runs CasaEnvelopeSchema.safeParse() on entry,
  requires an explicit timezone offset on all timestamps, and treats
  any unparseable timestamp as expired.
- compile.ts's keyword table matched bare "push"/"publish"/"release"/
  "deploy" with no contextual gate, false-permissively granting
  git.push/deployment.create for objectives like "push notification
  integration" or "review the release notes". Patterns now require a
  co-occurring contextual noun (git/commit/branch for push;
  service/app/production/version/etc. for deploy).

Deferred to a follow-up pass (documented, not silently dropped):
plugins/ruflo-agntcy has no package.json yet (this repo's plugins/
ruflo-* convention is Claude Code plugin content, not an npm
workspace package — unlike @metaharness/agntcy's sibling-package
convention on the metaharness side), so no optionalDependencies entry
was added anywhere; adding one now would reference a package shape
that doesn't exist and break installs.

Companion: metaharness repo ADR-237 (agent/adr-237-agntcy-outshift-
integration branch) — build-time half of this integration.

* docs(agntcy): fix stale companion-ADR cross-reference (metaharness ADR-237 -> ADR-240)

Metaharness's own numbering collided too (ADR-237 was already taken by
evals-math-live-gsm8k-fast-domain on their main) and got renumbered to
ADR-240 — this repo's references to it were stale.

* fix(agntcy): add missing allowed-tools frontmatter to agntcy-status skill

CI's fleet-wide SKILL.md frontmatter audit correctly flagged this —
no implicit 'all tools' is allowed. Scoped to Read since this is a
scaffolding stub (not yet implemented) that will only ever need to
inspect local package.json/config state, never execute commands.
This commit is contained in:
rUv
2026-07-30 23:05:44 -04:00
committed by GitHub
parent 491c75a6fa
commit 1e64c2fd3b
32 changed files with 3554 additions and 2 deletions
+14
View File
@@ -2,9 +2,23 @@
resolver = "2"
members = [
"v3/crates/ruflo-federation-peer",
"v3/crates/ruflo-agntcy",
]
exclude = [
"v3/plugins/gastown-bridge",
]
# ruflo is primarily TypeScript; this workspace manifest exists so the
# repo-scorecard analyzer sees ruflo's Rust components (federation peer
# binary, gastown-bridge plugin) without traversing deep subdirs.
#
# `v3/plugins/gastown-bridge` is deliberately in `exclude`, not `members`:
# it declares its own nested `[workspace]` (a separate WASM-focused
# workspace — see its own Cargo.toml) for `wasm/gastown-formula-wasm` and
# `wasm/ruvector-gnn-wasm`. Cargo refuses to resolve ANY member of this
# (parent) workspace — old or new — while a listed member path is itself
# another workspace root ("multiple workspace roots found in the same
# workspace"). `exclude` keeps the directory out of Cargo's member-set
# resolution (so `cargo check`/`cargo test` on every other crate here
# works) while still leaving it in the repo tree at the same path for the
# scorecard analyzer to find.
@@ -0,0 +1,27 @@
{
"name": "ruflo-agntcy",
"description": "AGNTCY/Outshift Internet-of-Cognition runtime integration for Ruflo — SLIM secure transport for cross-host agent coordination, CASA intent-scoped tool authorization enforcement, optional IOC Layer 9 coordination events, and AGNTCY identity/observability spans (ADR-380). Optional, removable augmentation per ADR-150; degrades gracefully when AGNTCY/SLIM packages are not installed.",
"version": "0.1.0",
"author": {
"name": "ruvnet",
"url": "https://github.com/ruvnet"
},
"homepage": "https://github.com/ruvnet/ruflo",
"license": "MIT",
"keywords": [
"ruflo",
"agntcy",
"outshift",
"casa",
"slim",
"ioc",
"internet-of-cognition",
"identity",
"observability",
"oasf",
"mycelium",
"authorization",
"multi-agent",
"distributed"
]
}
+51
View File
@@ -0,0 +1,51 @@
# ruflo-agntcy
AGNTCY/Outshift Internet-of-Cognition (IOC) runtime integration for Ruflo — the "AGNTCY-identified, SLIM-transported, CASA-authorized" leg of the clean-positioning stack described in [ADR-380](docs/adrs/ADR-380-agntcy-outshift-runtime-integration.md).
> AGNTCY and Outshift define the agent network. MetaHarness builds and evolves the agents. RuFlo executes and coordinates them. Meta LLM governs inference, cost, tenancy, and safety. RuVector supplies local memory and semantic state.
## Status: optional, removable augmentation
This plugin follows the **ADR-150 precedent**, not the ADR-321 hard-dependency exception (see [ADR-380 §1](docs/adrs/ADR-380-agntcy-outshift-runtime-integration.md#1-optional-removable-augmentation--follows-adr-150-not-adr-321)). AGNTCY/SLIM/CASA/IOC are early-stage, externally governed protocols (Cisco Outshift-led, Linux Foundation) this project does not control. Concretely:
- Every AGNTCY-facing package this plugin references (`@claude-flow/agntcy` and/or a Rust `ruflo-agntcy` crate) MUST live in `optionalDependencies`, never in `dependencies`.
- Ruflo MUST remain fully operational with every AGNTCY package removed — `npm ls --without @claude-flow/agntcy` (or equivalent) must still produce a working CLI.
- Every code path that touches AGNTCY infrastructure MUST catch `MODULE_NOT_FOUND` / connection-refused and fall back to today's local transport and existing tool-authorization model.
- This MUST pass a "works without AGNTCY installed" smoke test, mirrored on ADR-150's CI-gated architectural-constraint pattern.
## No upstream packages exist yet
As of this plugin's scaffolding date, **no AGNTCY/SLIM/Outshift npm or crates.io package exists under any plausible name** — verified 404 across every guessed identifier. Nothing in this plugin `require`s or `import`s such a package. Any network-touching piece (Directory publish, SLIM connect, CASA policy fetch) is implemented as a **clearly-logged, clearly-erroring stub** behind a feature flag / config check, never faked as if it succeeded. The stub's error message points back at this plugin's governing ADRs (ADR-380, and companion metaharness ADR-240) so a caller understands *why* it failed, not just *that* it failed.
## What's Included (per ADR-380)
| Area | ADR-380 section | Scope |
|------|------------------|-------|
| **SLIM transport** | §2 | Opt-in transport switch (`ruflo transport use slim`) for cross-host/cross-tenant swarm and hive-mind coordination. Local in-process transport stays the default for single-host swarms — zero behavior change, zero new operational cost in the common case. |
| **CASA authorization** | §3 | Deterministic, deny-by-default enforcement of a compiled intent envelope (`allow`/`deny`/`budget_usd`/`expires_at`) in front of every MCP tool call and `Agent`/`Task` dispatch. Enforcement never asks an LLM whether an action is permitted — that would defeat the entire point of the gate. |
| **IOC Layer 9 coordination events** | §4 | Optional semantic coordination events (Semantic Information Exchange, Cognition and Interoperability, Semantic Alignment Broadcast, Team Formation via Polling) layered on top of — never replacing — Ruflo's own `hive-mind_broadcast` / `hive-mind_consensus` / `coordination_consensus` orchestration. |
| **AGNTCY identity/observability** | §5 | OTel span attributes (`agent.identity`, `agent.capability`, `agent.intent`, `agent.parent`, `coordination.episode`, `authorization.decision`, `model.route`, `memory.provenance`, `evaluation.score`, `receipt.hash`) wired through the existing `ruflo-observability` plugin rather than a second tracing pipeline. Ruflo owns the two runtime-only attributes (`coordination.episode`, `authorization.decision`); the rest are emitted build-time by companion metaharness ADR-240. |
## Companion ADR
This plugin's runtime half is paired with metaharness repo ADR-240 (build-time half — AGNTCY identity generation, OASF export, semantic observability at manifest time). Neither ADR is complete without the other; see [ADR-380's "Companion" note](docs/adrs/ADR-380-agntcy-outshift-runtime-integration.md).
## Requires
- `ruflo-core` plugin (provides the MCP server this plugin's tools/commands attach to)
- `ruflo-observability` plugin (span/trace sink for §5's AGNTCY OTel attributes)
- Optionally, once upstream stabilizes: `@claude-flow/agntcy` (TS) and/or a Rust `ruflo-agntcy` crate targeting SLIM (§6)
## Architecture Decisions
- [`ADR-380` — AGNTCY/Outshift Runtime Integration: SLIM Transport, CASA Enforcement, IOC Coordination Events](docs/adrs/ADR-380-agntcy-outshift-runtime-integration.md)
## References
- Cisco AGNTCY overview — https://outshift.cisco.com/the-internet-of-agents/agntcy
- AGNTCY Identity — https://github.com/agntcy/identity
- AGNTCY Directory — https://github.com/agntcy/dir
- AGNTCY Observe — https://github.com/agntcy/observe
- SLIM architecture — https://github.com/agntcy/slim
- Cisco CASA overview — https://outshift.cisco.com/blog/ai-ml/continuous-agentic-semantic-authorization-for-mas
- IOC protocol repository — https://github.com/outshift-open/ioc-protocols-models
@@ -0,0 +1,128 @@
# ADR-380 — AGNTCY/Outshift Runtime Integration: SLIM Transport, CASA Enforcement, IOC Coordination Events
- **Status**: Proposed
- **Date**: 2026-07-30
- **Related**: ADR-150 (MetaHarness integration surfaces — the optional/removable-augmentation precedent this ADR follows), ADR-321 (metaharness hard-dependency exception — explicitly **not** followed here, see Decision §1), Cognitum funnel/tenancy ADRs (ADR-301/305/306 — tenant field alignment)
- **Companion**: metaharness repo ADR-240 (`agent/adr-237-agntcy-outshift-integration` branch) — AGNTCY identity, OASF export, semantic observability. That ADR covers what MetaHarness produces at build/manifest time; this ADR covers what RuFlo does with it at runtime. Neither is complete without the other; they are numbered and shipped as a pair.
- **Prompted by**: a strategic brief evaluating Cisco Outshift's AGNTCY / Internet of Cognition ecosystem (Mycelium is one coordination implementation inside that broader IoC program) as complementary, not competitive, infrastructure:
> AGNTCY and Outshift define the agent network. MetaHarness builds and evolves the agents. RuFlo executes and coordinates them. Meta LLM governs inference, cost, tenancy, and safety. RuVector supplies local memory and semantic state.
## Context
A repo-wide check found **zero existing references** to AGNTCY, Outshift, OASF, CASA, SLIM, or Mycelium anywhere in this codebase — genuinely greenfield, not a gap in an existing effort.
Two of the "clean positioning" stack's five legs are already real, shipping components and require no new work to be true:
- **"Meta LLM governs inference, cost, tenancy, and safety"** — already this repo's documented role for the meta-llm gateway (`metallm_delegate`/`metallm_ask`, root `CLAUDE.md`'s "Gateway-Delegated Development" section): cost-tier routing, metered spend, cwd-sandboxed agentic sub-tasks.
- **"RuVector supplies local memory and semantic state"** — already shipping (`ruflo-ruvector` plugin, `vector-engineer` agent, HNSW/RaBitQ-backed AgentDB memory).
This ADR is scoped to the two genuinely new legs RuFlo itself needs: AGNTCY-identified, SLIM-transported coordination, and CASA-enforced authorization — plus optional IOC Layer 9 coordination events layered on top of existing swarm/hive-mind orchestration.
## Decision
### 1. Optional, removable augmentation — follows ADR-150, not ADR-321
Unlike metaharness (ADR-321's hard-dependency exception, justified because metaharness is this project's own sibling tooling with an established track record), AGNTCY/SLIM/CASA/IOC are early-stage, externally governed (Cisco Outshift-led, Linux Foundation) protocols this project does not control. Every new package this ADR introduces — a TS `@claude-flow/agntcy` package and/or a Rust `ruflo-agntcy` crate (§6) — MUST live in `optionalDependencies`, MUST degrade gracefully (`MODULE_NOT_FOUND` / connection-refused → fall back to today's local transport and existing tool-authorization model), and MUST pass a "works without AGNTCY installed" smoke test — mirroring ADR-150's four architectural-constraint rules verbatim: removable, optional-only, graceful degradation, CI-gated.
### 2. SLIM transport for distributed coordination — local transport stays the default
Add three new `ruflo` CLI verbs:
```text
ruflo transport use slim
ruflo agent publish
ruflo swarm join cognitum/research/security
```
`ruflo transport use slim` switches the active swarm/hive-mind transport from today's in-process/local-hooks routing to SLIM (Rust, secure messaging for MCP/A2A, hierarchical routing, reliable delivery, group membership, MLS end-to-end encryption; JWT/mTLS/SPIFFE/WebSocket/Unix-socket auth) for agents coordinating across hosts or across a Cognitum tenant boundary.
**Keep the current local transport as the default for single-host swarms.** The originating brief is explicit that SLIM infrastructure adds unnecessary operational cost there, and this repo already has a working local coordination path (`swarm_init`/`hive-mind_*` MCP tools, the hierarchical-mesh anti-drift topology) that must not regress in the common case.
`ruflo agent publish` emits the AGNTCY-identified, OASF-described agent record (produced build-time by metaharness ADR-240 §2.1/2.2) to the configured Directory. `ruflo swarm join <namespace>` joins a SLIM group-membership channel scoped to a Cognitum tenant/project namespace.
Estimated effort: 15–25 days.
### 3. CASA intent-scoped authorization enforcement
MetaHarness (companion ADR-240 §4) compiles a user's objective into a bounded authority envelope. RuFlo is where that envelope is **enforced** at every tool invocation:
```json
{
"objective": "review repository security",
"allow": ["repository.read", "tests.execute"],
"deny": ["git.push", "secret.export", "deployment.create"],
"budget_usd": 8,
"expires_at": "2026-07-30T22:00:00Z"
}
```
- **Meta LLM** enforces `budget_usd` and provider policy — already its documented role; the new work is wiring this envelope's budget field into the existing `metallm_delegate`/`metallm_ask` cost-governance path, not inventing new budget machinery.
- **CASA** enforces `allow`/`deny`/`expires_at` at the network/tool-authority layer — this is new: a deterministic gate in front of every MCP tool call and every `Agent`/`Task` dispatch, checked against the envelope *before* dispatch, never after.
- **RuFlo logs every decision into signed receipts** — extends this repo's own signed-manifest precedent (`ruflo-core:witness-curator`, the ADR-103-style fix-attestation model) to per-invocation authorization decisions, not just release-time fix state.
**The single non-negotiable design constraint in this entire integration**, restated plainly because it is also the brief's explicitly named biggest failure mode: the *translation* of intent into the envelope may use an LLM (that's MetaHarness's job, companion ADR-240 §4). *Enforcement* must never ask a model whether an action is permitted at invocation time — it checks a bounded schema (explicit resource strings, explicit deny list, numeric budget, expiry timestamp) with deterministic code, deny-by-default. No code path in this ADR's implementation may let an LLM's runtime judgment substitute for the compiled envelope's `allow`/`deny` lists. This must be verified with explicit bypass-attempt tests, not just translation-quality tests, before enabling CASA enforcement by default for any tenant.
Estimated effort: 15–25 days (shared with companion ADR-240's compiler half: schema + translation-quality tests there; wiring + enforcement + bypass-attempt tests + receipts here).
### 4. IOC Layer 9 cognition envelopes — optional coordination events, not a replacement
Support Cisco's semantic protocols above MCP/A2A — Semantic Information Exchange, Cognition and Interoperability, Semantic Alignment Broadcast, Team Formation via Polling — as **optional RuFlo coordination events**, layered on top of existing swarm/hive-mind coordination (`hive-mind_broadcast`, `hive-mind_consensus`, `coordination_consensus`). This never replaces RuFlo's own orchestration — it is additive, consistent with this repo's existing anti-drift preference for RuFlo-owned hierarchical coordination as the default.
The schemas are Apache-2.0 with existing Python and Go bindings. Ship a native Rust implementation as a genuine upstream contribution to `outshift-open/ioc-protocols-models`, not a private fork — the same posture this project already takes toward other upstream ecosystems it depends on.
Estimated effort: 10–15 days.
### 5. AGNTCY semantic observability — the runtime-only spans
Map RuFlo spans and Flywheel-style receipts onto AGNTCY's OTel extensions (`agent.identity`, `agent.capability`, `agent.intent`, `agent.parent`, `coordination.episode`, `authorization.decision`, `model.route`, `memory.provenance`, `evaluation.score`, `receipt.hash`). RuFlo owns `coordination.episode` and `authorization.decision` specifically — the two attributes companion ADR-240 §2.3 explicitly defers here, since both only exist once SLIM/CASA are active at runtime. Wire this through the existing `ruflo-observability` plugin (`observe-trace`/`observe-metrics` skills) rather than inventing a second tracing pipeline.
Estimated effort: 5–8 days (shared line item with ADR-240 §2.3 — RuFlo emits the two runtime-only attributes; MetaHarness emits the rest).
### 6. `@claude-flow/agntcy` package and Rust `ruflo agntcy` crate
Mirrors metaharness's own sibling-package pattern (`@metaharness/darwin`, `@metaharness/redblue`) and this repo's own plugin-package convention (`plugins/ruflo-*`): an isolated, optional package rather than code folded into `@claude-flow/cli` directly, so §1's removability constraint has a clean boundary to enforce against.
The Rust crate specifically targets SLIM (already Rust) and the native IOC Layer 9 implementation (§4) — natural Rust surfaces, not TypeScript. This is a good technical fit independent of any workstation-level preference, given SLIM's own implementation language; existing Rust CI plumbing in this repo (`.github/workflows/federation-peer-rust.yml`) is a candidate to extend rather than standing up a second Rust CI pipeline from scratch.
## Consequences
### Positive
- Two of the "clean positioning" stack's five legs (Meta LLM cost/tenancy governance, RuVector local memory) are already real and require zero new work to be true — this ADR only has to build the two genuinely new legs (AGNTCY-identified execution/coordination, CASA-enforced authority) plus the optional IOC layer.
- SLIM's opt-in design (§2) means single-host swarms — the overwhelming common case today — see zero behavior change and zero new operational cost.
- Extending the existing witness/receipt-signing precedent to per-invocation CASA decisions reuses a pattern this repo already trusts, instead of inventing a second audit-log format.
### Negative / risks
- CASA enforcement is a new mandatory gate in front of every tool dispatch once enabled — a bug here is a security regression, not a feature bug. It needs the "no LLM-in-the-enforcement-loop" test discipline from §3 verified by explicit bypass-attempt tests before shipping enabled-by-default for any tenant.
- SLIM introduces a new Rust dependency surface and a new network topology (group membership, MLS encryption) this repo doesn't operate today — a real operational learning curve, mitigated by keeping it strictly opt-in per §1/§2.
- AGNTCY/Outshift ecosystem immaturity risk, same caveat as companion ADR-240: treat every new package here as optional and versioned, never load-bearing, until the upstream specs stabilize past 1.0.
## Alternatives Considered
- **Building CASA-equivalent enforcement as a bespoke ruflo-only authorization layer instead of adopting CASA.** Rejected — this repo already has `claims_*`/AuthScope machinery (the `claims-authorizer` agent); the intent-scoped, budget-and-expiry-bearing envelope CASA describes is a genuine capability gap that machinery doesn't currently cover, and building a second bespoke scheme when an emerging standard exists trades a small integration cost now for a larger reconciliation cost later.
- **Making SLIM the default transport immediately.** Rejected per the brief's own explicit guidance — unnecessary operational cost for single-host coordination, which is most of this repo's actual usage today.
- **Treating IOC Layer 9 as a replacement for RuFlo's own hive-mind/swarm orchestration.** Rejected — explicitly scoped as optional coordination *events* layered on top, consistent with this repo's existing anti-drift preference for RuFlo-owned hierarchical coordination as the default.
## Acceptance Test
Shared with companion ADR-240: generate a MetaHarness agent, publish its signed OASF record (ADR-240), discover it from a second network (ADR-240, via Directory), verify its AGNTCY identity (ADR-240 §2.1), invoke it through SLIM (this ADR §2), reject one out-of-scope tool call through CASA (this ADR §3, using ADR-240 §4's compiled envelope), and reconstruct the complete run from OpenTelemetry spans and Flywheel receipts (this ADR §5 + ADR-240 §2.3).
## Open Questions
- Should `ruflo transport use slim` be a per-swarm setting or a global session default? (Leaning: per-swarm, consistent with per-tenant SLIM group membership.)
- Does the existing `claims_*` AuthScope machinery get subsumed by CASA envelopes over time, or do they stay parallel (claims = internal agent-to-agent authorization, CASA = user-intent-to-network authorization)? Worth its own follow-up ADR once §3 ships and the overlap (or lack of one) is concretely observable.
- Where does the Rust `ruflo agntcy` crate live — a new top-level package, or folded into the existing federation-peer-rust surface given the CI plumbing already exists there?
## References
- Cisco AGNTCY overview — https://outshift.cisco.com/the-internet-of-agents/agntcy
- AGNTCY Identity — https://github.com/agntcy/identity
- AGNTCY Directory — https://github.com/agntcy/dir
- AGNTCY Observe — https://github.com/agntcy/observe
- SLIM architecture — https://github.com/agntcy/slim
- Cisco CASA overview — https://outshift.cisco.com/blog/ai-ml/continuous-agentic-semantic-authorization-for-mas
- IOC protocol repository — https://github.com/outshift-open/ioc-protocols-models
- Companion: metaharness repo ADR-240 (`agent/adr-237-agntcy-outshift-integration` branch) — build-time half of this integration
- ADR-150 (`v3/docs/adr/ADR-150-metaharness-integration-surfaces.md`) — the optional/removable-augmentation precedent this ADR follows
@@ -0,0 +1,16 @@
---
name: agntcy-status
description: Show AGNTCY/SLIM/CASA integration status — whether upstream AGNTCY packages are installed, which transport (local vs SLIM) is active, and whether CASA enforcement is enabled. Use when the user asks "is AGNTCY configured?", "show SLIM/CASA status", or "is AGNTCY/IOC integration active?".
allowed-tools: Read
argument-hint: ""
---
Scaffolding stub — not yet implemented. This skill will report, once `ruflo-agntcy` ships real logic (see [ADR-380](../../docs/adrs/ADR-380-agntcy-outshift-runtime-integration.md)):
1. **Package availability** — whether `@claude-flow/agntcy` (TS) and/or the Rust `ruflo-agntcy` crate are installed as `optionalDependencies`, or absent (the expected default today — no such upstream package exists yet publicly).
2. **Active transport** — local in-process (default) vs SLIM (opt-in, per ADR-380 §2), and which swarms/hive-mind sessions are on which transport.
3. **CASA enforcement state** — enabled/disabled, and for enabled tenants, the compiled intent envelope's `allow`/`deny`/`budget_usd`/`expires_at` currently in force (ADR-380 §3).
4. **IOC coordination events** — whether optional Layer 9 semantic events (ADR-380 §4) are wired on top of `hive-mind_broadcast`/`hive-mind_consensus`.
5. **Identity/observability** — whether AGNTCY OTel span attributes (`agent.identity`, `coordination.episode`, `authorization.decision`, etc., ADR-380 §5) are being emitted through `ruflo-observability`.
Until upstream AGNTCY/SLIM packages exist, this status check MUST report "not configured — see ADR-380" rather than fabricate a healthy status. No network call to AGNTCY infrastructure (Directory, SLIM broker) is safe to make from this skill until a real client library is available.
@@ -0,0 +1,184 @@
/**
* Tests for the deterministic CASA intent compiler (ADR-380 §3 / ADR-240 §4).
*/
import { describe, it, expect } from 'vitest';
import {
compileIntentToEnvelope,
DANGEROUS_SCOPES,
DEFAULT_BUDGET_USD,
DEFAULT_TTL_MINUTES,
} from '../compile.js';
import { CasaEnvelopeSchema } from '../schema.js';
const FIXED_NOW = new Date('2026-07-30T21:00:00.000Z');
describe('compileIntentToEnvelope', () => {
it('compiles a plain read/audit objective to repository.read + tests.execute, no dangerous scopes', () => {
const envelope = compileIntentToEnvelope('review repository security', { now: () => FIXED_NOW });
expect(envelope.allow).toContain('repository.read');
expect(envelope.allow).toContain('tests.execute');
for (const scope of DANGEROUS_SCOPES) {
expect(envelope.allow).not.toContain(scope);
expect(envelope.deny).toContain(scope);
}
});
it('produces output that validates against CasaEnvelopeSchema', () => {
const envelope = compileIntentToEnvelope('audit the codebase for vulnerabilities', { now: () => FIXED_NOW });
expect(() => CasaEnvelopeSchema.parse(envelope)).not.toThrow();
});
it('defaults budget_usd and expires_at when opts are omitted', () => {
const envelope = compileIntentToEnvelope('inspect the auth module', { now: () => FIXED_NOW });
expect(envelope.budget_usd).toBe(DEFAULT_BUDGET_USD);
expect(envelope.expires_at).toBe(
new Date(FIXED_NOW.getTime() + DEFAULT_TTL_MINUTES * 60_000).toISOString(),
);
});
it('honors defaultBudgetUsd and defaultTtlMinutes overrides', () => {
const envelope = compileIntentToEnvelope('review the repo', {
now: () => FIXED_NOW,
defaultBudgetUsd: 8,
defaultTtlMinutes: 15,
});
expect(envelope.budget_usd).toBe(8);
expect(envelope.expires_at).toBe(new Date(FIXED_NOW.getTime() + 15 * 60_000).toISOString());
});
it('never puts a dangerous scope in allow unless the objective explicitly names it', () => {
const neutralObjectives = [
'review repository security',
'run the test suite',
'audit dependencies for CVEs',
'inspect the codebase',
'analyze recent commits',
];
for (const objective of neutralObjectives) {
const envelope = compileIntentToEnvelope(objective, { now: () => FIXED_NOW });
for (const scope of DANGEROUS_SCOPES) {
expect(envelope.allow).not.toContain(scope);
}
}
});
it('grants git.push only when the objective explicitly says push', () => {
const envelope = compileIntentToEnvelope('push the latest commits to the branch', { now: () => FIXED_NOW });
expect(envelope.allow).toContain('git.push');
expect(envelope.deny).not.toContain('git.push');
// Other dangerous scopes remain denied since they were not named.
expect(envelope.allow).not.toContain('secret.export');
expect(envelope.allow).not.toContain('deployment.create');
});
it('grants deployment.create only when the objective explicitly says deploy/publish/release', () => {
for (const objective of ['deploy the service to production', 'publish the new version', 'release v2.0']) {
const envelope = compileIntentToEnvelope(objective, { now: () => FIXED_NOW });
expect(envelope.allow).toContain('deployment.create');
expect(envelope.deny).not.toContain('deployment.create');
}
});
it('does not grant git.push for objectives that merely contain the bare word "push" in an unrelated sense', () => {
const falsePositives = [
'push notification integration for mobile app',
'push back on the proposed schema change',
'please push through this urgent bug fix',
];
for (const objective of falsePositives) {
const envelope = compileIntentToEnvelope(objective, { now: () => FIXED_NOW });
expect(envelope.allow).not.toContain('git.push');
expect(envelope.deny).toContain('git.push');
}
});
it('does not grant deployment.create for objectives that merely contain "publish"/"release"/"deploy" in an unrelated sense', () => {
const falsePositives = [
'review the release notes for security issues',
'check for a new release of the dependency',
'publish a blog post about our roadmap',
'audit the changelog before we publish it',
];
for (const objective of falsePositives) {
const envelope = compileIntentToEnvelope(objective, { now: () => FIXED_NOW });
expect(envelope.allow).not.toContain('deployment.create');
expect(envelope.deny).toContain('deployment.create');
}
});
it('grants secret.export only when the objective explicitly says export secrets', () => {
const envelope = compileIntentToEnvelope('export secrets for the migration', { now: () => FIXED_NOW });
expect(envelope.allow).toContain('secret.export');
expect(envelope.deny).not.toContain('secret.export');
});
it('never allows a scope to appear in both allow and deny simultaneously', () => {
const objectives = [
'review repository security',
'push the release branch',
'deploy the service to production',
'export secrets for the migration',
'do absolutely nothing recognizable',
];
for (const objective of objectives) {
const envelope = compileIntentToEnvelope(objective, { now: () => FIXED_NOW });
const overlap = envelope.allow.filter((scope) => envelope.deny.includes(scope));
expect(overlap).toEqual([]);
}
});
it('an objective matching no keywords yields an empty allow list and full dangerous deny list', () => {
const envelope = compileIntentToEnvelope('do absolutely nothing recognizable', { now: () => FIXED_NOW });
expect(envelope.allow).toEqual([]);
expect([...envelope.deny].sort()).toEqual([...DANGEROUS_SCOPES].sort());
});
it('never calls an LLM/network: is synchronous and returns a plain object, not a Promise', () => {
const result = compileIntentToEnvelope('review repository security', { now: () => FIXED_NOW });
expect(result).not.toBeInstanceOf(Promise);
expect(typeof result).toBe('object');
});
it('is deterministic — same objective + same clock yields byte-identical output', () => {
const a = compileIntentToEnvelope('review repository security', { now: () => FIXED_NOW });
const b = compileIntentToEnvelope('review repository security', { now: () => FIXED_NOW });
expect(a).toEqual(b);
});
it('supports the documented translator extension point without calling it internally by default', () => {
let called = false;
const envelope = compileIntentToEnvelope('review repository security', {
now: () => FIXED_NOW,
translator: (objective) => {
called = true;
return { allow: ['custom.scope'] };
},
});
expect(called).toBe(true);
expect(envelope.allow).toContain('custom.scope');
expect(envelope.allow).toContain('repository.read');
});
it('translator-added scope is removed from deny if it was a dangerous default', () => {
const envelope = compileIntentToEnvelope('review repository security', {
now: () => FIXED_NOW,
translator: () => ({ allow: ['git.push'] }),
});
expect(envelope.allow).toContain('git.push');
expect(envelope.deny).not.toContain('git.push');
});
it('translator output is re-validated and cannot produce a structurally invalid envelope', () => {
// Type-valid (budget_usd is a number) but runtime-invalid (not positive) —
// proves the translator patch is re-validated by CasaEnvelopeSchema, not
// trusted blindly.
expect(() =>
compileIntentToEnvelope('review repository security', {
now: () => FIXED_NOW,
translator: () => ({ budget_usd: -5 }),
}),
).toThrow();
});
});
@@ -0,0 +1,153 @@
/**
* Bypass-attempt tests for CASA enforcement (ADR-380 §3 / ADR-240 §4).
*
* These specifically probe the load-bearing invariant: enforcement is a
* pure, deterministic function over the bounded envelope schema, and
* deny always wins, and the default posture is deny.
*/
import { describe, it, expect } from 'vitest';
import { checkAuthorization } from '../enforce.js';
import type { CasaEnvelope } from '../schema.js';
const baseEnvelope: CasaEnvelope = {
objective: 'review repository security',
allow: ['repository.read', 'tests.execute'],
deny: ['git.push', 'secret.export', 'deployment.create'],
budget_usd: 8,
expires_at: '2026-07-30T22:00:00Z',
};
describe('checkAuthorization', () => {
it('allows an action that is in allow and not in deny', () => {
const result = checkAuthorization(baseEnvelope, 'repository.read', '2026-07-30T10:00:00Z');
expect(result).toEqual({ allowed: true, reason: 'allowed' });
});
it('denies an action that is in neither allow nor deny (deny-by-default)', () => {
const result = checkAuthorization(baseEnvelope, 'network.exfiltrate', '2026-07-30T10:00:00Z');
expect(result.allowed).toBe(false);
expect(result.reason).toBe('not in allow list (deny-by-default)');
});
it('denies an action present in both allow AND deny — deny wins', () => {
const conflicted: CasaEnvelope = {
...baseEnvelope,
allow: ['repository.read', 'git.push'],
deny: ['git.push'],
};
const result = checkAuthorization(conflicted, 'git.push', '2026-07-30T10:00:00Z');
expect(result.allowed).toBe(false);
expect(result.reason).toBe('explicit deny');
});
it('denies any request after expiry, even one that is in allow', () => {
const result = checkAuthorization(baseEnvelope, 'repository.read', '2026-07-30T23:00:00Z');
expect(result.allowed).toBe(false);
expect(result.reason).toBe('expired');
});
it('denies at and after the boundary; allows strictly before it (spec: now >= expires_at is expired)', () => {
// now === expires_at IS treated as expired — matches the Rust reference
// implementation (`envelope.rs::check_authorization`), which denies at
// `now_secs >= expires_at_secs`. The two enforcement points must agree
// bit-for-bit on this boundary.
const exactlyOnBoundary = checkAuthorization(baseEnvelope, 'repository.read', '2026-07-30T22:00:00Z');
expect(exactlyOnBoundary.allowed).toBe(false);
expect(exactlyOnBoundary.reason).toBe('expired');
const oneMsLate = checkAuthorization(baseEnvelope, 'repository.read', '2026-07-30T22:00:00.001Z');
expect(oneMsLate.allowed).toBe(false);
expect(oneMsLate.reason).toBe('expired');
const oneMsEarly = checkAuthorization(baseEnvelope, 'repository.read', '2026-07-30T21:59:59.999Z');
expect(oneMsEarly.allowed).toBe(true);
});
it('bypass attempt: non-array allow/deny on a malformed envelope is denied, not substring-matched', () => {
// Prior to the schema-revalidation fix, `Array.prototype.includes` /
// `String.prototype.includes` overload confusion meant a caller
// handing in `allow: "repository.read"` (a string, not string[]) would
// authorize the substring-matching scope "repository" via
// `"repository.read".includes("repository")` — a real deny-by-default
// bypass. checkAuthorization must defend its own precondition instead
// of trusting the (compile-time-only) TypeScript type.
const malformed = {
...baseEnvelope,
allow: 'repository.read',
} as unknown as CasaEnvelope;
const result = checkAuthorization(malformed, 'repository', '2026-07-30T10:00:00Z');
expect(result.allowed).toBe(false);
expect(result.reason).toBe('invalid envelope (failed schema validation)');
const malformedDeny = {
...baseEnvelope,
deny: 'git.push,secret.export,deployment.create',
} as unknown as CasaEnvelope;
const denyResult = checkAuthorization(malformedDeny, 'repository.read', '2026-07-30T10:00:00Z');
expect(denyResult.allowed).toBe(false);
expect(denyResult.reason).toBe('invalid envelope (failed schema validation)');
});
it('bypass attempt: an unparseable `now` fails closed (expired), not silently skips the expiry gate', () => {
// Previously, `Date.parse('not-a-real-timestamp')` is NaN, which
// short-circuited the old `!Number.isNaN(now) && ...` guard to false —
// silently disabling the expiry check entirely and falling through to
// the deny/allow checks as if the envelope had no expiry at all.
const result = checkAuthorization(baseEnvelope, 'repository.read', 'not-a-real-timestamp');
expect(result.allowed).toBe(false);
expect(result.reason).toBe('expired');
});
it('bypass attempt: an offset-less `now` is rejected rather than interpreted in the host timezone', () => {
// `Date.parse('2026-07-30T22:00:00')` (no trailing Z/offset) is parsed
// in the *local system timezone*, not UTC — two machines with
// different TZ settings would silently disagree on whether this is
// before or after expires_at. Reject it outright, matching the Rust
// reference parser's explicit "no explicit timezone — reject rather
// than guess" behavior.
const result = checkAuthorization(baseEnvelope, 'repository.read', '2026-07-30T10:00:00');
expect(result.allowed).toBe(false);
expect(result.reason).toBe('expired');
});
it('denies everything when allow is an empty list', () => {
const lockedDown: CasaEnvelope = {
...baseEnvelope,
allow: [],
};
for (const action of ['repository.read', 'tests.execute', 'anything.at.all']) {
const result = checkAuthorization(lockedDown, action, '2026-07-30T10:00:00Z');
expect(result.allowed).toBe(false);
expect(result.reason).toBe('not in allow list (deny-by-default)');
}
});
it('denies an action in deny even when allow is empty (deny checked before allow)', () => {
const lockedDownWithDeny: CasaEnvelope = {
...baseEnvelope,
allow: [],
deny: ['git.push'],
};
const result = checkAuthorization(lockedDownWithDeny, 'git.push', '2026-07-30T10:00:00Z');
expect(result.allowed).toBe(false);
expect(result.reason).toBe('explicit deny');
});
it('the one genuinely-allowed case: tests.execute under the ADR-380 example envelope', () => {
const result = checkAuthorization(baseEnvelope, 'tests.execute', '2026-07-30T21:59:59Z');
expect(result).toEqual({ allowed: true, reason: 'allowed' });
});
it('is a pure function: identical inputs always produce identical output', () => {
const a = checkAuthorization(baseEnvelope, 'repository.read', '2026-07-30T10:00:00Z');
const b = checkAuthorization(baseEnvelope, 'repository.read', '2026-07-30T10:00:00Z');
expect(a).toEqual(b);
});
it('does not mutate the envelope it is given', () => {
const snapshot = JSON.parse(JSON.stringify(baseEnvelope));
checkAuthorization(baseEnvelope, 'git.push', '2026-07-30T10:00:00Z');
expect(baseEnvelope).toEqual(snapshot);
});
});
+185
View File
@@ -0,0 +1,185 @@
/**
* Deterministic, rule-based CASA intent compiler (ADR-380 §3 / ADR-240 §4).
*
* Translates a free-text `objective` into a bounded {@link CasaEnvelope}
* using a small, static keyword/pattern table — no network calls, no LLM,
* no nondeterministic judgment of any kind. This is intentionally a first,
* honest pass: it is conservative (deny-by-default for the three dangerous
* scopes) and only grants a scope when the objective contains language
* that plausibly names it.
*
* ADR-380 §3 explicitly allows a future *translation* step to use an LLM
* ("that's MetaHarness's job") — this module exposes that as the optional
* `translator` parameter below, but never invokes one itself. Wiring an
* LLM-based translator in here would violate the ADR's load-bearing
* invariant just as much as putting one in `enforce.ts` would, since a
* translator that silently widens `allow`/narrows `deny` at compile time
* has the same practical effect as bypassing enforcement. Callers who add
* an LLM-based translator remain responsible for treating its output as
* untrusted and re-validating it against {@link CasaEnvelopeSchema}, which
* this function already does for its own deterministic output.
*/
import { CasaEnvelopeSchema, type CasaEnvelope } from './schema.js';
/** Default envelope lifetime, in minutes, when `opts.defaultTtlMinutes` is omitted. */
export const DEFAULT_TTL_MINUTES = 60;
/** Default budget, in USD, when `opts.defaultBudgetUsd` is omitted. */
export const DEFAULT_BUDGET_USD = 5;
/**
* Scopes that are ALWAYS denied unless the objective explicitly names the
* activity that scope corresponds to. These mirror the ADR-380 §3 example
* envelope's deny list verbatim.
*/
export const DANGEROUS_SCOPES = ['git.push', 'secret.export', 'deployment.create'] as const;
/**
* One entry in the keyword/pattern table: if `pattern` matches the
* objective, every scope in `allow` is granted. Patterns are matched
* case-insensitively against the raw objective string; order does not
* affect the result (all matching rules contribute, deduplicated).
*/
interface CompileRule {
readonly pattern: RegExp;
readonly allow: readonly string[];
}
/**
* The keyword table. Kept small and explicit on purpose — this is a
* first, honest pass, not an attempt to cover every possible phrasing.
* Extend by adding rows, not by adding branching logic.
*/
const RULE_TABLE: readonly CompileRule[] = [
// Safe, read-only activity.
{ pattern: /\b(review|reading|reads?|audit(?:ing)?|inspect(?:ing)?|analy[sz](?:e|ing|is)|scan(?:ning)?|check(?:ing)?)\b/i, allow: ['repository.read'] },
// Test execution.
{ pattern: /\btest(?:s|ing)?\b/i, allow: ['tests.execute'] },
// "Security" review language implies both reading the repo and running
// its test/verification suite (mirrors the ADR-380 §3 worked example:
// "review repository security" -> ["repository.read", "tests.execute"]).
{ pattern: /\bsecurity\b/i, allow: ['repository.read', 'tests.execute'] },
// Explicit git push language — otherwise git.push stays denied by default.
// Bare "push"/"pushing" only counts when it co-occurs with a git-object
// noun (commit/branch/repo/code) or the literal "git push"; this keeps
// "push notification", "push back on ...", "push through this fix" from
// false-permissively granting git.push (found in adversarial review).
{
pattern:
/\bgit\s+push\b|\bpush(?:ing)?\b(?=[\s\S]*\b(?:commits?|branch(?:es)?|repo(?:sitory)?|code)\b)/i,
allow: ['git.push'],
},
// Explicit deploy/publish/release language — otherwise deployment.create
// stays denied. Bare "deploy"/"publish"/"release" only count when they
// co-occur with a deployment-target noun (service/app/version/
// production/prod/staging/package/build/artifact/image) or a version
// number (e.g. "v2.0"); this keeps "release notes", "publish a blog
// post", "new release of the dependency" from false-permissively
// granting deployment.create (found in adversarial review).
{
pattern:
/\b(?:deploy(?:ment|ing|s)?|publish(?:ing)?|release(?:s|d|ing)?)\b(?=[\s\S]*\b(?:service|app(?:lication)?|version|production|prod|staging|package|build|artifact|image)\b|[\s\S]*\bv?\d+(?:\.\d+)+\b)/i,
allow: ['deployment.create'],
},
// Explicit secret-export language — otherwise secret.export stays denied.
{ pattern: /\b(export(?:ing)?\s+secrets?|secrets?\s+export|export\s+credentials?)\b/i, allow: ['secret.export'] },
];
/** Extension point for a future translator (see module doc). Never called internally. */
export type CasaTranslator = (objective: string) => Partial<CasaEnvelope>;
export interface CompileIntentOptions {
/** Overrides {@link DEFAULT_BUDGET_USD}. Must be positive if provided. */
defaultBudgetUsd?: number;
/** Overrides {@link DEFAULT_TTL_MINUTES}. Must be positive if provided. */
defaultTtlMinutes?: number;
/**
* Optional, caller-supplied translator invoked AFTER the deterministic
* rule table runs. Its `allow`/`deny` are unioned into the deterministic
* result (never used to silently narrow `deny` for a dangerous scope
* out from under the rule table); `budget_usd`/`expires_at`/`objective`,
* if returned, override the defaults. This is the designated seam for a
* future LLM-based compiler — `compileIntentToEnvelope` itself never
* calls an LLM or makes a network request.
*/
translator?: CasaTranslator;
/** Clock override for tests. Defaults to `Date.now()`. */
now?: () => Date;
}
function dedupeSorted(scopes: Iterable<string>): string[] {
return Array.from(new Set(scopes)).sort();
}
/**
* Compile a free-text objective into a bounded CASA authority envelope.
*
* Pure, deterministic rule application over {@link RULE_TABLE}:
* 1. Start with `deny = [...DANGEROUS_SCOPES]`, `allow = []`.
* 2. For every rule whose pattern matches `objective`, union its `allow`
* scopes into the result.
* 3. Any dangerous scope that ended up in `allow` (because the objective
* explicitly named it) is removed from `deny` — a scope can never be
* load-bearingly present in both lists in this compiler's output,
* since `enforce.ts` treats `deny` as authoritative over `allow`.
* 4. If a `translator` was supplied, its output is merged in (union for
* `allow`/`deny`, override for scalars) and the merged result is
* re-validated against {@link CasaEnvelopeSchema} — a misbehaving
* translator cannot produce a structurally invalid envelope.
*/
export function compileIntentToEnvelope(
objective: string,
opts: CompileIntentOptions = {},
): CasaEnvelope {
const now = (opts.now ?? (() => new Date()))();
const ttlMinutes = opts.defaultTtlMinutes ?? DEFAULT_TTL_MINUTES;
const budgetUsd = opts.defaultBudgetUsd ?? DEFAULT_BUDGET_USD;
const allowSet = new Set<string>();
for (const rule of RULE_TABLE) {
if (rule.pattern.test(objective)) {
for (const scope of rule.allow) {
allowSet.add(scope);
}
}
}
const denySet = new Set<string>(DANGEROUS_SCOPES);
for (const scope of allowSet) {
denySet.delete(scope);
}
let allow = dedupeSorted(allowSet);
let deny = dedupeSorted(denySet);
let objectiveOut = objective;
let budgetOut = budgetUsd;
let expiresAtOut = new Date(now.getTime() + ttlMinutes * 60_000).toISOString();
if (opts.translator) {
const patch = opts.translator(objective);
if (patch.allow) {
allow = dedupeSorted([...allow, ...patch.allow]);
// Anything the translator explicitly allows must not remain denied.
deny = deny.filter((scope) => !allow.includes(scope));
}
if (patch.deny) {
deny = dedupeSorted([...deny, ...patch.deny]);
}
if (patch.objective !== undefined) objectiveOut = patch.objective;
if (patch.budget_usd !== undefined) budgetOut = patch.budget_usd;
if (patch.expires_at !== undefined) expiresAtOut = patch.expires_at;
}
const candidate: CasaEnvelope = {
objective: objectiveOut,
allow,
deny,
budget_usd: budgetOut,
expires_at: expiresAtOut,
};
// Re-validate: guarantees the compiler (and any translator patch) can
// never hand back a structurally invalid envelope to a caller.
return CasaEnvelopeSchema.parse(candidate);
}
+100
View File
@@ -0,0 +1,100 @@
/**
* CASA envelope enforcement (ADR-380 §3 / ADR-240 §4).
*
* LOAD-BEARING INVARIANT (stated in both ADRs, restated here verbatim in
* spirit): translation of free-text intent into a {@link CasaEnvelope}
* MAY use an LLM (see `compile.ts`'s `translator` extension point).
* ENFORCEMENT of the envelope — checking a requested action against
* `allow`/`deny`/`expires_at` — must NEVER call an LLM or any
* nondeterministic judgment. `checkAuthorization` below is a pure,
* synchronous function: no I/O, no network calls, no randomness, no
* calls to any model or external service. It is deny-by-default:
* anything not explicitly present in `envelope.allow` is denied, even if
* it is also absent from `envelope.deny`. Any change to this file that
* introduces async behavior, an API call, or lets a model's runtime
* judgment influence the `allowed` result is a security regression, not
* a feature — see ADR-380 §3's explicit call for "bypass-attempt tests,
* not just translation-quality tests" before this gate is trusted.
*/
import { CasaEnvelopeSchema, hasExplicitTimezone, type CasaEnvelope } from './schema.js';
export interface AuthorizationResult {
readonly allowed: boolean;
readonly reason: string;
}
/**
* Parses an ISO 8601 timestamp to epoch milliseconds, requiring an
* explicit UTC/offset marker (see {@link hasExplicitTimezone} in
* `schema.ts`). Returns `NaN` — matching `Date.parse`'s own
* not-a-timestamp sentinel — for anything else, including a string
* `Date.parse` would otherwise happily (and ambiguously, under the host's
* local timezone) accept.
*/
function parseTimestampStrict(value: string): number {
if (typeof value !== 'string' || !hasExplicitTimezone(value)) {
return NaN;
}
return Date.parse(value);
}
/**
* Check whether `requestedAction` is authorized under `envelope`.
*
* Evaluation order (first match wins):
* 0. `envelope` fails schema validation -> denied, "invalid envelope..."
* 1. Expiry unparseable, OR `now >= expires_at` -> denied, "expired"
* 2. In `envelope.deny` -> denied, "explicit deny"
* 3. NOT in `envelope.allow` -> denied, "not in allow list (deny-by-default)"
* 4. Otherwise -> allowed
*
* This function defends its own precondition rather than trusting the
* caller's (erased-at-runtime) TypeScript type: `envelope` is re-validated
* against {@link CasaEnvelopeSchema} before anything else runs, so a
* caller that hands in a structurally malformed envelope (e.g. `allow`
* as a bare string instead of `string[]`, which would otherwise silently
* fall through to `String.prototype.includes`'s substring-match overload
* instead of `Array.prototype.includes`'s exact-value match) gets denied
* outright instead of authorized. A malformed/unparseable `now` is
* likewise treated as expired (fail closed) rather than silently
* skipping the expiry gate — matching the Rust reference implementation
* in `v3/crates/ruflo-agntcy/src/envelope.rs::check_authorization`, which
* also denies at `now >= expires_at` (strict "before expiry", not
* "on-or-before") for the same reason: the two enforcement points must
* agree bit-for-bit on this load-bearing boundary.
*
* Deny always wins over allow: an action present in both lists is
* denied at step 2, before `allow` is ever consulted. This function
* takes no dependency on wall-clock time other than through the
* `nowIso` parameter (defaulting to `new Date().toISOString()` at call
* time), so it remains a pure function of its inputs for testing.
*/
export function checkAuthorization(
envelope: CasaEnvelope,
requestedAction: string,
nowIso: string = new Date().toISOString(),
): AuthorizationResult {
const parsed = CasaEnvelopeSchema.safeParse(envelope);
if (!parsed.success) {
return { allowed: false, reason: 'invalid envelope (failed schema validation)' };
}
const safeEnvelope = parsed.data;
const now = parseTimestampStrict(nowIso);
const expiresAt = parseTimestampStrict(safeEnvelope.expires_at);
if (Number.isNaN(now) || Number.isNaN(expiresAt) || now >= expiresAt) {
return { allowed: false, reason: 'expired' };
}
if (safeEnvelope.deny.includes(requestedAction)) {
return { allowed: false, reason: 'explicit deny' };
}
if (!safeEnvelope.allow.includes(requestedAction)) {
return { allowed: false, reason: 'not in allow list (deny-by-default)' };
}
return { allowed: true, reason: 'allowed' };
}
+19
View File
@@ -0,0 +1,19 @@
/**
* CASA authority envelope module (ADR-380 §3 / ADR-240 §4).
*
* Re-exports:
* - `schema.ts` — the envelope's Zod schema + inferred type.
* - `compile.ts` — deterministic, rule-based intent -> envelope compiler.
* - `enforce.ts` — deterministic, LLM-free envelope enforcement.
*/
export { CasaEnvelopeSchema, type CasaEnvelope } from './schema.js';
export {
compileIntentToEnvelope,
type CasaTranslator,
type CompileIntentOptions,
DANGEROUS_SCOPES,
DEFAULT_BUDGET_USD,
DEFAULT_TTL_MINUTES,
} from './compile.js';
export { checkAuthorization, type AuthorizationResult } from './enforce.js';
+82
View File
@@ -0,0 +1,82 @@
/**
* CASA authority envelope schema (ADR-380 §3 / ADR-240 §4).
*
* The envelope is the bounded, serializable authorization contract that
* gates every tool/agent dispatch under CASA (Continuous Agentic Semantic
* Authorization). Its shape is fixed by the two ADRs and MUST NOT be
* extended without updating both documents:
*
* {
* "objective": "review repository security",
* "allow": ["repository.read", "tests.execute"],
* "deny": ["git.push", "secret.export", "deployment.create"],
* "budget_usd": 8,
* "expires_at": "2026-07-30T22:00:00Z"
* }
*
* This module only validates the envelope's shape. It performs no
* authorization logic — see `enforce.ts` for the deterministic gate that
* actually checks a requested action against `allow`/`deny`/`expires_at`.
*
* Zod is used for validation-at-boundaries per this repo's security
* convention (root CLAUDE.md: "Always validate user input at system
* boundaries").
*/
import { z } from 'zod';
/**
* A resource-scope string, e.g. `repository.read`, `git.push`,
* `secret.export`. Deliberately just `string` (not an enum) — the set of
* scopes is open-ended and governed by whatever CASA-compatible network
* the envelope targets, not by this repo. Must be non-empty.
*/
const CasaScopeSchema = z.string().min(1, 'scope must be a non-empty string');
/**
* True if `value` carries an explicit UTC/offset marker (`Z`/`z` or a
* trailing `±HH:MM`). Mirrors the Rust reference parser
* (`v3/crates/ruflo-agntcy/src/envelope.rs::parse_rfc3339_to_epoch_seconds`),
* which rejects a timezone-less timestamp outright rather than guessing.
* Required because `Date.parse()` on an offset-less ISO string is parsed
* in the *host's local timezone*, not UTC — two machines with different
* `TZ` settings would silently disagree on the same nominal timestamp.
*/
export function hasExplicitTimezone(value: string): boolean {
return /(?:Z|z|[+-]\d{2}:\d{2})$/.test(value.trim());
}
/**
* ISO 8601 timestamp string, with an explicit UTC/offset marker required
* (see {@link hasExplicitTimezone}). Validated for parseability (via
* `Date` rejecting malformed strings) on top of that requirement, since
* ISO 8601 has multiple valid representations (with/without milliseconds,
* etc). Enforcement (`enforce.ts`) re-parses this value itself and is the
* authority on expiry semantics — this schema rejects strings that cannot
* represent an unambiguous instant at all.
*/
const Iso8601Schema = z.string().refine(
(value) => hasExplicitTimezone(value) && !Number.isNaN(Date.parse(value)),
{
message:
'expires_at must be a valid ISO 8601 timestamp string with an explicit UTC/offset marker (Z or ±HH:MM)',
},
);
export const CasaEnvelopeSchema = z
.object({
/** Free-text objective the envelope was compiled from. */
objective: z.string().min(1, 'objective must be a non-empty string'),
/** Explicitly permitted action scopes. Deny-by-default: anything not
* listed here is denied, even if it is also absent from `deny`. */
allow: z.array(CasaScopeSchema),
/** Explicitly forbidden action scopes. Deny always wins over allow. */
deny: z.array(CasaScopeSchema),
/** Maximum USD spend authorized under this envelope. */
budget_usd: z.number().positive('budget_usd must be a positive number'),
/** Envelope expiry, ISO 8601. */
expires_at: Iso8601Schema,
})
.strict();
export type CasaEnvelope = z.infer<typeof CasaEnvelopeSchema>;
@@ -0,0 +1,72 @@
/**
* AGNTCY OTel semantic-convention span attributes — RuFlo's runtime-owned
* half (ADR-380 §5).
*
* AGNTCY (Cisco Outshift) defines ten OTel span-attribute extensions for
* agent observability across the identity/execution/coordination lifecycle.
* Per ADR-380 §5, RuFlo and the companion metaharness ADR-240 split
* ownership of those ten attributes by *when* the value is knowable:
*
* - MetaHarness (build/manifest time, companion ADR-240 §2.3) owns the
* eight attributes that exist once an agent is compiled/described:
* `agent.identity` — AGNTCY-issued agent identity
* `agent.capability` — declared tool/capability surface
* `agent.intent` — compiled objective (OASF manifest field)
* `agent.parent` — lineage / parent-agent reference
* `model.route` — which model tier served the invocation
* `memory.provenance` — RuVector/AgentDB provenance pointer
* `evaluation.score` — harness scorecard / GEPA evaluation result
* `receipt.hash` — content hash of the signed run receipt
*
* - RuFlo (runtime, this file) owns the two attributes that only exist
* once SLIM transport and/or CASA enforcement are active at runtime —
* they describe *this* coordination episode and *this* authorization
* decision, neither of which can be known at build time:
* `coordination.episode` — see AGNTCY_SPAN_ATTR_COORDINATION_EPISODE
* `authorization.decision` — see AGNTCY_SPAN_ATTR_AUTHORIZATION_DECISION
*
* Deliberately NOT re-exported here: the eight metaharness-owned names
* above. Defining them in both places would let the two ADRs drift out of
* sync; the companion ADR-240 package is the single source of truth for
* those constants. This module only defines the two RuFlo owns.
*
* Wire these attribute names through the existing `ruflo-observability`
* plugin (`observe-trace`/`observe-metrics` skills) — ADR-380 §5 is
* explicit that this must not become a second tracing pipeline.
*
* @see v3/docs/adr/ADR-380-agntcy-outshift-runtime-integration.md §5
* @see AGNTCY Observe — https://github.com/agntcy/observe
*/
/**
* OTel span attribute name for the SLIM/hive-mind coordination episode a
* span belongs to (e.g. a swarm run, a hive-mind consensus round, a
* `ruflo swarm join <namespace>` session). Only meaningful once SLIM
* transport (ADR-380 §2) is active — a span emitted under the default
* local/in-process transport has no coordination episode to report.
*/
export const AGNTCY_SPAN_ATTR_COORDINATION_EPISODE = 'coordination.episode';
/**
* OTel span attribute name for the outcome of a CASA intent-scoped
* authorization check (ADR-380 §3) gating the tool invocation the span
* covers. Set for every span produced by a CASA-enforced dispatch,
* whether the decision was `allow` or `deny` — a denied span still needs
* the attribute so the trace shows *why* the invocation didn't proceed.
*/
export const AGNTCY_SPAN_ATTR_AUTHORIZATION_DECISION = 'authorization.decision';
/**
* All AGNTCY OTel span attributes RuFlo defines and is responsible for
* emitting, per ADR-380 §5. Convenience array for callers that want to
* enumerate rather than reference the individual constants (e.g. an
* `observe-trace` schema validator or an attribute allow-list).
*/
export const AGNTCY_RUFLO_OWNED_SPAN_ATTRS = [
AGNTCY_SPAN_ATTR_COORDINATION_EPISODE,
AGNTCY_SPAN_ATTR_AUTHORIZATION_DECISION,
] as const;
/** Union type of the span attribute names RuFlo owns. */
export type AgntcyRufloOwnedSpanAttr =
(typeof AGNTCY_RUFLO_OWNED_SPAN_ATTRS)[number];
@@ -0,0 +1,142 @@
import { describe, it, expect, afterEach } from 'vitest';
import { readFileSync, rmSync, existsSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
appendCasaReceipt,
loadOrCreateCasaSigningKeypair,
signCasaReceipt,
verifyCasaReceipt,
DEFAULT_CASA_RECEIPT_PATH,
type CasaReceiptInput,
type SignedCasaReceipt,
} from '../casa-receipt.js';
describe('casa-receipt', () => {
const tmpDirs: string[] = [];
function makeTmpDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'casa-receipt-test-'));
tmpDirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of tmpDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
const sampleInput = (overrides: Partial<CasaReceiptInput> = {}): CasaReceiptInput => ({
envelope: {
objective: 'review repository security',
allow: ['repository.read', 'tests.execute'],
deny: ['git.push', 'secret.export', 'deployment.create'],
budget_usd: 8,
expires_at: '2026-07-30T22:00:00Z',
},
requestedAction: 'repository.read',
decision: { allowed: true, reason: 'action is in allow list' },
timestampIso: '2026-07-30T21:05:00.000Z',
...overrides,
});
it('exports the documented default receipt path', () => {
expect(DEFAULT_CASA_RECEIPT_PATH).toBe(join('.swarm', 'casa-receipts.jsonl'));
});
it('appends a signed JSONL receipt to the given file path, and the signature verifies', async () => {
const dir = makeTmpDir();
const filePath = join(dir, 'nested', 'casa-receipts.jsonl');
const input = sampleInput();
await appendCasaReceipt(input, filePath);
expect(existsSync(filePath)).toBe(true);
const raw = readFileSync(filePath, 'utf-8');
const lines = raw.split('\n').filter((l) => l.length > 0);
expect(lines).toHaveLength(1);
const record = JSON.parse(lines[0]) as SignedCasaReceipt;
// JSON round-trips: every input field survives untouched.
expect(record.envelope).toEqual(input.envelope);
expect(record.requestedAction).toBe(input.requestedAction);
expect(record.decision).toEqual(input.decision);
expect(record.timestampIso).toBe(input.timestampIso);
expect(record.schema).toBe('ruflo-agntcy-casa-receipt/v1');
expect(record.publicKey).toMatch(/^ed25519:[0-9a-f]{64}$/);
expect(record.signature).toMatch(/^[0-9a-f]{128}$/);
// Signature must validate — self-embedded key (local inspection mode).
await expect(verifyCasaReceipt(record)).resolves.toBe(true);
// ...and pinning explicitly to the embedded public key must also pass.
await expect(verifyCasaReceipt(record, record.publicKey)).resolves.toBe(true);
});
it('appends multiple receipts as separate JSON lines, each independently verifiable', async () => {
const dir = makeTmpDir();
const filePath = join(dir, 'casa-receipts.jsonl');
await appendCasaReceipt(sampleInput({ requestedAction: 'repository.read' }), filePath);
await appendCasaReceipt(
sampleInput({
requestedAction: 'git.push',
decision: { allowed: false, reason: 'git.push is in the deny list' },
}),
filePath,
);
const lines = readFileSync(filePath, 'utf-8').split('\n').filter((l) => l.length > 0);
expect(lines).toHaveLength(2);
const records = lines.map((l) => JSON.parse(l) as SignedCasaReceipt);
expect(records[0].decision.allowed).toBe(true);
expect(records[1].decision.allowed).toBe(false);
expect(records[1].decision.reason).toBe('git.push is in the deny list');
for (const record of records) {
await expect(verifyCasaReceipt(record)).resolves.toBe(true);
}
});
it('rejects a tampered receipt body (decision flipped after signing)', async () => {
const keypair = await loadOrCreateCasaSigningKeypair(join(makeTmpDir(), 'key.json'));
const signed = await signCasaReceipt(sampleInput(), keypair);
const tampered: SignedCasaReceipt = {
...signed,
decision: { allowed: !signed.decision.allowed, reason: 'tampered' },
};
await expect(verifyCasaReceipt(tampered)).resolves.toBe(false);
});
it('rejects verification against an untrusted public key', async () => {
const keypairA = await loadOrCreateCasaSigningKeypair(join(makeTmpDir(), 'key-a.json'));
const keypairB = await loadOrCreateCasaSigningKeypair(join(makeTmpDir(), 'key-b.json'));
const signed = await signCasaReceipt(sampleInput(), keypairA);
await expect(verifyCasaReceipt(signed, `ed25519:${keypairB.publicKeyHex}`)).resolves.toBe(
false,
);
// ...but pinning to the correct signer's key still passes.
await expect(verifyCasaReceipt(signed, `ed25519:${keypairA.publicKeyHex}`)).resolves.toBe(
true,
);
});
it('loadOrCreateCasaSigningKeypair persists and reloads the same key material', async () => {
const dir = makeTmpDir();
const keyPath = join(dir, 'nested', 'key.json');
const first = await loadOrCreateCasaSigningKeypair(keyPath);
expect(existsSync(keyPath)).toBe(true);
const second = await loadOrCreateCasaSigningKeypair(keyPath);
expect(second.publicKeyHex).toBe(first.publicKeyHex);
expect(Buffer.from(second.privateKey)).toEqual(Buffer.from(first.privateKey));
});
});
@@ -0,0 +1,297 @@
/**
* CASA decision receipt logger (ADR-380 §3).
*
* MetaHarness (companion ADR-240 §4) compiles a user's objective into a
* bounded authority envelope (`allow`/`deny` resource lists, a budget, an
* expiry timestamp). RuFlo is where that envelope is *enforced* — a
* deterministic, non-LLM gate in front of every MCP tool call and every
* `Agent`/`Task` dispatch. ADR-380 §3 requires "RuFlo logs every decision
* into signed receipts", extending this repo's existing witness/receipt
* precedent (ADR-103's fix-attestation model, the ADR-126 Phase 4/6
* signed-artifact family in `plugins/ruflo-neural-trader/src/signed-*.ts`)
* to per-invocation CASA authorization decisions.
*
* This module intentionally does NOT implement CASA enforcement itself
* (the `allow`/`deny`/`expires_at` check against the compiled envelope) —
* only the append-only signed audit trail of decisions already made
* elsewhere. It mirrors the ADR-150 Phase 2 `router-parallel-recorder.ts`
* pattern of one JSON-line per decision appended to a `.swarm/*.jsonl`
* file, but for CASA decisions specifically and with the receipt line
* Ed25519-signed (the router-parallel recorder is unsigned telemetry;
* a CASA authorization decision is a security-relevant record and needs
* tamper evidence).
*
* Signing scheme:
* - Uses `@noble/ed25519` (already a hard dependency — see root
* `package.json`), not `node:crypto`, matching the signing library
* this repo's ADR-126 signed-artifact family
* (`plugins/ruflo-neural-trader/src/signed-artifact.ts`,
* `signed-attribution.ts`) already uses.
* - Canonical bytes = `JSON.stringify(body)` over the receipt body
* WITHOUT the `schema`/`publicKey`/`signature` fields — plain, no
* whitespace, no key sorting — identical convention to the
* ADR-126 signed-artifact family (CWE-347 pattern, #1922).
* - Keypair generation/loading mirrors
* `v3/@claude-flow/plugin-agent-federation/src/plugin.ts`'s exact
* pattern: a JSON key file under `.claude-flow/<namespace>/`
* (hex-encoded `privateKey`/`publicKey` fields, dir mode 0o700, file
* mode 0o600), generated with `ed.utils.randomPrivateKey()` on first
* use and persisted so the signing identity survives restarts; falls
* back to an ephemeral in-memory key if persistence fails (still real
* crypto, just not durable). `@noble/ed25519` v2's synchronous API
* needs `sha512Sync` wired explicitly via `node:crypto` — same one-line
* wiring the federation plugin uses.
* - Verification pins to a caller-supplied trusted public key when one
* is given (never trust the receipt's self-asserted `publicKey` field
* alone in production — same CWE-347 caution the signed-artifact
* family documents), falling back to the embedded key only when no
* trusted key is supplied (e.g. local inspection / tests).
*
* @see v3/docs/adr/ADR-380-agntcy-outshift-runtime-integration.md §3
* @see v3/@claude-flow/plugin-agent-federation/src/plugin.ts — key handling pattern
* @see plugins/ruflo-neural-trader/src/signed-artifact.ts — signing/canonicalization pattern
* @see v3/@claude-flow/cli/src/ruvector/router-parallel-recorder.ts — the JSONL append-log precedent (ADR-150 Phase 2)
*/
import { createHash } from 'node:crypto';
import {
existsSync,
readFileSync,
writeFileSync,
mkdirSync,
appendFileSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
// @noble/ed25519 v2 needs a sync sha512 wired explicitly. Identical wiring
// to v3/@claude-flow/plugin-agent-federation/src/plugin.ts — kept local
// (rather than imported) so this module has no dependency on the
// federation plugin package.
let _ed: typeof import('@noble/ed25519') | null = null;
async function loadEd(): Promise<typeof import('@noble/ed25519')> {
if (_ed) return _ed;
const ed = await import('@noble/ed25519');
ed.etc.sha512Sync = (...m: Uint8Array[]): Uint8Array => {
const h = createHash('sha512');
for (const x of m) h.update(x);
return h.digest();
};
_ed = ed;
return ed;
}
/* ---------------------------------------------------------------------- */
/* Public types */
/* ---------------------------------------------------------------------- */
/** A CASA authorization decision, prior to signing. */
export interface CasaReceiptInput {
/** The full request envelope that was evaluated (opaque to this module). */
envelope: unknown;
/** The resource/action string that was checked, e.g. `git.push`. */
requestedAction: string;
/** The enforcement outcome. */
decision: {
allowed: boolean;
reason: string;
};
/** ISO-8601 timestamp of the decision. */
timestampIso: string;
}
/** The signed, on-disk JSONL receipt record. */
export interface SignedCasaReceipt extends CasaReceiptInput {
schema: 'ruflo-agntcy-casa-receipt/v1';
/** `ed25519:<hex>` — self-asserted signer key (see caution in module doc). */
publicKey: string;
/** hex-encoded Ed25519 signature over the canonical receipt body. */
signature: string;
}
/** Body that gets signed — everything except schema + signature fields. */
export type SignedCasaReceiptBody = CasaReceiptInput;
/** An Ed25519 keypair used to sign CASA receipts. */
export interface CasaSigningKeypair {
privateKey: Uint8Array;
/** hex-encoded, no `ed25519:` prefix. */
publicKeyHex: string;
}
/* ---------------------------------------------------------------------- */
/* Defaults */
/* ---------------------------------------------------------------------- */
/** Default append-only receipt log path, mirroring `.swarm/router-parallel.jsonl`. */
export const DEFAULT_CASA_RECEIPT_PATH = join('.swarm', 'casa-receipts.jsonl');
/** Default directory + file the CASA signing keypair is persisted to. */
const DEFAULT_KEY_DIR = join('.claude-flow', 'agntcy');
const DEFAULT_KEY_PATH = join(DEFAULT_KEY_DIR, 'casa-receipt-key.json');
interface StoredCasaKeypair {
privateKey: string;
publicKey: string;
createdAt: string;
}
/* ---------------------------------------------------------------------- */
/* Key handling — mirrors plugin-agent-federation/src/plugin.ts */
/* ---------------------------------------------------------------------- */
/**
* Load the persisted CASA signing keypair from `keyPath`, generating and
* persisting a fresh one on first use. Falls back to an ephemeral
* (non-persisted) keypair if the filesystem is unavailable/unwritable —
* signing still happens with real Ed25519 keys, they just won't survive
* a restart.
*/
export async function loadOrCreateCasaSigningKeypair(
keyPath: string = DEFAULT_KEY_PATH,
): Promise<CasaSigningKeypair> {
const ed = await loadEd();
try {
if (existsSync(keyPath)) {
const stored = JSON.parse(readFileSync(keyPath, 'utf-8')) as StoredCasaKeypair;
return {
privateKey: hexToBytes(stored.privateKey),
publicKeyHex: stored.publicKey,
};
}
const privateKey = ed.utils.randomPrivateKey();
const publicKeyHex = bytesToHex(ed.getPublicKey(privateKey));
const keyDir = dirname(keyPath);
if (!existsSync(keyDir)) mkdirSync(keyDir, { recursive: true, mode: 0o700 });
const record: StoredCasaKeypair = {
privateKey: bytesToHex(privateKey),
publicKey: publicKeyHex,
createdAt: new Date().toISOString(),
};
writeFileSync(keyPath, JSON.stringify(record, null, 2), { mode: 0o600 });
return { privateKey, publicKeyHex };
} catch {
// Persistence failed (read-only fs, permissions, ...) — fall back to
// an ephemeral key. Still real Ed25519 crypto, just not durable.
const privateKey = ed.utils.randomPrivateKey();
return { privateKey, publicKeyHex: bytesToHex(ed.getPublicKey(privateKey)) };
}
}
/* ---------------------------------------------------------------------- */
/* Signing + verification */
/* ---------------------------------------------------------------------- */
function canonicalBytes(body: SignedCasaReceiptBody): Uint8Array {
return new TextEncoder().encode(JSON.stringify(body));
}
/**
* Sign a CASA decision and return the fully-formed `SignedCasaReceipt`.
* The signature covers the receipt body WITHOUT `schema`, `publicKey`, or
* `signature` (CWE-347 pattern — same convention as the ADR-126
* signed-artifact family).
*/
export async function signCasaReceipt(
input: CasaReceiptInput,
keypair: CasaSigningKeypair,
): Promise<SignedCasaReceipt> {
const ed = await loadEd();
const canonical = canonicalBytes(input);
const signatureBytes = ed.sign(canonical, keypair.privateKey);
return {
schema: 'ruflo-agntcy-casa-receipt/v1',
...input,
publicKey: `ed25519:${keypair.publicKeyHex}`,
signature: bytesToHex(signatureBytes),
};
}
/**
* Verify a signed CASA receipt. When `trustedPublicKey` is supplied,
* verification pins to it (the receipt's self-asserted `publicKey` field
* is untrusted input an attacker controls). When omitted, falls back to
* the receipt's own embedded key — only safe for local inspection/tests,
* never for production trust decisions.
*/
export async function verifyCasaReceipt(
receipt: SignedCasaReceipt,
trustedPublicKey?: string,
): Promise<boolean> {
if (!receipt || !receipt.signature) return false;
const ed = await loadEd();
const body: SignedCasaReceiptBody = {
envelope: receipt.envelope,
requestedAction: receipt.requestedAction,
decision: receipt.decision,
timestampIso: receipt.timestampIso,
};
const canonical = canonicalBytes(body);
try {
const keySource = trustedPublicKey ?? receipt.publicKey;
const pubKeyHex = keySource.replace(/^ed25519:/, '');
const pubKey = hexToBytes(pubKeyHex);
if (pubKey.length !== 32) return false;
const sig = hexToBytes(receipt.signature);
if (sig.length !== 64) return false;
return ed.verify(sig, canonical, pubKey);
} catch {
return false;
}
}
/* ---------------------------------------------------------------------- */
/* Append-only JSONL log */
/* ---------------------------------------------------------------------- */
/**
* Ed25519-sign a CASA authorization decision and append it as one JSON
* line to `filePath` (default `.swarm/casa-receipts.jsonl`), creating the
* parent directory if needed. Mirrors the append-only paired-decision-row
* pattern in `router-parallel-recorder.ts` (ADR-150 Phase 2), but for CASA
* decisions specifically and with a real signature on every row instead
* of unsigned telemetry.
*
* Unlike the router-parallel recorder, this never silently swallows a
* write failure — a CASA receipt is a security-relevant audit record, so
* a failed append surfaces as a thrown error rather than being logged and
* dropped.
*/
export async function appendCasaReceipt(
receipt: CasaReceiptInput,
filePath: string = DEFAULT_CASA_RECEIPT_PATH,
): Promise<void> {
const keypair = await loadOrCreateCasaSigningKeypair();
const signed = await signCasaReceipt(receipt, keypair);
const dir = dirname(filePath);
if (dir && dir !== '.' && !existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
appendFileSync(filePath, JSON.stringify(signed) + '\n', { encoding: 'utf-8' });
}
/* ---------------------------------------------------------------------- */
/* Helpers */
/* ---------------------------------------------------------------------- */
function hexToBytes(hex: string): Uint8Array {
const clean = hex.replace(/^0x/, '');
if (clean.length % 2 !== 0) {
throw new Error('hexToBytes: odd-length hex string');
}
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
function bytesToHex(bytes: Uint8Array): string {
let s = '';
for (let i = 0; i < bytes.length; i++) {
s += bytes[i].toString(16).padStart(2, '0');
}
return s;
}
@@ -0,0 +1,219 @@
/**
* V3 CLI AGNTCY/SLIM command scaffold tests — ADR-380.
*
* Asserts the "works without AGNTCY installed" smoke contract each command
* must satisfy (mirroring ADR-150's CI-gate precedent, §1 of ADR-380):
* with no `RUFLO_AGNTCY_SLIM_ENDPOINT` set (the default, real-world state
* today since no runtime package exists to install), every command must
* run to completion without throwing, exit 0, and print the documented
* "not configured" fallback message.
*
* Lives under the package's top-level `__tests__/agntcy/` (mirroring
* `__tests__/ruvector/` and `__tests__/services/`, which mirror
* `src/ruvector/` and `src/services/` respectively) so it's picked up by
* the package's `vitest.config.ts` `include: ['__tests__/**\/*.test.ts']`
* glob — the original `src/commands/agntcy/__tests__/` location was never
* reachable by that glob and left this suite unrun by `npm test`.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtemp, writeFile, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { transportCommand, useCommand } from '../../src/commands/agntcy/transport.js';
import { agentPublishCommand, validateOasfRecordShape } from '../../src/commands/agntcy/publish.js';
import { swarmJoinCommand, validateNamespace } from '../../src/commands/agntcy/swarm-join.js';
import {
detectAgntcyRuntime,
AGNTCY_ENDPOINT_ENV,
AGNTCY_NOT_CONFIGURED_MESSAGE,
} from '../../src/commands/agntcy/runtime.js';
import { output } from '../../src/output.js';
import type { CommandContext, CommandResult } from '../../src/types.js';
function makeCtx(args: string[] = [], flags: Record<string, unknown> = {}): CommandContext {
return {
args,
flags: { _: args, ...flags } as CommandContext['flags'],
cwd: process.cwd(),
interactive: false,
};
}
async function runAction(
cmd: { action?: (ctx: CommandContext) => Promise<CommandResult | void> },
ctx: CommandContext,
): Promise<CommandResult | void> {
if (!cmd.action) throw new Error('command has no action');
return cmd.action(ctx);
}
describe('agntcy command scaffold (ADR-380) — not-configured fallback', () => {
const originalEndpoint = process.env[AGNTCY_ENDPOINT_ENV];
beforeEach(() => {
delete process.env[AGNTCY_ENDPOINT_ENV];
});
afterEach(() => {
if (originalEndpoint === undefined) {
delete process.env[AGNTCY_ENDPOINT_ENV];
} else {
process.env[AGNTCY_ENDPOINT_ENV] = originalEndpoint;
}
vi.restoreAllMocks();
});
describe('runtime.ts — detectAgntcyRuntime', () => {
it('reports not configured when the endpoint env var is unset', async () => {
const status = await detectAgntcyRuntime({});
expect(status.configured).toBe(false);
expect(status.reason).toContain(AGNTCY_ENDPOINT_ENV);
expect(status.endpoint).toBeUndefined();
});
it('reports not configured (package unpublished) when the endpoint is set', async () => {
const status = await detectAgntcyRuntime({ [AGNTCY_ENDPOINT_ENV]: 'https://slim.example.com' });
expect(status.configured).toBe(false);
expect(status.endpoint).toBe('https://slim.example.com');
expect(status.reason).toMatch(/not installed|error probing/);
});
it('never throws regardless of env shape', async () => {
await expect(detectAgntcyRuntime({} as NodeJS.ProcessEnv)).resolves.toBeDefined();
});
});
describe('ruflo transport use slim', () => {
it('exposes a "use" subcommand on the transport command tree', () => {
expect(transportCommand.name).toBe('transport');
expect(transportCommand.subcommands?.map((c) => c.name)).toContain('use');
});
it('falls back to local transport without throwing, exit 0, expected message', async () => {
const infoSpy = vi.spyOn(output, 'printInfo').mockImplementation(() => {});
const result = await runAction(useCommand, makeCtx(['slim']));
expect(result).toBeDefined();
expect(result?.success).toBe(true);
expect(result?.exitCode).toBeUndefined();
expect((result?.data as Record<string, unknown>)?.transport).toBe('local');
expect(infoSpy).toHaveBeenCalledWith(AGNTCY_NOT_CONFIGURED_MESSAGE);
});
it('rejects an unsupported transport name with exit 1 (usage error, not a fallback)', async () => {
const result = await runAction(useCommand, makeCtx(['not-a-real-transport']));
expect(result?.success).toBe(false);
expect(result?.exitCode).toBe(1);
});
it('requires a transport name argument', async () => {
const result = await runAction(useCommand, makeCtx([]));
expect(result?.success).toBe(false);
expect(result?.exitCode).toBe(1);
});
it('parent "transport" command action runs without throwing', async () => {
const result = await runAction(transportCommand, makeCtx([]));
expect(result?.success).toBe(true);
});
});
describe('ruflo agent publish', () => {
let tmpDir: string;
let manifestPath: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'agntcy-publish-'));
manifestPath = join(tmpDir, 'agent.oasf.json');
await writeFile(manifestPath, JSON.stringify({ name: 'test-agent', version: '0.1.0' }), 'utf-8');
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('validateOasfRecordShape accepts a minimal valid record', () => {
expect(validateOasfRecordShape({ name: 'a', version: '1.0.0' }).valid).toBe(true);
});
it('validateOasfRecordShape rejects missing fields, non-objects, and null', () => {
expect(validateOasfRecordShape({}).valid).toBe(false);
expect(validateOasfRecordShape({ name: 'a' }).valid).toBe(false);
expect(validateOasfRecordShape(null).valid).toBe(false);
expect(validateOasfRecordShape('nope').valid).toBe(false);
expect(validateOasfRecordShape([]).valid).toBe(false);
});
it('falls back gracefully (exit 0) when not configured, after validating the manifest locally', async () => {
const infoSpy = vi.spyOn(output, 'printInfo').mockImplementation(() => {});
const result = await runAction(agentPublishCommand, makeCtx([], { manifest: manifestPath }));
expect(result).toBeDefined();
expect(result?.success).toBe(true);
expect(result?.exitCode).toBeUndefined();
expect((result?.data as Record<string, unknown>)?.published).toBe(false);
expect(infoSpy).toHaveBeenCalledWith(AGNTCY_NOT_CONFIGURED_MESSAGE);
});
it('errors (exit 1) on a missing manifest file, independent of AGNTCY configuration', async () => {
const result = await runAction(
agentPublishCommand,
makeCtx([], { manifest: join(tmpDir, 'does-not-exist.json') }),
);
expect(result?.success).toBe(false);
expect(result?.exitCode).toBe(1);
});
it('errors (exit 1) on a manifest that is valid JSON but not a valid OASF record', async () => {
const badManifest = join(tmpDir, 'bad.json');
await writeFile(badManifest, JSON.stringify({ notAName: true }), 'utf-8');
const result = await runAction(agentPublishCommand, makeCtx([], { manifest: badManifest }));
expect(result?.success).toBe(false);
expect(result?.exitCode).toBe(1);
});
});
describe('ruflo swarm join <namespace>', () => {
it('validateNamespace accepts slash-delimited alphanumeric namespaces', () => {
expect(validateNamespace('cognitum/research/security').valid).toBe(true);
expect(validateNamespace('single-segment').valid).toBe(true);
});
it('validateNamespace rejects empty and malformed namespaces', () => {
expect(validateNamespace('').valid).toBe(false);
expect(validateNamespace(' ').valid).toBe(false);
expect(validateNamespace('bad namespace!').valid).toBe(false);
expect(validateNamespace('cognitum//security').valid).toBe(false);
});
it('falls back to local coordination without throwing, exit 0, expected message', async () => {
const infoSpy = vi.spyOn(output, 'printInfo').mockImplementation(() => {});
const result = await runAction(swarmJoinCommand, makeCtx(['cognitum/research/security']));
expect(result).toBeDefined();
expect(result?.success).toBe(true);
expect(result?.exitCode).toBeUndefined();
expect((result?.data as Record<string, unknown>)?.joined).toBe(false);
expect(infoSpy).toHaveBeenCalledWith(AGNTCY_NOT_CONFIGURED_MESSAGE);
});
it('requires a namespace argument', async () => {
const result = await runAction(swarmJoinCommand, makeCtx([]));
expect(result?.success).toBe(false);
expect(result?.exitCode).toBe(1);
});
it('rejects an invalid namespace with exit 1 (usage error, not a fallback)', async () => {
const result = await runAction(swarmJoinCommand, makeCtx(['not a namespace!']));
expect(result?.success).toBe(false);
expect(result?.exitCode).toBe(1);
});
});
});
+2 -1
View File
@@ -8,6 +8,7 @@ import { output } from '../output.js';
import { select, confirm, input } from '../prompt.js';
import { callMCPTool, MCPClientError } from '../mcp-client.js';
import { wasmSubcommands } from './agent-wasm.js';
import { agentPublishCommand } from './agntcy/publish.js';
import * as fs from 'fs';
import * as path from 'path';
@@ -1076,7 +1077,7 @@ function formatLogLevel(level: string): string {
export const agentCommand: Command = {
name: 'agent',
description: 'Agent management commands',
subcommands: [spawnCommand, listCommand, statusCommand, stopCommand, metricsCommand, poolCommand, healthCommand, logsCommand, ...wasmSubcommands],
subcommands: [spawnCommand, listCommand, statusCommand, stopCommand, metricsCommand, poolCommand, healthCommand, logsCommand, ...wasmSubcommands, agentPublishCommand],
options: [],
examples: [
{ command: 'claude-flow agent spawn -t coder', description: 'Spawn a coder agent' },
@@ -0,0 +1,82 @@
/**
* V3 CLI AGNTCY/Outshift Runtime Integration — ADR-380 §2.
*
* Scaffolds the three new `ruflo` CLI verbs ADR-380 §2 specifies:
*
* ruflo transport use slim
* ruflo agent publish
* ruflo swarm join <namespace>
*
* ============================================================================
* INTEGRATION STATUS: NOT WIRED IN. This module is intentionally
* self-contained and is NOT imported by, or registered with, the CLI's
* central command registry/index. A later, separate pass must:
*
* 1. Add `transportCommand` as a new top-level command (alongside
* `agent`, `swarm`, etc. in whatever file enumerates top-level
* commands today).
* 2. Merge `agentPublishCommand` into the existing `agentCommand`'s
* `subcommands` array in `../agent.ts`.
* 3. Merge `swarmJoinCommand` into the existing `swarmCommand`'s
* `subcommands` array in `../swarm.ts`.
* 4. Decide (per ADR-380's own "Open Questions") whether `transport use`
* is a per-swarm or global-session setting before wiring state
* persistence for the selected transport.
*
* No file outside `v3/@claude-flow/cli/src/commands/agntcy/` is touched by
* this scaffold.
* ============================================================================
*
* ARCHITECTURAL CONSTRAINT (mirrors ADR-150 §"architectural constraint",
* which ADR-380 §1 explicitly follows):
* - No `@claude-flow/agntcy` (or any other AGNTCY/SLIM/Outshift) package
* exists on npm or crates.io yet — verified 404 on every plausible
* name. Nothing in this directory statically imports one.
* - Every runtime touchpoint goes through `detectAgntcyRuntime()` in
* `runtime.ts`, which only ever does a guarded dynamic `import()`
* wrapped in try/catch, and only after an explicit
* `RUFLO_AGNTCY_SLIM_ENDPOINT` env var opt-in.
* - Every command in this directory exits 0 (not an error) when AGNTCY/
* SLIM is not configured — this is optional, removable augmentation,
* not a required dependency. The CLI, and these commands' own local
* fallback behavior, work identically whether or not this directory
* exists at all.
*
* Created with care by ruv.io
*/
import type { Command } from '../../types.js';
export { transportCommand, useCommand } from './transport.js';
export { agentPublishCommand } from './publish.js';
export { swarmJoinCommand } from './swarm-join.js';
export {
detectAgntcyRuntime,
AGNTCY_ENDPOINT_ENV,
AGNTCY_PACKAGE_NAME,
AGNTCY_ADR_PATH,
AGNTCY_NOT_CONFIGURED_MESSAGE,
} from './runtime.js';
export type { AgntcyRuntimeStatus } from './runtime.js';
export {
validateOasfRecordShape,
} from './publish.js';
export type { OasfAgentRecordShape, OasfValidationResult } from './publish.js';
export { validateNamespace } from './swarm-join.js';
import { transportCommand } from './transport.js';
import { agentPublishCommand } from './publish.js';
import { swarmJoinCommand } from './swarm-join.js';
/**
* Convenience bundle for the later integration pass — NOT a registered
* command tree itself (there is no top-level `agntcy` verb per ADR-380;
* each of these attaches to an existing or new top-level command per the
* mapping documented above).
*/
export const agntcyCommands: readonly Command[] = [transportCommand, agentPublishCommand, swarmJoinCommand];
export default agntcyCommands;
@@ -0,0 +1,161 @@
/**
* V3 CLI `ruflo agent publish` — ADR-380 §2.
*
* Emits the AGNTCY-identified, OASF-described agent record (produced
* build-time by the companion metaharness ADR-240 §2.1/2.2) to the
* configured AGNTCY Directory (https://github.com/agntcy/dir).
*
* This scaffold does NOT implement the Directory publish protocol — no
* SLIM/Directory SDK is installable yet (verified 404 on every plausible
* npm/crates.io name). It implements the one thing that IS real without
* an external SDK: reading a local OASF-shaped manifest off disk (the
* artifact metaharness ADR-240 is responsible for producing) and
* validating its shape before attempting a publish, so the eventual
* network call has a real payload to send rather than being invented at
* publish time.
*
* NOT WIRED INTO THE MAIN CLI ROUTER YET. This file is exported for a
* later integration pass to attach as a new subcommand of the existing
* `agent` command (`v3/@claude-flow/cli/src/commands/agent.ts`).
*/
import type { Command, CommandContext, CommandResult } from '../../types.js';
import { output } from '../../output.js';
import { readFile } from 'fs/promises';
import {
AGNTCY_NOT_CONFIGURED_MESSAGE,
AGNTCY_PACKAGE_NAME,
detectAgntcyRuntime,
} from './runtime.js';
/** Minimal shape check for the OASF agent record metaharness ADR-240 §2.1/2.2 produces. */
export interface OasfAgentRecordShape {
name: unknown;
version: unknown;
identity?: unknown;
capabilities?: unknown;
[key: string]: unknown;
}
export interface OasfValidationResult {
valid: boolean;
errors: string[];
}
/**
* Validate that a parsed JSON value looks like an OASF agent record —
* deterministic shape check, no network, no LLM. Kept exported so the
* test file can exercise it directly without spawning the whole command.
*/
export function validateOasfRecordShape(record: unknown): OasfValidationResult {
const errors: string[] = [];
if (typeof record !== 'object' || record === null || Array.isArray(record)) {
return { valid: false, errors: ['record is not a JSON object'] };
}
const rec = record as Record<string, unknown>;
if (typeof rec.name !== 'string' || rec.name.trim().length === 0) {
errors.push('missing or empty required field "name"');
}
if (typeof rec.version !== 'string' || rec.version.trim().length === 0) {
errors.push('missing or empty required field "version"');
}
return { valid: errors.length === 0, errors };
}
interface AgntcyDirectoryModule {
publishAgentRecord?: (opts: { endpoint: string; record: OasfAgentRecordShape }) => Promise<{ uri?: string }>;
}
const DEFAULT_MANIFEST_PATH = '.agntcy/agent.oasf.json';
const publishCommand: Command = {
name: 'publish',
description: 'Publish the AGNTCY-identified, OASF-described agent record to the configured Directory (ADR-380 §2)',
options: [
{
name: 'manifest',
short: 'm',
description: 'Path to the OASF agent record produced by metaharness (ADR-240)',
type: 'string',
default: DEFAULT_MANIFEST_PATH,
},
],
examples: [
{ command: 'ruflo agent publish', description: 'Publish the local OASF agent record to the AGNTCY Directory' },
{ command: 'ruflo agent publish --manifest ./out/agent.oasf.json', description: 'Publish a specific manifest' },
],
action: async (ctx: CommandContext): Promise<CommandResult> => {
const manifestPath = (ctx.flags.manifest as string) || DEFAULT_MANIFEST_PATH;
// Step 1 (real, no SDK needed): locate + shape-validate the manifest.
let raw: string;
try {
raw = await readFile(manifestPath, 'utf-8');
} catch {
output.printError(
`No OASF agent record found at "${manifestPath}". Run the metaharness ADR-240 build step first, ` +
'or pass --manifest <path>.',
);
return { success: false, exitCode: 1 };
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
output.printError(
`"${manifestPath}" is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
);
return { success: false, exitCode: 1 };
}
const validation = validateOasfRecordShape(parsed);
if (!validation.valid) {
output.printError(`"${manifestPath}" is not a valid OASF agent record:`);
for (const err of validation.errors) {
output.writeln(` - ${err}`);
}
return { success: false, exitCode: 1 };
}
// Step 2 (stub, requires the not-yet-published runtime): the actual
// Directory publish call.
const status = await detectAgntcyRuntime();
if (!status.configured) {
output.printInfo(AGNTCY_NOT_CONFIGURED_MESSAGE);
output.printInfo(
`Validated OASF record at "${manifestPath}" locally (name=${(parsed as OasfAgentRecordShape).name}, ` +
`version=${(parsed as OasfAgentRecordShape).version}); publish to the Directory was skipped.`,
);
return {
success: true,
data: { published: false, manifestPath, configured: false, reason: status.reason },
};
}
try {
const mod = (await import(AGNTCY_PACKAGE_NAME)) as AgntcyDirectoryModule;
if (typeof mod.publishAgentRecord !== 'function') {
throw new Error(`"${AGNTCY_PACKAGE_NAME}" is installed but does not export publishAgentRecord()`);
}
const result = await mod.publishAgentRecord({
endpoint: status.endpoint as string,
record: parsed as OasfAgentRecordShape,
});
output.printSuccess(`Published agent record${result?.uri ? ` to ${result.uri}` : ''}.`);
return { success: true, data: { published: true, manifestPath, uri: result?.uri } };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
output.printError(`Directory publish failed: ${message}`);
return { success: true, data: { published: false, manifestPath, error: message } };
}
},
};
export { publishCommand as agentPublishCommand };
export default publishCommand;
@@ -0,0 +1,106 @@
/**
* V3 CLI AGNTCY/SLIM Runtime Presence Check — ADR-380 §1.
*
* ADR-380 introduces three new `ruflo` CLI verbs (`transport use slim`,
* `agent publish`, `swarm join <namespace>`) that talk to Cisco Outshift's
* AGNTCY ecosystem (SLIM transport, Directory publish, group membership).
*
* As of this scaffold (2026-07-30), NO `@claude-flow/agntcy` package (or
* any SLIM SDK under any other guessed npm/crates.io name) exists — a
* repo-wide check and a registry check both came back clean/404. This
* module therefore NEVER performs a real network call to AGNTCY
* infrastructure. It only answers one question, deterministically and
* without throwing: "has the operator pointed ruflo at a SLIM endpoint,
* and if so, is the optional runtime package actually installed?"
*
* Per ADR-150's precedent (which ADR-380 §1 explicitly follows, not
* ADR-321's hard-dependency exception):
* - removable: deleting this whole directory changes nothing
* about the rest of the CLI working.
* - optional-only: `@claude-flow/agntcy` MUST live in
* optionalDependencies once it is published,
* never `dependencies`.
* - graceful degradation: every caller of `detectAgntcyRuntime()` falls
* back to the local transport / existing
* authorization model on `configured: false`.
* - CI-gated: a "works without AGNTCY installed" smoke test
* is exercised by __tests__/agntcy-commands.test.ts.
*/
/** Env var an operator sets to point ruflo at a SLIM endpoint. */
export const AGNTCY_ENDPOINT_ENV = 'RUFLO_AGNTCY_SLIM_ENDPOINT';
/**
* Guessed package name for the future optional runtime. Verified 404 on
* npm as of this scaffold — see the ADR-380 companion research. Kept as a
* named constant (not a string literal scattered through the module) so a
* single edit repoints every dynamic-import call site once the real
* package ships.
*/
export const AGNTCY_PACKAGE_NAME = '@claude-flow/agntcy';
/** Pointer to the ADR every "not configured" message should send users to. */
export const AGNTCY_ADR_PATH = 'v3/docs/adr/ADR-380-agntcy-outshift-runtime-integration.md';
export const AGNTCY_NOT_CONFIGURED_MESSAGE =
`AGNTCY/SLIM transport is not configured — see ADR-380 (${AGNTCY_ADR_PATH}) for setup. ` +
'Falling back to local transport.';
export interface AgntcyRuntimeStatus {
/** True only when an endpoint is set AND the optional runtime package resolves. */
configured: boolean;
/** Human-readable reason for the current status — always safe to print. */
reason: string;
/** The configured endpoint, if any (present even when configured is false, e.g. package missing). */
endpoint?: string;
}
/**
* Detect whether the optional AGNTCY/SLIM runtime is available.
*
* This function never throws and never makes a network call. It performs,
* in order:
* 1. An env var presence check for {@link AGNTCY_ENDPOINT_ENV}. Absent →
* not configured, no further work.
* 2. A dynamic `import()` of {@link AGNTCY_PACKAGE_NAME}, wrapped in
* try/catch. Today this import always fails with a module-resolution
* error (the package doesn't exist yet) — that failure is treated as
* the expected "not installed" signal, not a bug.
*
* @param env Injectable for tests; defaults to `process.env`.
*/
export async function detectAgntcyRuntime(
env: NodeJS.ProcessEnv = process.env,
): Promise<AgntcyRuntimeStatus> {
const endpoint = env[AGNTCY_ENDPOINT_ENV];
if (!endpoint) {
return { configured: false, reason: `${AGNTCY_ENDPOINT_ENV} is not set` };
}
try {
// Dynamic import of an optional dependency, exactly the pattern this
// repo already uses for other optional runtimes (see status.ts's
// `await import('pg')`). Never a static `import` — a static import
// would make @claude-flow/cli fail to build/run when the package is
// absent, which is the one thing ADR-150/ADR-380 forbid.
await import(AGNTCY_PACKAGE_NAME);
return { configured: true, reason: `${AGNTCY_PACKAGE_NAME} resolved`, endpoint };
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') {
return {
configured: false,
reason: `${AGNTCY_ENDPOINT_ENV} is set but the optional "${AGNTCY_PACKAGE_NAME}" runtime package is not installed`,
endpoint,
};
}
// Any other error importing the module (syntax error in a real
// install, permission issue, etc.) — still degrade gracefully rather
// than propagate, but keep the real reason for diagnostics.
return {
configured: false,
reason: `error probing "${AGNTCY_PACKAGE_NAME}": ${error instanceof Error ? error.message : String(error)}`,
endpoint,
};
}
}
@@ -0,0 +1,105 @@
/**
* V3 CLI `ruflo swarm join <namespace>` — ADR-380 §2.
*
* Joins a SLIM group-membership channel scoped to a Cognitum tenant/project
* namespace (e.g. `cognitum/research/security`). This is an AGNTCY/SLIM
* concept layered on top of — never a replacement for — this repo's own
* `swarm_init`/`hive-mind_*` MCP-tool coordination, which remains the
* default for single-host swarms per ADR-380 §2.
*
* NOT WIRED INTO THE MAIN CLI ROUTER YET. This file is exported for a
* later integration pass to attach as a new subcommand of the existing
* `swarm` command (`v3/@claude-flow/cli/src/commands/swarm.ts`).
*/
import type { Command, CommandContext, CommandResult } from '../../types.js';
import { output } from '../../output.js';
import {
AGNTCY_NOT_CONFIGURED_MESSAGE,
AGNTCY_PACKAGE_NAME,
detectAgntcyRuntime,
} from './runtime.js';
/**
* SLIM group-membership namespaces are slash-delimited (e.g.
* `cognitum/research/security`), matching the ADR-380 §2 example. Validate
* the shape locally — deterministic, no SDK required — before ever
* attempting a network join.
*/
export function validateNamespace(namespace: string): { valid: boolean; error?: string } {
if (!namespace || namespace.trim().length === 0) {
return { valid: false, error: 'namespace must be a non-empty string' };
}
// Deliberately NOT filtering empty segments — a leading/trailing/double
// slash (e.g. "cognitum//security") is a malformed namespace, not a
// cosmetic one, and should be rejected rather than silently repaired.
const segments = namespace.split('/');
if (segments.length === 0) {
return { valid: false, error: 'namespace must contain at least one segment' };
}
const validSegment = /^[a-zA-Z0-9_-]+$/;
const bad = segments.find((s) => !validSegment.test(s));
if (bad !== undefined) {
return { valid: false, error: `invalid namespace segment "${bad}" — only [a-zA-Z0-9_-] allowed per segment` };
}
return { valid: true };
}
interface AgntcySlimGroupModule {
joinGroup?: (opts: { endpoint: string; namespace: string }) => Promise<{ members?: number }>;
}
const joinCommand: Command = {
name: 'join',
description: 'Join a SLIM group-membership channel scoped to a namespace (ADR-380 §2)',
examples: [
{ command: 'ruflo swarm join cognitum/research/security', description: 'Join the SLIM group-membership channel for a tenant namespace' },
],
action: async (ctx: CommandContext): Promise<CommandResult> => {
const namespace = (ctx.args[0] || (ctx.flags.namespace as string) || '').trim();
if (!namespace) {
output.printError('Namespace is required. Usage: ruflo swarm join <namespace> (e.g. cognitum/research/security)');
return { success: false, exitCode: 1 };
}
const nsCheck = validateNamespace(namespace);
if (!nsCheck.valid) {
output.printError(`Invalid namespace "${namespace}": ${nsCheck.error}`);
return { success: false, exitCode: 1 };
}
const status = await detectAgntcyRuntime();
if (!status.configured) {
output.printInfo(AGNTCY_NOT_CONFIGURED_MESSAGE);
output.printInfo(
`Namespace "${namespace}" was not joined via SLIM. Local swarm/hive-mind coordination ` +
'(swarm_init / hive-mind_* MCP tools) remains available and unaffected.',
);
return {
success: true,
data: { joined: false, namespace, configured: false, reason: status.reason },
};
}
try {
const mod = (await import(AGNTCY_PACKAGE_NAME)) as AgntcySlimGroupModule;
if (typeof mod.joinGroup !== 'function') {
throw new Error(`"${AGNTCY_PACKAGE_NAME}" is installed but does not export joinGroup()`);
}
const result = await mod.joinGroup({ endpoint: status.endpoint as string, namespace });
output.printSuccess(
`Joined SLIM group "${namespace}"${typeof result?.members === 'number' ? ` (${result.members} members)` : ''}.`,
);
return { success: true, data: { joined: true, namespace, members: result?.members } };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
output.printError(`SLIM group join failed: ${message}`);
return { success: true, data: { joined: false, namespace, error: message } };
}
},
};
export { joinCommand as swarmJoinCommand };
export default joinCommand;
@@ -0,0 +1,117 @@
/**
* V3 CLI `ruflo transport use slim` — ADR-380 §2.
*
* Switches the active swarm/hive-mind coordination transport from today's
* in-process/local-hooks routing to SLIM (secure messaging for MCP/A2A,
* hierarchical routing, group membership, MLS encryption) for agents
* coordinating across hosts or across a tenant boundary.
*
* Local transport stays the default. This command is a NO-OP on the local
* path unless an operator has both set {@link AGNTCY_ENDPOINT_ENV} AND
* installed the (not-yet-published) optional runtime package — see
* runtime.ts for the full graceful-degradation contract.
*
* NOT WIRED INTO THE MAIN CLI ROUTER YET. This file is exported for a
* later integration pass to attach as a new top-level `transport` command
* (or a subcommand of one, per the open question in ADR-380 §"Open
* Questions" about per-swarm vs. global scope).
*/
import type { Command, CommandContext, CommandResult } from '../../types.js';
import { output } from '../../output.js';
import {
AGNTCY_NOT_CONFIGURED_MESSAGE,
AGNTCY_PACKAGE_NAME,
detectAgntcyRuntime,
} from './runtime.js';
/** Transport names this command currently recognizes. Only 'slim' per ADR-380 §2. */
const SUPPORTED_TRANSPORTS = new Set(['slim']);
interface AgntcySlimRuntimeModule {
createSlimTransport?: (opts: { endpoint: string }) => Promise<unknown>;
}
const useCommand: Command = {
name: 'use',
description: 'Switch the active swarm/hive-mind transport (e.g. slim) — ADR-380 §2',
examples: [
{ command: 'ruflo transport use slim', description: 'Switch coordination transport to SLIM (opt-in, degrades to local)' },
],
action: async (ctx: CommandContext): Promise<CommandResult> => {
const requested = (ctx.args[0] || (ctx.flags.transport as string) || '').trim().toLowerCase();
if (!requested) {
output.printError('Transport name required. Usage: ruflo transport use <name> (e.g. slim)');
return { success: false, exitCode: 1 };
}
if (!SUPPORTED_TRANSPORTS.has(requested)) {
output.printError(
`Unknown transport "${requested}". Supported: ${Array.from(SUPPORTED_TRANSPORTS).join(', ')}.`,
);
output.printInfo('Local transport remains the default and needs no explicit "use".');
return { success: false, exitCode: 1 };
}
const status = await detectAgntcyRuntime();
if (!status.configured) {
output.printInfo(AGNTCY_NOT_CONFIGURED_MESSAGE);
output.printInfo('Active transport remains: local (in-process hooks routing).');
return {
success: true,
data: { transport: 'local', requested, configured: false, reason: status.reason },
};
}
// Reachable only once the optional package is actually installed AND
// an endpoint is configured — not possible today (package unpublished).
try {
const mod = (await import(AGNTCY_PACKAGE_NAME)) as AgntcySlimRuntimeModule;
if (typeof mod.createSlimTransport !== 'function') {
throw new Error(`"${AGNTCY_PACKAGE_NAME}" is installed but does not export createSlimTransport()`);
}
await mod.createSlimTransport({ endpoint: status.endpoint as string });
output.printSuccess(`Active transport switched to: slim (${status.endpoint})`);
return { success: true, data: { transport: 'slim', endpoint: status.endpoint, configured: true } };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
output.printError(`Failed to activate SLIM transport: ${message}`);
output.printInfo('Falling back to local transport.');
return { success: true, data: { transport: 'local', requested, configured: false, error: message } };
}
},
};
export const transportCommand: Command = {
name: 'transport',
description: 'Manage the active swarm/hive-mind coordination transport (ADR-380 §2)',
subcommands: [useCommand],
examples: [
{ command: 'ruflo transport use slim', description: 'Switch coordination transport to SLIM' },
],
action: async (): Promise<CommandResult> => {
output.writeln();
output.writeln(output.bold('AGNTCY/SLIM Transport'));
output.writeln(output.dim('='.repeat(60)));
output.writeln();
output.printBox(
[
'Local transport (in-process hooks routing) is the default.',
'',
'Subcommands:',
'',
' use <name> Switch the active transport (currently: slim)',
'',
`See ADR-380 for setup: v3/docs/adr/ADR-380-agntcy-outshift-runtime-integration.md`,
].join('\n'),
'AGNTCY/SLIM Transport',
);
output.writeln();
return { success: true };
},
};
export { useCommand };
export default transportCommand;
@@ -99,6 +99,10 @@ const commandLoaders: Record<string, CommandLoader> = {
spinner: () => import('./spinner.js'),
// Ruflo entries in Claude Code's companyAnnouncements startup rotation (ADR-319)
announcements: () => import('./announcements.js'),
// AGNTCY/Outshift runtime transport selection (ADR-324 §2) — optional,
// removable augmentation; no-ops to local transport when AGNTCY/SLIM is
// not configured (RUFLO_AGNTCY_SLIM_ENDPOINT unset).
transport: () => import('./agntcy/transport.js'),
};
// Cache for loaded commands
@@ -208,6 +212,7 @@ export async function getGuidanceCommand() { return loadCommand('guidance'); }
export async function getApplianceCommand() { return loadCommand('appliance'); }
export async function getCleanupCommand() { return loadCommand('cleanup'); }
export async function getAutopilotCommand() { return loadCommand('autopilot'); }
export async function getTransportCommand() { return loadCommand('transport'); }
/**
* Core commands loaded synchronously (available immediately)
+2 -1
View File
@@ -7,6 +7,7 @@ import type { Command, CommandContext, CommandResult } from '../types.js';
import { output } from '../output.js';
import { select, confirm, multiSelect } from '../prompt.js';
import { callMCPTool, MCPClientError } from '../mcp-client.js';
import { swarmJoinCommand } from './agntcy/swarm-join.js';
import * as fs from 'fs';
import * as path from 'path';
@@ -1072,7 +1073,7 @@ const pheromoneCommand: Command = {
export const swarmCommand: Command = {
name: 'swarm',
description: 'Swarm coordination commands',
subcommands: [initCommand, startCommand, statusCommand, stopCommand, scaleCommand, coordinateCommand, compressMessageCommand, pheromoneCommand],
subcommands: [initCommand, startCommand, statusCommand, stopCommand, scaleCommand, coordinateCommand, compressMessageCommand, pheromoneCommand, swarmJoinCommand],
options: [],
examples: [
{ command: 'claude-flow swarm init --v3-mode', description: 'Initialize V3 swarm' },
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "ruflo-agntcy"
version = "0.1.0"
edition = "2021"
rust-version = "1.85"
description = "AGNTCY/Outshift runtime integration for ruflo: CASA intent-scoped authorization enforcement + SLIM transport surface (ADR-380)"
license = "MIT OR Apache-2.0"
repository = "https://github.com/ruvnet/ruflo"
keywords = ["ruflo", "agntcy", "casa", "slim", "authorization"]
categories = ["network-programming", "authentication"]
[lib]
crate-type = ["lib"]
# The "slim" feature currently compiles a STUB ONLY.
#
# No `agntcy`/`slim` Rust crate is published on crates.io as of this
# writing — every plausible package name (`agntcy-slim`, `slim-transport`,
# `agntcy`, `outshift-slim`, etc.) 404s; verified during ADR-380
# scaffolding, not assumed. Enabling this feature exposes `SlimTransport`
# (see `src/transport.rs`), whose methods return an explicit
# `Err(TransportError::Unavailable(...))` describing the missing
# dependency rather than doing anything fake. It is NOT wired into
# `default-features` so a plain `cargo build`/`cargo check` on this crate
# never requires SLIM to exist.
#
# Swap in the real upstream dependency (as an optional Cargo dep gated
# behind this same feature) once agntcy/slim publishes a Rust crate — see
# ADR-380 §2 ("SLIM transport for distributed coordination") and §6
# ("`@claude-flow/agntcy` package and Rust `ruflo agntcy` crate").
[features]
default = []
slim = []
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+276
View File
@@ -0,0 +1,276 @@
//! CASA (Continuous Agentic Semantic Authorization) intent-scoped
//! authorization envelope + enforcement gate — ADR-380 §3.
//!
//! MetaHarness (companion ADR-240 §4) compiles a user's objective into a
//! bounded authority envelope. This module is where RuFlo *enforces* that
//! envelope, matching the TypeScript `CasaEnvelope` schema field-for-field
//! and the same deny-by-default enforcement algorithm as the TypeScript
//! `enforce.ts` reference: an expiry check, then a deny-list check, then
//! an allow-list check.
//!
//! The single non-negotiable design constraint from ADR-380 §3, restated
//! here because it is load-bearing: *enforcement* must never ask a model
//! whether an action is permitted at invocation time. [`check_authorization`]
//! is a pure function — no async, no I/O, no network call, no model call —
//! that checks a bounded schema (explicit resource strings, explicit deny
//! list, numeric budget, expiry timestamp) with deterministic code.
use serde::{Deserialize, Serialize};
/// A CASA authority envelope, as compiled by MetaHarness from a user's
/// objective (ADR-240 §4) and enforced by RuFlo at every tool invocation
/// (ADR-380 §3).
///
/// Mirrors the TypeScript `CasaEnvelope` schema exactly:
///
/// ```json
/// {
/// "objective": "review repository security",
/// "allow": ["repository.read", "tests.execute"],
/// "deny": ["git.push", "secret.export", "deployment.create"],
/// "budget_usd": 8,
/// "expires_at": "2026-07-30T22:00:00Z"
/// }
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CasaEnvelope {
/// Human-readable description of what this envelope was compiled to
/// authorize (informational only — not part of the enforcement logic).
pub objective: String,
/// Explicit list of resource strings this envelope authorizes.
pub allow: Vec<String>,
/// Explicit list of resource strings this envelope forbids. Wins over
/// `allow` when a resource string appears in both (see
/// [`check_authorization`]).
pub deny: Vec<String>,
/// The USD budget attached to this envelope. Enforced by Meta LLM's
/// existing cost-governance path (ADR-380 §3) — not by this crate;
/// carried here only because it is part of the envelope schema.
pub budget_usd: f64,
/// RFC3339 timestamp (e.g. `"2026-07-30T22:00:00Z"`) after which this
/// envelope is no longer valid.
pub expires_at: String,
}
/// The result of a [`check_authorization`] call.
///
/// Deny-by-default: `allowed` is `true` only if the requested action
/// survives every gate in [`check_authorization`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AuthDecision {
pub allowed: bool,
pub reason: String,
}
impl AuthDecision {
fn denied(reason: impl Into<String>) -> Self {
Self {
allowed: false,
reason: reason.into(),
}
}
fn allowed(reason: impl Into<String>) -> Self {
Self {
allowed: true,
reason: reason.into(),
}
}
}
/// Deterministic, pure CASA enforcement gate (ADR-380 §3).
///
/// Gate order — every gate must pass for the action to be authorized:
///
/// 1. **Expiry** — `now` must be strictly before `envelope.expires_at`.
/// An unparseable timestamp on either side is treated as expired
/// (deny-by-default extends to malformed input, not just late input).
/// 2. **Deny-list** — if `requested_action` appears in `envelope.deny`,
/// the action is refused, *even if it also appears in `envelope.allow`*.
/// Deny wins ties — this is the bypass case ADR-380 §3 calls out
/// explicitly as a required test.
/// 3. **Allow-list** — `requested_action` must appear in `envelope.allow`
/// to be authorized. Anything not explicitly allowed is denied.
///
/// This function performs no I/O and calls no model.
pub fn check_authorization(
envelope: &CasaEnvelope,
requested_action: &str,
now: &str,
) -> AuthDecision {
// Gate 1: expiry.
let expires_at_secs = parse_rfc3339_to_epoch_seconds(&envelope.expires_at);
let now_secs = parse_rfc3339_to_epoch_seconds(now);
match (now_secs, expires_at_secs) {
(Some(now_secs), Some(expires_at_secs)) => {
if now_secs >= expires_at_secs {
return AuthDecision::denied(format!(
"envelope expired at {} (now: {})",
envelope.expires_at, now
));
}
}
_ => {
return AuthDecision::denied(
"could not parse expires_at/now as RFC3339 timestamps — denying by default",
);
}
}
// Gate 2: deny-list. Deny wins even if the action is also in `allow`.
if envelope.deny.iter().any(|d| d == requested_action) {
return AuthDecision::denied(format!(
"action '{requested_action}' is explicitly denied"
));
}
// Gate 3: allow-list. Deny by default — must be explicitly present.
if !envelope.allow.iter().any(|a| a == requested_action) {
return AuthDecision::denied(format!(
"action '{requested_action}' is not in the allow list"
));
}
AuthDecision::allowed(format!("action '{requested_action}' authorized"))
}
/// Parses a subset of RFC3339 (`YYYY-MM-DDTHH:MM:SS[.fraction](Z|±HH:MM)`)
/// into whole seconds since the Unix epoch (UTC).
///
/// Implemented from scratch (no `chrono`/`time` dependency — this crate's
/// only dependencies are `serde`/`serde_json`, per ADR-380 §6) using the
/// well-known `days_from_civil` algorithm. Returns `None` on any malformed
/// input, including a timestamp with no explicit timezone — `check_authorization`
/// treats an unparseable timestamp as expired (deny-by-default).
fn parse_rfc3339_to_epoch_seconds(s: &str) -> Option<i64> {
let s = s.trim();
let t_pos = s.find(['T', 't'])?;
let (date_part, rest) = s.split_at(t_pos);
let time_and_offset = &rest[1..];
let date_fields: Vec<&str> = date_part.split('-').collect();
if date_fields.len() != 3 {
return None;
}
let year: i64 = date_fields[0].parse().ok()?;
let month: i64 = date_fields[1].parse().ok()?;
let day: i64 = date_fields[2].parse().ok()?;
let (time_part, offset_seconds) = if let Some(z_pos) = time_and_offset.find(['Z', 'z']) {
(&time_and_offset[..z_pos], 0i64)
} else if let Some(sign_pos) = time_and_offset.rfind(['+', '-']) {
let sign = if time_and_offset.as_bytes()[sign_pos] == b'-' {
-1
} else {
1
};
let offset_str = &time_and_offset[sign_pos + 1..];
let offset_fields: Vec<&str> = offset_str.split(':').collect();
if offset_fields.len() != 2 {
return None;
}
let offset_hours: i64 = offset_fields[0].parse().ok()?;
let offset_minutes: i64 = offset_fields[1].parse().ok()?;
(
&time_and_offset[..sign_pos],
sign * (offset_hours * 3600 + offset_minutes * 60),
)
} else {
// No explicit timezone — reject rather than guess.
return None;
};
let time_fields: Vec<&str> = time_part.split(':').collect();
if time_fields.len() != 3 {
return None;
}
let hour: i64 = time_fields[0].parse().ok()?;
let minute: i64 = time_fields[1].parse().ok()?;
// Seconds may carry a fractional part (e.g. "05.250"); we only need
// whole-second precision for expiry comparisons.
let second_whole_str = time_fields[2].split('.').next()?;
let second: i64 = second_whole_str.parse().ok()?;
if !(1..=12).contains(&month)
|| !(1..=31).contains(&day)
|| !(0..24).contains(&hour)
|| !(0..60).contains(&minute)
|| !(0..=60).contains(&second)
{
return None;
}
let days = days_from_civil(year, month, day)?;
Some(days * 86_400 + hour * 3600 + minute * 60 + second - offset_seconds)
}
/// Howard Hinnant's `days_from_civil` algorithm: converts a
/// proleptic-Gregorian (year, month, day) into a day count relative to
/// 1970-01-01. See <http://howardhinnant.github.io/date_algorithms.html>.
fn days_from_civil(y: i64, m: i64, d: i64) -> Option<i64> {
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
let y = if m <= 2 { y - 1 } else { y };
let era = (if y >= 0 { y } else { y - 399 }) / 400;
let yoe = y - era * 400; // [0, 399]
let mp = (m + 9) % 12; // [0, 11]
let doy = (153 * mp + 2) / 5 + d - 1; // [0, 365]
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
Some(era * 146_097 + doe - 719_468)
}
#[cfg(test)]
mod parser_tests {
use super::parse_rfc3339_to_epoch_seconds;
#[test]
fn parses_unix_epoch() {
assert_eq!(
parse_rfc3339_to_epoch_seconds("1970-01-01T00:00:00Z"),
Some(0)
);
}
#[test]
fn parses_known_timestamp() {
// 2026-07-30T22:00:00Z — sanity-checked against `date -u -d`.
let secs = parse_rfc3339_to_epoch_seconds("2026-07-30T22:00:00Z").unwrap();
assert_eq!(secs, 1785448800);
}
#[test]
fn respects_positive_offset() {
// Same instant as 2026-07-30T22:00:00Z, expressed at +02:00.
let utc = parse_rfc3339_to_epoch_seconds("2026-07-30T22:00:00Z").unwrap();
let offset = parse_rfc3339_to_epoch_seconds("2026-07-31T00:00:00+02:00").unwrap();
assert_eq!(utc, offset);
}
#[test]
fn respects_negative_offset() {
let utc = parse_rfc3339_to_epoch_seconds("2026-07-30T22:00:00Z").unwrap();
let offset = parse_rfc3339_to_epoch_seconds("2026-07-30T17:00:00-05:00").unwrap();
assert_eq!(utc, offset);
}
#[test]
fn parses_fractional_seconds() {
assert_eq!(
parse_rfc3339_to_epoch_seconds("2026-07-30T22:00:00.250Z"),
parse_rfc3339_to_epoch_seconds("2026-07-30T22:00:00Z")
);
}
#[test]
fn rejects_missing_timezone() {
assert_eq!(parse_rfc3339_to_epoch_seconds("2026-07-30T22:00:00"), None);
}
#[test]
fn rejects_garbage() {
assert_eq!(parse_rfc3339_to_epoch_seconds("not-a-timestamp"), None);
assert_eq!(parse_rfc3339_to_epoch_seconds(""), None);
}
}
+30
View File
@@ -0,0 +1,30 @@
//! `ruflo-agntcy` — AGNTCY/Outshift runtime integration for RuFlo (ADR-380).
//!
//! This crate is scoped to the two genuinely new legs RuFlo needs per
//! ADR-380 (`v3/docs/adr/ADR-380-agntcy-outshift-runtime-integration.md`):
//!
//! - **CASA intent-scoped authorization enforcement** (§3) — the
//! [`envelope`] module. Always compiled, always pure: it is the
//! deterministic, deny-by-default gate that checks a
//! MetaHarness-compiled [`envelope::CasaEnvelope`] against a
//! requested action *before* dispatch. It never calls a model.
//!
//! - **SLIM transport for distributed coordination** (§2) — the
//! [`transport`] module. Ships a real, working [`transport::LocalTransport`]
//! (today's default, in-process/loopback) plus a
//! [`transport::SlimTransport`] stub behind the `slim` Cargo feature,
//! since no `agntcy`/`slim` Rust crate is published on crates.io yet.
//!
//! Per ADR-380 §1, this crate is optional and removable: nothing in this
//! repo's default build path requires it, and disabling the `slim`
//! feature (the default) keeps the crate free of any dependency on
//! unpublished upstream packages.
pub mod envelope;
pub mod transport;
pub use envelope::{check_authorization, AuthDecision, CasaEnvelope};
pub use transport::{LocalTransport, Transport, TransportError};
#[cfg(feature = "slim")]
pub use transport::SlimTransport;
+198
View File
@@ -0,0 +1,198 @@
//! Transport surface for AGNTCY/SLIM-coordinated agent messaging — ADR-380
//! §2.
//!
//! [`LocalTransport`] is the default, always-available in-process/loopback
//! implementation — the same "keep the current local transport as the
//! default for single-host swarms" posture ADR-380 §2 requires. It is a
//! real, working implementation usable today with no external
//! dependencies.
//!
//! [`SlimTransport`] (behind the `slim` Cargo feature) is a deliberate
//! stub: no `agntcy`/`slim` Rust crate is published on crates.io yet
//! (verified during ADR-380 scaffolding), so its methods return an
//! explicit, clearly-labeled error rather than silently succeeding or
//! faking network behavior.
use std::collections::HashMap;
use std::fmt;
use std::sync::Mutex;
/// Errors a [`Transport`] implementation can return.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransportError {
/// A transport-specific failure, always carrying an explicit message
/// (never a silent partial success).
Failed(String),
/// This transport is not available in the current build/configuration
/// — e.g. [`SlimTransport`] before the upstream `agntcy`/`slim` Rust
/// crate exists.
Unavailable(String),
}
impl fmt::Display for TransportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TransportError::Failed(msg) => write!(f, "transport failed: {msg}"),
TransportError::Unavailable(msg) => write!(f, "transport unavailable: {msg}"),
}
}
}
impl std::error::Error for TransportError {}
/// A message transport for agent-to-agent coordination.
///
/// Implementations are expected to be usable from multiple threads
/// (`send`/`recv` take `&self`).
pub trait Transport {
/// Sends `payload` on `channel`. Returns `Ok(())` once the payload has
/// been accepted by the transport (delivery semantics are
/// implementation-defined).
fn send(&self, channel: &str, payload: &[u8]) -> Result<(), TransportError>;
/// Drains and returns every payload currently queued on `channel`.
/// Returns an empty `Vec` (not an error) if the channel has no
/// pending messages.
fn recv(&self, channel: &str) -> Result<Vec<Vec<u8>>, TransportError>;
}
/// A real, working in-process/loopback [`Transport`].
///
/// Messages sent to a channel are queued in memory and returned in FIFO
/// order to the next `recv` call on that same channel. This is today's
/// default transport for single-host swarms per ADR-380 §2 — "Keep the
/// current local transport as the default for single-host swarms" — and
/// requires no external dependencies or network access.
#[derive(Debug, Default)]
pub struct LocalTransport {
channels: Mutex<HashMap<String, Vec<Vec<u8>>>>,
}
impl LocalTransport {
/// Creates a new, empty `LocalTransport`.
pub fn new() -> Self {
Self {
channels: Mutex::new(HashMap::new()),
}
}
}
impl Transport for LocalTransport {
fn send(&self, channel: &str, payload: &[u8]) -> Result<(), TransportError> {
let mut channels = self
.channels
.lock()
.map_err(|_| TransportError::Failed("LocalTransport mutex poisoned".to_string()))?;
channels
.entry(channel.to_string())
.or_default()
.push(payload.to_vec());
Ok(())
}
fn recv(&self, channel: &str) -> Result<Vec<Vec<u8>>, TransportError> {
let mut channels = self
.channels
.lock()
.map_err(|_| TransportError::Failed("LocalTransport mutex poisoned".to_string()))?;
Ok(channels.remove(channel).unwrap_or_default())
}
}
/// SLIM (secure messaging for MCP/A2A, ADR-380 §2) transport — **stub
/// only**.
///
/// No `agntcy`/`slim` Rust crate is published on crates.io as of this
/// writing. Every method here returns
/// `Err(TransportError::Unavailable(...))` with an explicit, actionable
/// message rather than doing anything fake. This type only exists behind
/// the `slim` Cargo feature (off by default) so downstream code can start
/// writing against the [`Transport`] trait today and swap in a real
/// implementation once the upstream crate ships.
#[cfg(feature = "slim")]
#[derive(Debug, Default)]
pub struct SlimTransport;
#[cfg(feature = "slim")]
impl SlimTransport {
/// Constructs a `SlimTransport` stub. Does not attempt any network
/// connection — connection attempts happen (and fail explicitly) in
/// [`Transport::send`]/[`Transport::recv`].
pub fn new() -> Self {
Self
}
}
#[cfg(feature = "slim")]
impl Transport for SlimTransport {
fn send(&self, _channel: &str, _payload: &[u8]) -> Result<(), TransportError> {
Err(TransportError::Unavailable(
"SLIM transport not yet available — pending upstream agntcy/slim Rust crate publication, see ADR-380".to_string(),
))
}
fn recv(&self, _channel: &str) -> Result<Vec<Vec<u8>>, TransportError> {
Err(TransportError::Unavailable(
"SLIM transport not yet available — pending upstream agntcy/slim Rust crate publication, see ADR-380".to_string(),
))
}
}
#[cfg(test)]
mod local_transport_tests {
use super::*;
#[test]
fn recv_on_empty_channel_returns_empty_vec() {
let t = LocalTransport::new();
assert_eq!(t.recv("nobody-sent-here").unwrap(), Vec::<Vec<u8>>::new());
}
#[test]
fn send_then_recv_round_trips_fifo() {
let t = LocalTransport::new();
t.send("ch1", b"first").unwrap();
t.send("ch1", b"second").unwrap();
let msgs = t.recv("ch1").unwrap();
assert_eq!(msgs, vec![b"first".to_vec(), b"second".to_vec()]);
}
#[test]
fn recv_drains_the_channel() {
let t = LocalTransport::new();
t.send("ch1", b"only").unwrap();
assert_eq!(t.recv("ch1").unwrap().len(), 1);
assert_eq!(t.recv("ch1").unwrap().len(), 0);
}
#[test]
fn channels_are_independent() {
let t = LocalTransport::new();
t.send("a", b"for-a").unwrap();
t.send("b", b"for-b").unwrap();
assert_eq!(t.recv("a").unwrap(), vec![b"for-a".to_vec()]);
assert_eq!(t.recv("b").unwrap(), vec![b"for-b".to_vec()]);
}
#[cfg(feature = "slim")]
#[test]
fn slim_transport_send_returns_explicit_unavailable_error() {
let t = SlimTransport::new();
let err = t.send("ch1", b"payload").unwrap_err();
match err {
TransportError::Unavailable(msg) => {
assert!(msg.contains("ADR-380"));
assert!(msg.contains("SLIM"));
}
other => panic!("expected Unavailable, got {other:?}"),
}
}
#[cfg(feature = "slim")]
#[test]
fn slim_transport_recv_returns_explicit_unavailable_error() {
let t = SlimTransport::new();
let err = t.recv("ch1").unwrap_err();
assert!(matches!(err, TransportError::Unavailable(_)));
}
}
@@ -0,0 +1,118 @@
//! Integration tests for `check_authorization` — the CASA deny-by-default
//! enforcement gate (ADR-380 §3).
//!
//! These mirror the bypass-attempt cases the ADR requires be tested
//! explicitly, not just translation-quality tests: denial on expiry,
//! denial when an action is in both `allow` and `deny`, denial when an
//! action is absent from `allow`, and the genuine allowed case.
use ruflo_agntcy::{check_authorization, CasaEnvelope};
fn sample_envelope() -> CasaEnvelope {
CasaEnvelope {
objective: "review repository security".to_string(),
allow: vec!["repository.read".to_string(), "tests.execute".to_string()],
deny: vec![
"git.push".to_string(),
"secret.export".to_string(),
"deployment.create".to_string(),
],
budget_usd: 8.0,
expires_at: "2026-07-30T22:00:00Z".to_string(),
}
}
#[test]
fn denied_when_envelope_has_expired() {
let envelope = sample_envelope();
// `now` is after `expires_at`.
let decision = check_authorization(&envelope, "repository.read", "2026-07-30T23:00:00Z");
assert!(!decision.allowed);
assert!(decision.reason.to_lowercase().contains("expired"));
}
#[test]
fn denied_exactly_at_expiry_instant() {
// `now == expires_at` must deny (strictly-before semantics), not
// allow — an off-by-one here is a real bypass.
let envelope = sample_envelope();
let decision = check_authorization(&envelope, "repository.read", "2026-07-30T22:00:00Z");
assert!(!decision.allowed);
}
#[test]
fn denied_when_action_is_in_both_allow_and_deny() {
// Bypass attempt: an attacker-controlled compiler might try to smuggle
// an action into `allow` while it is also present in `deny`. Deny
// must win.
let mut envelope = sample_envelope();
envelope.allow.push("git.push".to_string());
assert!(envelope.deny.contains(&"git.push".to_string()));
assert!(envelope.allow.contains(&"git.push".to_string()));
let decision = check_authorization(&envelope, "git.push", "2026-07-30T12:00:00Z");
assert!(!decision.allowed);
assert!(decision.reason.to_lowercase().contains("denied"));
}
#[test]
fn denied_when_action_not_in_allow_list() {
// Deny-by-default: an action that is in neither list must still be
// refused.
let envelope = sample_envelope();
let decision = check_authorization(&envelope, "billing.charge", "2026-07-30T12:00:00Z");
assert!(!decision.allowed);
assert!(decision.reason.to_lowercase().contains("not in the allow list"));
}
#[test]
fn allowed_in_the_genuine_case() {
let envelope = sample_envelope();
let decision = check_authorization(&envelope, "tests.execute", "2026-07-30T12:00:00Z");
assert!(decision.allowed);
assert!(decision.reason.contains("tests.execute"));
}
#[test]
fn denied_when_expiry_timestamp_is_malformed() {
// Malformed input must deny-by-default, not panic or silently allow.
let mut envelope = sample_envelope();
envelope.expires_at = "not-a-timestamp".to_string();
let decision = check_authorization(&envelope, "repository.read", "2026-07-30T12:00:00Z");
assert!(!decision.allowed);
}
#[test]
fn denied_when_now_timestamp_is_malformed() {
let envelope = sample_envelope();
let decision = check_authorization(&envelope, "repository.read", "definitely-not-rfc3339");
assert!(!decision.allowed);
}
#[test]
fn serde_round_trip_matches_typescript_schema_field_names() {
// Locks the wire schema to the exact field names/shape the
// TypeScript `CasaEnvelope` uses, so a Rust/TS drift is caught here
// rather than at integration time.
let json = r#"{
"objective": "review repository security",
"allow": ["repository.read", "tests.execute"],
"deny": ["git.push", "secret.export", "deployment.create"],
"budget_usd": 8,
"expires_at": "2026-07-30T22:00:00Z"
}"#;
let envelope: CasaEnvelope = serde_json::from_str(json).expect("valid CasaEnvelope JSON");
assert_eq!(envelope.objective, "review repository security");
assert_eq!(envelope.allow, vec!["repository.read", "tests.execute"]);
assert_eq!(
envelope.deny,
vec!["git.push", "secret.export", "deployment.create"]
);
assert_eq!(envelope.budget_usd, 8.0);
assert_eq!(envelope.expires_at, "2026-07-30T22:00:00Z");
let round_tripped: CasaEnvelope =
serde_json::from_str(&serde_json::to_string(&envelope).unwrap()).unwrap();
assert_eq!(round_tripped, envelope);
}
@@ -0,0 +1,250 @@
# ADR-378 — npm Trusted Publishing for CI/CD Release Automation
- **Status**: Proposed
- **Date**: 2026-07-30
- **Related**: root `CLAUDE.md` → "Publishing to npm" (existing manual runbook), 2026-07-14 helpers-signing-key leak incident (same section), Cognitum Platform deployment table (existing WIF precedent for `meta-llm`'s CD)
- **Supersedes**: nothing — this repo has no existing publish/release GitHub Actions workflow (`.github/workflows/` has 24 workflows; none named `*publish*` or `*release*`). This is net-new automation, not a migration.
## Context
### Current state (fully manual)
All three published packages — `@claude-flow/cli` (`v3/@claude-flow/cli/`), `claude-flow`
(repo root), `ruflo` (`ruflo/`) — are version-locked (currently `3.32.24` across all three)
and published **by hand, from an operator's local machine**, per the runbook in root
`CLAUDE.md`:
1. `npm version <x.y.z> --no-git-tag-version` in each of the three package directories, in order
2. `npm publish` per package (default `latest` tag)
3. `npm dist-tag add <pkg>@<version> alpha` and `... v3alpha` per package (9 dist-tag calls total)
4. Manual verification loop confirming `latest === alpha === v3alpha` across all three
5. `git tag v<version> main && git push origin v<version> && gh release create ...`
`@claude-flow/cli`'s `prepublishOnly` runs `scripts/prepare-publish.mjs`, which:
- builds `@claude-flow/swarm` then the CLI itself via `tsc`
- copies the root `README.md` into the package
- bundles the `plugins/ruflo-metaharness` plugin into the tarball
- runs `generate-catalog-manifest.mjs`, **`sign-helpers.mjs`**, `verify-helpers.mjs` in sequence
`sign-helpers.mjs` needs a private Ed25519 key to sign `.claude/helpers/helpers.manifest.json`.
That key lives in GCP Secret Manager, project **`ruv-dev`**, secret `ruflo-helpers-signing-key`
— a **separate trust boundary from npm auth itself**, and one this repo has already been burned
by: on 2026-07-14, `gcloud secrets versions access latest --secret=ruflo-helpers-signing-key`
was run in a way that printed the PEM to stdout, which landed in a Claude Code tool-call
transcript. GCP secret v1 was destroyed and v2 rotated in (commit `0052b1b06` / PR #2673).
CLAUDE.md now mandates the secret only ever be redirected straight to a file, never allowed
to reach tool output.
### The trigger for this ADR
The user attempted to create an npm access token on npmjs.com for CI/CD use and hit npm's
own warning against it:
> "There are security risks with this option. For automation or CI/CD uses, please use
> Trusted Publishing instead."
npm's **Trusted Publishing** (general availability with npm CLI ≥ 11.5.1, OIDC-based, modeled
on PyPI's Trusted Publishers) lets a CI job authenticate to the npm registry with a
short-lived token minted by exchanging the CI provider's OIDC identity token — no `NPM_TOKEN`
secret stored anywhere, nothing to leak, nothing to rotate. Currently supported: GitHub Actions
(GA); GitLab CI (beta).
This is the same shape of fix this repo already applied elsewhere: `meta-llm`'s Cloud Run
deploy uses Workload Identity Federation (`GCP_WIF_PROVIDER` + `GCP_DEPLOY_SA`, no static
service-account JSON key) instead of a long-lived credential. Trusted Publishing is the npm
-registry equivalent of that same pattern.
## Decision
Adopt npm Trusted Publishing via GitHub Actions OIDC for all three packages, and fetch the
helpers-signing secret in CI via GCP Workload Identity Federation rather than a static
service-account key — so **no long-lived secret of either kind (npm or GCP) is stored in
GitHub Actions secrets** for this workflow.
### 1. npmjs.com configuration (one-time, per package)
For each of `@claude-flow/cli`, `claude-flow`, `ruflo`: package Settings → **Trusted
Publisher** → add GitHub Actions publisher scoped to:
- Organization/repo: `ruvnet/ruflo`
- Workflow filename: `.github/workflows/npm-publish.yml`
- Environment: `npm-publish` (see below — required reviewers preserve the "a human approves
this" property the manual process has today)
### 2. GCP Workload Identity Federation (one-time)
Create a dedicated WIF pool + provider trusting GitHub's OIDC issuer, scoped to this repo,
and an IAM binding granting **only** `roles/secretmanager.secretAccessor` on
`projects/ruv-dev/secrets/ruflo-helpers-signing-key` — not project-wide, not org-wide. This
is a new, narrower principal; do not reuse `meta-llm`'s `GCP_DEPLOY_SA` binding (different
blast radius, different purpose).
### 3. New workflow: `.github/workflows/npm-publish.yml`
- **Trigger**: `workflow_dispatch` only (inputs: `version`, `bump` type). Not on tag-push,
not on merge-to-main — the semver bump is a judgment call per CLAUDE.md's PATCH/MINOR/MAJOR
discipline, so a human still decides *when* and *what* to publish; this workflow only
automates the *mechanics* once that decision is made.
- **Permissions**: `id-token: write` (OIDC for both npm and GCP WIF), `contents: write`
(tag + release).
- **Environment**: `npm-publish`, with required reviewers configured in repo settings —
this is the workflow-level equivalent of "an operator is at the keyboard."
- **Steps** (single job, `ubuntu-latest`):
1. `actions/checkout@v4`
2. `actions/setup-node@v4` — pin `node-version: 20`, `registry-url: https://registry.npmjs.org`;
confirm npm ≥ 11.5.1 (`npm install -g npm@latest` if the bundled version is older —
Trusted Publishing silently falls back to failing auth on older npm, so pin explicitly,
don't assume)
3. `google-github-actions/auth@v2` with `workload_identity_provider` + the dedicated
service account — exports credentials for the next step without ever materializing a
JSON key file
4. Fetch the signing secret via the authenticated client library / `gcloud` **with output
redirected straight into env**, never through a step that could echo it:
`RUFLO_HELPERS_SIGNING_SECRET=ruflo-helpers-signing-key RUFLO_HELPERS_SIGNING_PROJECT=ruv-dev`
— reuses the existing env-var contract `sign-helpers.mjs` already supports; no code
change needed in `prepare-publish.mjs` or `sign-helpers.mjs`.
5. Bump version in all three `package.json`s (`npm version <input> --no-git-tag-version`),
in the mandated order: `@claude-flow/cli` → `claude-flow` (root) → `ruflo`.
6. `npm publish --provenance` for `@claude-flow/cli` — Trusted Publishing activates
automatically once npm detects the OIDC context and a matching registered publisher;
no `NODE_AUTH_TOKEN` anywhere in the workflow. `--provenance` is free once CI is doing
the publish and produces a public Sigstore-signed build attestation — a supply-chain
improvement beyond what this ADR was originally asked to solve.
7. Repeat step 6 for `claude-flow` (root) and `ruflo`.
8. `npm dist-tag add` × 9 (3 packages × `latest`/`alpha`/`v3alpha`) — script this as a small
loop rather than 9 literal lines.
9. Verification: `npm view <pkg>@latest version` for all three, assert equality with the
input version and with each other — same check the manual runbook already performs,
now enforced as a CI gate instead of an eyeballed step.
10. `git tag v<version> main`, `git push origin v<version>`, `gh release create` — using
the workflow's default `GITHUB_TOKEN` (already scoped by `contents: write`), no new
secret needed.
### 4. Manual fallback retained
The existing local/manual runbook in CLAUDE.md stays documented as a fallback for at least
one full release cycle after this workflow is proven, and permanently as a break-glass path
for whenever the workflow itself is broken (e.g., mid-migration on a workflow-filename
rename — see Consequences).
## Alternatives Considered
- **Keep manual publishing, just rotate npm tokens more often.** Rejected — doesn't remove
the standing secret, doesn't remove the operator-workstation risk, and doesn't touch the
actual failure class that already bit this repo (a secret transiting somewhere it can be
captured — see the 2026-07-14 incident, which was a GCP secret, not npm, but the exact same
shape of mistake).
- **Classic "Automation" npm access token as a `NPM_TOKEN` GitHub secret.** Rejected — this is
precisely the option npm's own UI is warning against for CI/CD use; it's long-lived, must be
manually rotated in two places (npmjs.com and GitHub) on any suspected compromise, and has
no expiry unless one is manually set.
- **Fine-grained/granular npm tokens with an expiry date.** Better than an automation token,
but still a standing secret with a live blast radius until it expires. Trusted Publishing has
zero standing secret on the npm-auth leg — nothing to steal between publishes.
- **Auto-publish on every merge to `main`.** Rejected — this repo's release cadence is
deliberate (semver-bump judgment call, hand-written GitHub release notes, sometimes a linked
gist). Automatic-on-merge would remove a decision point this repo currently wants to keep.
`workflow_dispatch` preserves it.
- **Static GCP service-account JSON key for the signing-secret fetch, stored as a GitHub
secret.** Rejected for the same reason as the npm automation token — it's the exact class of
standing credential this ADR exists to eliminate, and this repo already has WIF precedent
(`meta-llm`'s CD) to reuse the pattern instead.
## Consequences
### Positive
- Eliminates the standing `NPM_TOKEN` secret class entirely for CI — a short-lived, per-run
token is minted via OIDC exchange and never stored.
- `--provenance` becomes essentially free to add once CI does the publish, improving the
supply-chain trust signal for all three packages beyond what this ADR set out to fix.
- Publish becomes auditable via the Actions run log instead of depending on a specific
operator's local shell state — no more dependency on the `~/.ruflo/helpers-signing.key`
local-file workaround documented for the 2026-07-14 incident.
- Removes the Windows `prepublishOnly` shell-quirk risk class for CI-driven publishes, since
the runner is always `ubuntu-latest` — the documented Git-Bash-manual-steps fallback stops
being relevant for this path (it remains relevant only for someone still publishing locally
from Windows, which is now explicitly the fallback path, not the primary one).
### Negative / risks
- **New coupling between the workflow file and npmjs.com's config.** If `.github/workflows/npm-publish.yml`
is ever renamed, moved, or the repo is renamed/transferred, npmjs.com's Trusted Publisher
entries must be updated in lockstep for all three packages or every publish starts failing
auth — an easy step to forget during an unrelated refactor. Document this explicitly in
CLAUDE.md alongside the workflow.
- **New GCP infrastructure to maintain.** The WIF pool/provider + scoped IAM binding is new,
one-time setup that didn't exist before; if mis-scoped (e.g., granted at project level
instead of on the single secret), it *widens* CI's blast radius relative to today's
fully-manual, single-operator flow. Least-privilege scoping (Decision §2) is load-bearing,
not optional.
- **The `npm-publish` environment's required-reviewers setting is the only thing standing in**
for "a person is running this by hand." If that protection rule is ever weakened or removed,
a compromised or malicious workflow-file change could self-approve a publish. Mitigate with
branch protection on changes to `.github/workflows/npm-publish.yml` itself, in addition to
the environment gate.
- Does not address GitHub Actions runner supply-chain trust — an accepted risk shared with
every other GH-Actions-based CD path already in this repo (e.g., `meta-llm`'s existing CD).
## Security Notes
- npm's Trusted Publisher config is inherently scoped to (org, repo, workflow file, optional
environment) — using the `npm-publish` environment with required reviewers is what makes
this **at least as strong as** today's "an operator runs this by hand" gate, not weaker.
- The GCP WIF principal must be scoped to `roles/secretmanager.secretAccessor` on exactly
`projects/ruv-dev/secrets/ruflo-helpers-signing-key` — verify this at implementation time
rather than copying whatever scope `GCP_DEPLOY_SA` currently has for `meta-llm`, which is a
different principal for a different purpose.
- Never let the fetched secret value reach a logged command or `stdout`. `sign-helpers.mjs`
already consumes it via env vars, not by reading a printed value, so no code change is
needed there — but the CI step that populates those env vars must not itself echo, `cat`,
or otherwise surface the value. This is exactly the mistake that caused the 2026-07-14 leak
(`gcloud secrets versions access` writing to stdout, captured into a tool-call transcript);
an authenticated-client-library or `--out-file`-style fetch avoids that failure mode by
construction.
## Implementation Plan (phased)
- **Phase 0 — Registration.** Register the Trusted Publisher on npmjs.com for all three
packages, pointing at the not-yet-created workflow path. Confirm/pin npm ≥ 11.5.1 on the
runner image.
- **Phase 1 — GCP WIF plumbing.** Create the pool/provider + scoped IAM binding. Validate with
a dry-run workflow that only authenticates and fetches the secret (no publish step yet).
- **Phase 2 — Workflow authoring.** Write `.github/workflows/npm-publish.yml` per Decision §3.
Gate on `workflow_dispatch` + the `npm-publish` environment with required reviewers.
- **Phase 3 — Dry run.** Exercise `npm publish --dry-run` in CI to validate the full
build → sign → pack pipeline without touching the registry. Do **not** publish a real
prerelease version to test — CLAUDE.md explicitly forbids pre-release tags (`-alpha.N` etc.)
outside an explicit user request, and `--dry-run` covers everything except the actual
registry write.
- **Phase 4 — First supervised live run.** Execute against a real, small patch bump under
direct supervision; verify the full runbook (dist-tags, `npm view`, GitHub release) matches
what the manual process would have produced.
- **Phase 5 — Promote to primary.** After one full clean release cycle, update the
"Publishing to npm" section of root `CLAUDE.md` to point at this workflow as the primary
path, keeping the manual steps as documented fallback rather than deleting them.
## Open Questions
- Should a tag-push (`v*`) trigger be added alongside `workflow_dispatch` once the flow is
trusted, or should it stay manual-trigger-only indefinitely? (Leaning: stay manual — the
semver-bump decision in CLAUDE.md is explicitly a judgment call, not a mechanical one.)
- Should the GCP WIF principal for this workflow be shared with any future CI needs for the
same secret, or does every consumer get its own dedicated, narrowly-scoped principal?
(Leaning: dedicated per consumer, least-privilege, consistent with why this ADR rejects a
shared static key in the first place.)
- GitLab CI Trusted Publishing is in beta and irrelevant today (this repo is GitHub-hosted),
but worth a one-line note here in case that ever changes.
## References
- npm CLI Trusted Publishing (GA with npm ≥ 11.5.1, OIDC-based; GitHub Actions supported,
GitLab CI in beta)
- Root `CLAUDE.md` → "Publishing to npm" (existing manual runbook, dist-tag matrix, signing-key
handling, Windows `prepublishOnly` caveat)
- Root `CLAUDE.md` → Cognitum Platform deployment table — existing WIF precedent
(`meta-llm`'s `GCP_WIF_PROVIDER` + `GCP_DEPLOY_SA`)
- Root `CLAUDE.md` → 2026-07-14 helpers-signing-key leak incident and its "never let a secret
reach tool output" rule — the direct motivating precedent for the GCP-fetch design in this
ADR
- `v3/@claude-flow/cli/scripts/prepare-publish.mjs`, `scripts/sign-helpers.mjs` — existing
`prepublishOnly` chain this ADR's CI workflow must reproduce unchanged
@@ -0,0 +1,245 @@
# ADR-379 — Optional Context/Session/Week Usage Segments and Extra Statusline Lines
- **Status**: Proposed
- **Date**: 2026-07-30
- **Related**: ADR-301 (Promotional Status Surface — the existing rotating Line 3 mechanism this
ADR builds alongside), ADR-311 (funnel analytics/click-redirect, referenced by the promo row
this ADR does not modify)
- **Reference artifact**: `/home/ruvultra/projects/ruflo/image.png` — a screenshot of a
statusline (project `july-genesis`, **not** a ruflo repo) showing a header row with
`Opus 5 · eff:xhigh · Projects/july-genesis · main ✚19 · 24%/(3M) · session 0% · week 51% ·
$49.48 · 12:27pm PT/3:27pm ET · /rc`, followed by four rotating tip/quote lines (a `.gitignore`
tip, a "what should I do next?" prompt, a "staging" definition, and a John Doerr quote).
## Context
### What ruflo's statusline already does
`.claude/helpers/statusline.cjs` renders a deliberately **3-line** layout, per its own doc
comment (lines 802–806):
```
Line 1 — Header (RuFlo version · git · model · timing · context · cost)
Line 2 — Compressed ops (Swarm · Hooks · 🧠 · 💾 · Health)
Line 3 — Promo / disclosure row (funnel surface, ADR-301)
```
That cap is load-bearing, not arbitrary — the comment explains it's sized to "fit Claude Code's
visible statusline area (line 4+ gets replaced by the system guidance / input prompt line)."
Line 3 already rotates through four content kinds (`disclosure`, `promotional`, `educational`,
`insight` — see `getPromoRow()`), server-selected by the CLI's funnel/promo subsystem on a 20s
rotation slot. This is functionally similar to the tip lines in the reference screenshot, but
today it is **one line at a time**, not several simultaneous lines.
The file already has a working precedent for **optional segments**: `CONFIG.hideCost` /
`RUFLO_STATUSLINE_HIDE_COST` lets a user remove the cost segment from Line 1 without touching
code, and `RUFLO_FUNNEL=0` disables Line 3 entirely. Context usage (`ctxInfo.usedPct`), by
contrast, has **no** hide toggle today — it renders unconditionally whenever
`getContextFromStdin()` returns a positive percentage.
### What ruflo's statusline does not have
Two segments visible in the reference screenshot have no equivalent in this file at all:
- **`session 0%` / `week 51%`** — usage against Anthropic's 5-hour and 7-day (weekly) rate
limits. `getStdinData()` currently reads `model`, `context_window`, and `cost` from the
Claude Code stdin payload; nothing here reads or expects a session/week usage field.
- **`eff:xhigh`** — the active reasoning-effort level. Also absent from `getStdinData()` and
from every function in this file.
### Grounding check (per the RuvNet Brain requirement to verify before asserting)
Before proposing a schema, `search_ruvnet` was queried against the decompiled Claude Code
research corpus (`ruvector/docs/research/claude-code-rvsource/07-context-and-session-management.md`,
built from reverse-engineering the actual Claude Code CLI) and against `open-claude-code`'s
ADR-002 fidelity gap-analysis. **Neither documents a session-usage or week-usage stdin field.**
That research doc is detailed on context windows, compaction, session persistence, and prompt
caching, but has no mention of a 5-hour/weekly rate-limit percentage being exposed to
statusline scripts. This does not prove the field doesn't exist in a Claude Code version newer
than that research pass — it means **this ADR cannot assume a concrete field name or shape**,
and implementation must start with a verification spike rather than a schema guess (see
Decision §2 and Implementation Plan Phase 0).
## Decision
Add each of the reference screenshot's extra segments as an **independently optional** piece
of the existing 3-line design, following the `hideCost` precedent exactly (additive `CONFIG`
field + env var, never a required behavior change), rather than redesigning the layout. Where
the underlying data source is unconfirmed (session %, week %, effort), default the segment to
**off** until a spike confirms how to source it — this repo does not ship guessed/placeholder
data in a statusline that people trust for real numbers (cost, security status, etc. are all
sourced from real state elsewhere in this file; a fabricated usage percentage would break that
trust).
### 1. Context % — make it hideable (low-risk, data already exists)
Add `CONFIG.hideContext` / `RUFLO_STATUSLINE_HIDE_CONTEXT`, mirroring `hideCost` exactly:
```js
hideContext: /^(1|true|yes|on)$/i.test(process.env.RUFLO_STATUSLINE_HIDE_CONTEXT || ''),
```
Guard the existing render block (`statusline.cjs:827`) with `if (!CONFIG.hideContext && ctxInfo
&& ctxInfo.usedPct > 0)`. Default **shown** (`false`), so existing behavior is unchanged for
everyone who doesn't set the var — this is a pure opt-out addition.
### 2. Session % and week % — spike first, ship behind a flag, default off
These cannot be implemented today without knowing where the numbers come from. Phase 0
(Implementation Plan) determines which of the following is true, in this preference order:
1. **Claude Code's stdin payload already includes it** under some field this file hasn't been
updated to read (most likely candidate names to probe: `data.usage`, `data.rate_limits`,
`data.session_usage` / `data.week_usage` — verify against a live payload dump, don't guess
in code).
2. **Claude Code exposes it via a separate mechanism** (e.g. a `/usage`-equivalent CLI
subcommand, or a file under `~/.claude/`) that this script would need to shell out to or
read, similar to how `getPkgVersion()` already probes multiple candidate paths.
3. **Neither exists yet**, and the numbers in the reference screenshot come from a different,
non-stock statusline tool — in which case this repo computes its own approximation from
local session transcripts (`~/.claude/projects/<hash>/*.jsonl`, already known to exist per
the Session Persistence research cited above) against Anthropic's published 5-hour/weekly
window semantics. This is the most expensive path and should only be taken if 1 and 2 are
both dead ends.
Once a real source is confirmed, add `getSessionUsageFromStdin()` / `getWeekUsageFromStdin()`
(or the equivalent for whichever source won the spike) following the exact null-safe pattern
`getContextFromStdin()` already uses — return `null` on any missing/malformed data, never
throw. Render behind two new flags, **defaulting to hidden**:
```js
hideSessionUsage: !/^(1|true|yes|on)$/i.test(process.env.RUFLO_STATUSLINE_SHOW_SESSION_USAGE || ''),
hideWeekUsage: !/^(1|true|yes|on)$/i.test(process.env.RUFLO_STATUSLINE_SHOW_WEEK_USAGE || ''),
```
Note the inverted polarity versus `hideCost`/`hideContext` — those hide something known-good by
default-on; these are opt-**in** (default-off) because, unlike cost/context, there is no
existing confirmed data path, so shipping "on by default" risks showing a stale/wrong number
until the spike lands and the feature is validated end-to-end for at least one release.
Color threshold convention should match the existing context-percentage bands
(`>=90` red, `>=70` yellow, else green — `statusline.cjs:828`) for visual consistency across
all three usage segments.
### 3. Reasoning effort — same spike-first, default-off treatment as session/week
`eff:xhigh` needs its own verification: does Claude Code's stdin payload carry the active
effort level anywhere under `data.model` or a sibling field? If yes, add
`getEffortFromStdin()` next to `getModelFromStdin()`; render behind
`RUFLO_STATUSLINE_SHOW_EFFORT` (default off, same reasoning as §2). If the effort level is not
in the stdin payload at all, this segment is descoped from this ADR — do not infer it from
environment variables or config files that could drift from the actual runtime effort.
### 4. "Other optional lines" — respect the documented 3-line invariant by default
The reference screenshot shows **four** simultaneous tip/quote lines, which conflicts with this
file's own documented constraint that line 4+ gets silently replaced by Claude Code's system
UI. Do not change the default line count. Instead:
- Keep Line 3 (the existing single rotating promo/tip/insight row) exactly as-is — no change.
- Add an **opt-in, explicit-risk** `RUFLO_STATUSLINE_EXTRA_TIP_LINES` (integer, default `0`,
clamp to `0–3`) that appends up to N additional lines below Line 3, sourced from a small,
local, static `TIP_POOL` array shipped in this file (git/workflow tips and quotes, in the
spirit of the reference screenshot's `.gitignore` / "staging" / John Doerr lines) — **not**
the funnel/promo server payload, since these are meant to be plain educational/static content,
not promotional or personalized.
- Document, next to the env var and in this ADR, that setting it above `0` means some or all of
those lines may be visually overwritten by Claude Code's own input-prompt UI depending on
terminal height and Claude Code version — this is a known, accepted tradeoff for users who
explicitly opt in, not a bug to chase.
- Rotate through `TIP_POOL` using the same 20s-slot cadence already established for Line 3
(`ROTATION_SLOT_MS`, referenced in the existing comment at `statusline.cjs:61-69`) so the
extra lines feel consistent with the existing rotation rather than introducing a second timing
system.
### 5. Implementation discipline (applies to every new segment above)
- Every new field is additive to `CONFIG`, env-var gated, and defaults to preserving current
output for anyone who sets nothing.
- Every new data getter follows `getContextFromStdin()`'s null-safe shape: return `null`/`false`
on anything missing or malformed, never throw — consistent with `getPromoRow()`'s own
`try { … } catch { return null; }` wrapper, which exists specifically so "the promo row must
never break the statusline" (statusline.cjs:1037-1039). The same invariant applies to every
segment added by this ADR.
## Alternatives Considered
- **Unconditionally add all four screenshot segments, always on.** Rejected — breaks the
documented "fits Claude Code's visible area" invariant for every user by default, and ships
session%/week% numbers before their data source is even confirmed to exist.
- **A separate "verbose statusline" mode as a whole alternate script/file.** Rejected — this
file already has a working single-source-of-truth CONFIG-toggle model (`hideCost`,
`identityMode`, `RUFLO_FUNNEL`); forking a second file duplicates the git/cost/security
plumbing this one already does carefully (single execSync call, 2s timeouts, shared cache)
for no real benefit.
- **Guess a stdin field name for session/week usage now and ship it.** Rejected — the grounding
check found no confirmed source for this data. Shipping a guessed field name that silently
returns `null` forever (because the real field, if any, has a different name) would look like
a shipped feature that quietly never works — worse than not having it, because it's not
discoverable as broken.
## Consequences
### Positive
- Context-hide toggle is a same-day, zero-risk addition — pure opt-out, data already exists.
- Session/week/effort segments, once their source is confirmed, slot into the exact pattern
every other optional segment in this file already uses — no new architecture, no new config
surface shape to learn.
- The extra-tip-lines feature gives users who want the reference screenshot's denser look a way
to get it, without silently changing the default experience for everyone else.
### Negative / risks
- **The single biggest risk is Decision §2/§3's open question**: if Claude Code does not expose
session/week usage or effort level to statusline scripts at all, those segments simply cannot
ship as designed, and the fallback (computing usage windows from local transcripts) is
materially more work and carries its own accuracy risk (Anthropic's exact 5-hour/weekly
window boundaries and reset semantics would need to be reverse-engineered, not just read).
- More `CONFIG`/env-var surface area to document and keep consistent (inverted default polarity
between the "hide a known-good thing" group and the "opt into an unconfirmed thing" group is
a deliberate but easy-to-forget asymmetry — call it out in the code comment, not just here).
- `RUFLO_STATUSLINE_EXTRA_TIP_LINES` users may see their own terminal visually clip/overwrite
the extra lines depending on Claude Code version and terminal height — accepted tradeoff, but
worth a one-line note in `ruflo doctor` output or docs so it isn't reported as a bug.
## Implementation Plan (phased)
- **Phase 0 — Spike (no shipped code change).** Dump a real Claude Code stdin payload
(`cat` what this script actually receives on stdin during a live session) on a current Claude
Code version and grep it for anything resembling session/week usage or an effort field.
Cross-check against Claude Code's own `/usage`-style output if one exists. This phase's output
is a yes/no answer per segment plus, if yes, the exact field path — not code.
- **Phase 1 — Context hide toggle.** Ship `RUFLO_STATUSLINE_HIDE_CONTEXT` per Decision §1.
Independent of Phase 0; can ship immediately.
- **Phase 2 — Session/week/effort (conditional on Phase 0).** If Phase 0 found a real field,
implement the three getters + render blocks behind their default-off flags. If Phase 0 found
nothing, this phase either takes the local-transcript-computation fallback (Decision §2 item
3) as a separate, explicitly-scoped follow-up ADR, or is dropped.
- **Phase 3 — Extra tip lines.** Ship `TIP_POOL` + `RUFLO_STATUSLINE_EXTRA_TIP_LINES`,
independent of Phase 2.
- **Phase 4 — Docs.** Note all new env vars alongside the existing `RUFLO_STATUSLINE_*` /
`RUFLO_FUNNEL` docs (wherever those are currently documented for end users), including the
visible-area tradeoff warning for Phase 3.
## Open Questions
- Does Claude Code's stdin payload carry session/week usage or effort level under any field
today, and if so, what's the exact shape? (Blocks Phase 2 — see Phase 0.)
- If no stdin source exists, is a locally-computed approximation from `~/.claude/projects/*/*.jsonl`
accurate enough to be trustworthy, or does it risk showing a confidently-wrong number (arguably
worse than showing nothing)?
- Should the extra tip pool (Decision §4) ever be user-extensible (e.g. a project-local
`.claude/statusline-tips.json`), or does that add more surface area than the feature is worth?
## References
- `.claude/helpers/statusline.cjs` — existing 3-line design doc comment (lines 802–806),
`CONFIG.hideCost` precedent (lines 34–42), `getContextFromStdin()` (lines 598–604),
`getPromoRow()` and its rotation-slot comment (lines 61–69, 937–1039)
- ADR-301 — Promotional Status Surface for CLI Runtime (the existing Line 3 mechanism)
- `ruvector/docs/research/claude-code-rvsource/07-context-and-session-management.md` —
decompiled Claude Code research; cited as the negative-evidence source for "no documented
session/week usage stdin field" in the Context section above
- `open-claude-code/docs/adr/ADR-002-path-to-100-percent.md` — independent Claude Code
reimplementation's own feature-gap analysis, also silent on a session/week usage field
@@ -0,0 +1,128 @@
# ADR-380 — AGNTCY/Outshift Runtime Integration: SLIM Transport, CASA Enforcement, IOC Coordination Events
- **Status**: Proposed
- **Date**: 2026-07-30
- **Related**: ADR-150 (MetaHarness integration surfaces — the optional/removable-augmentation precedent this ADR follows), ADR-321 (metaharness hard-dependency exception — explicitly **not** followed here, see Decision §1), Cognitum funnel/tenancy ADRs (ADR-301/305/306 — tenant field alignment)
- **Companion**: metaharness repo ADR-240 (`agent/adr-237-agntcy-outshift-integration` branch) — AGNTCY identity, OASF export, semantic observability. That ADR covers what MetaHarness produces at build/manifest time; this ADR covers what RuFlo does with it at runtime. Neither is complete without the other; they are numbered and shipped as a pair.
- **Prompted by**: a strategic brief evaluating Cisco Outshift's AGNTCY / Internet of Cognition ecosystem (Mycelium is one coordination implementation inside that broader IoC program) as complementary, not competitive, infrastructure:
> AGNTCY and Outshift define the agent network. MetaHarness builds and evolves the agents. RuFlo executes and coordinates them. Meta LLM governs inference, cost, tenancy, and safety. RuVector supplies local memory and semantic state.
## Context
A repo-wide check found **zero existing references** to AGNTCY, Outshift, OASF, CASA, SLIM, or Mycelium anywhere in this codebase — genuinely greenfield, not a gap in an existing effort.
Two of the "clean positioning" stack's five legs are already real, shipping components and require no new work to be true:
- **"Meta LLM governs inference, cost, tenancy, and safety"** — already this repo's documented role for the meta-llm gateway (`metallm_delegate`/`metallm_ask`, root `CLAUDE.md`'s "Gateway-Delegated Development" section): cost-tier routing, metered spend, cwd-sandboxed agentic sub-tasks.
- **"RuVector supplies local memory and semantic state"** — already shipping (`ruflo-ruvector` plugin, `vector-engineer` agent, HNSW/RaBitQ-backed AgentDB memory).
This ADR is scoped to the two genuinely new legs RuFlo itself needs: AGNTCY-identified, SLIM-transported coordination, and CASA-enforced authorization — plus optional IOC Layer 9 coordination events layered on top of existing swarm/hive-mind orchestration.
## Decision
### 1. Optional, removable augmentation — follows ADR-150, not ADR-321
Unlike metaharness (ADR-321's hard-dependency exception, justified because metaharness is this project's own sibling tooling with an established track record), AGNTCY/SLIM/CASA/IOC are early-stage, externally governed (Cisco Outshift-led, Linux Foundation) protocols this project does not control. Every new package this ADR introduces — a TS `@claude-flow/agntcy` package and/or a Rust `ruflo-agntcy` crate (§6) — MUST live in `optionalDependencies`, MUST degrade gracefully (`MODULE_NOT_FOUND` / connection-refused → fall back to today's local transport and existing tool-authorization model), and MUST pass a "works without AGNTCY installed" smoke test — mirroring ADR-150's four architectural-constraint rules verbatim: removable, optional-only, graceful degradation, CI-gated.
### 2. SLIM transport for distributed coordination — local transport stays the default
Add three new `ruflo` CLI verbs:
```text
ruflo transport use slim
ruflo agent publish
ruflo swarm join cognitum/research/security
```
`ruflo transport use slim` switches the active swarm/hive-mind transport from today's in-process/local-hooks routing to SLIM (Rust, secure messaging for MCP/A2A, hierarchical routing, reliable delivery, group membership, MLS end-to-end encryption; JWT/mTLS/SPIFFE/WebSocket/Unix-socket auth) for agents coordinating across hosts or across a Cognitum tenant boundary.
**Keep the current local transport as the default for single-host swarms.** The originating brief is explicit that SLIM infrastructure adds unnecessary operational cost there, and this repo already has a working local coordination path (`swarm_init`/`hive-mind_*` MCP tools, the hierarchical-mesh anti-drift topology) that must not regress in the common case.
`ruflo agent publish` emits the AGNTCY-identified, OASF-described agent record (produced build-time by metaharness ADR-240 §2.1/2.2) to the configured Directory. `ruflo swarm join <namespace>` joins a SLIM group-membership channel scoped to a Cognitum tenant/project namespace.
Estimated effort: 15–25 days.
### 3. CASA intent-scoped authorization enforcement
MetaHarness (companion ADR-240 §4) compiles a user's objective into a bounded authority envelope. RuFlo is where that envelope is **enforced** at every tool invocation:
```json
{
"objective": "review repository security",
"allow": ["repository.read", "tests.execute"],
"deny": ["git.push", "secret.export", "deployment.create"],
"budget_usd": 8,
"expires_at": "2026-07-30T22:00:00Z"
}
```
- **Meta LLM** enforces `budget_usd` and provider policy — already its documented role; the new work is wiring this envelope's budget field into the existing `metallm_delegate`/`metallm_ask` cost-governance path, not inventing new budget machinery.
- **CASA** enforces `allow`/`deny`/`expires_at` at the network/tool-authority layer — this is new: a deterministic gate in front of every MCP tool call and every `Agent`/`Task` dispatch, checked against the envelope *before* dispatch, never after.
- **RuFlo logs every decision into signed receipts** — extends this repo's own signed-manifest precedent (`ruflo-core:witness-curator`, the ADR-103-style fix-attestation model) to per-invocation authorization decisions, not just release-time fix state.
**The single non-negotiable design constraint in this entire integration**, restated plainly because it is also the brief's explicitly named biggest failure mode: the *translation* of intent into the envelope may use an LLM (that's MetaHarness's job, companion ADR-240 §4). *Enforcement* must never ask a model whether an action is permitted at invocation time — it checks a bounded schema (explicit resource strings, explicit deny list, numeric budget, expiry timestamp) with deterministic code, deny-by-default. No code path in this ADR's implementation may let an LLM's runtime judgment substitute for the compiled envelope's `allow`/`deny` lists. This must be verified with explicit bypass-attempt tests, not just translation-quality tests, before enabling CASA enforcement by default for any tenant.
Estimated effort: 15–25 days (shared with companion ADR-240's compiler half: schema + translation-quality tests there; wiring + enforcement + bypass-attempt tests + receipts here).
### 4. IOC Layer 9 cognition envelopes — optional coordination events, not a replacement
Support Cisco's semantic protocols above MCP/A2A — Semantic Information Exchange, Cognition and Interoperability, Semantic Alignment Broadcast, Team Formation via Polling — as **optional RuFlo coordination events**, layered on top of existing swarm/hive-mind coordination (`hive-mind_broadcast`, `hive-mind_consensus`, `coordination_consensus`). This never replaces RuFlo's own orchestration — it is additive, consistent with this repo's existing anti-drift preference for RuFlo-owned hierarchical coordination as the default.
The schemas are Apache-2.0 with existing Python and Go bindings. Ship a native Rust implementation as a genuine upstream contribution to `outshift-open/ioc-protocols-models`, not a private fork — the same posture this project already takes toward other upstream ecosystems it depends on.
Estimated effort: 10–15 days.
### 5. AGNTCY semantic observability — the runtime-only spans
Map RuFlo spans and Flywheel-style receipts onto AGNTCY's OTel extensions (`agent.identity`, `agent.capability`, `agent.intent`, `agent.parent`, `coordination.episode`, `authorization.decision`, `model.route`, `memory.provenance`, `evaluation.score`, `receipt.hash`). RuFlo owns `coordination.episode` and `authorization.decision` specifically — the two attributes companion ADR-240 §2.3 explicitly defers here, since both only exist once SLIM/CASA are active at runtime. Wire this through the existing `ruflo-observability` plugin (`observe-trace`/`observe-metrics` skills) rather than inventing a second tracing pipeline.
Estimated effort: 5–8 days (shared line item with ADR-240 §2.3 — RuFlo emits the two runtime-only attributes; MetaHarness emits the rest).
### 6. `@claude-flow/agntcy` package and Rust `ruflo agntcy` crate
Mirrors metaharness's own sibling-package pattern (`@metaharness/darwin`, `@metaharness/redblue`) and this repo's own plugin-package convention (`plugins/ruflo-*`): an isolated, optional package rather than code folded into `@claude-flow/cli` directly, so §1's removability constraint has a clean boundary to enforce against.
The Rust crate specifically targets SLIM (already Rust) and the native IOC Layer 9 implementation (§4) — natural Rust surfaces, not TypeScript. This is a good technical fit independent of any workstation-level preference, given SLIM's own implementation language; existing Rust CI plumbing in this repo (`.github/workflows/federation-peer-rust.yml`) is a candidate to extend rather than standing up a second Rust CI pipeline from scratch.
## Consequences
### Positive
- Two of the "clean positioning" stack's five legs (Meta LLM cost/tenancy governance, RuVector local memory) are already real and require zero new work to be true — this ADR only has to build the two genuinely new legs (AGNTCY-identified execution/coordination, CASA-enforced authority) plus the optional IOC layer.
- SLIM's opt-in design (§2) means single-host swarms — the overwhelming common case today — see zero behavior change and zero new operational cost.
- Extending the existing witness/receipt-signing precedent to per-invocation CASA decisions reuses a pattern this repo already trusts, instead of inventing a second audit-log format.
### Negative / risks
- CASA enforcement is a new mandatory gate in front of every tool dispatch once enabled — a bug here is a security regression, not a feature bug. It needs the "no LLM-in-the-enforcement-loop" test discipline from §3 verified by explicit bypass-attempt tests before shipping enabled-by-default for any tenant.
- SLIM introduces a new Rust dependency surface and a new network topology (group membership, MLS encryption) this repo doesn't operate today — a real operational learning curve, mitigated by keeping it strictly opt-in per §1/§2.
- AGNTCY/Outshift ecosystem immaturity risk, same caveat as companion ADR-240: treat every new package here as optional and versioned, never load-bearing, until the upstream specs stabilize past 1.0.
## Alternatives Considered
- **Building CASA-equivalent enforcement as a bespoke ruflo-only authorization layer instead of adopting CASA.** Rejected — this repo already has `claims_*`/AuthScope machinery (the `claims-authorizer` agent); the intent-scoped, budget-and-expiry-bearing envelope CASA describes is a genuine capability gap that machinery doesn't currently cover, and building a second bespoke scheme when an emerging standard exists trades a small integration cost now for a larger reconciliation cost later.
- **Making SLIM the default transport immediately.** Rejected per the brief's own explicit guidance — unnecessary operational cost for single-host coordination, which is most of this repo's actual usage today.
- **Treating IOC Layer 9 as a replacement for RuFlo's own hive-mind/swarm orchestration.** Rejected — explicitly scoped as optional coordination *events* layered on top, consistent with this repo's existing anti-drift preference for RuFlo-owned hierarchical coordination as the default.
## Acceptance Test
Shared with companion ADR-240: generate a MetaHarness agent, publish its signed OASF record (ADR-240), discover it from a second network (ADR-240, via Directory), verify its AGNTCY identity (ADR-240 §2.1), invoke it through SLIM (this ADR §2), reject one out-of-scope tool call through CASA (this ADR §3, using ADR-240 §4's compiled envelope), and reconstruct the complete run from OpenTelemetry spans and Flywheel receipts (this ADR §5 + ADR-240 §2.3).
## Open Questions
- Should `ruflo transport use slim` be a per-swarm setting or a global session default? (Leaning: per-swarm, consistent with per-tenant SLIM group membership.)
- Does the existing `claims_*` AuthScope machinery get subsumed by CASA envelopes over time, or do they stay parallel (claims = internal agent-to-agent authorization, CASA = user-intent-to-network authorization)? Worth its own follow-up ADR once §3 ships and the overlap (or lack of one) is concretely observable.
- Where does the Rust `ruflo agntcy` crate live — a new top-level package, or folded into the existing federation-peer-rust surface given the CI plumbing already exists there?
## References
- Cisco AGNTCY overview — https://outshift.cisco.com/the-internet-of-agents/agntcy
- AGNTCY Identity — https://github.com/agntcy/identity
- AGNTCY Directory — https://github.com/agntcy/dir
- AGNTCY Observe — https://github.com/agntcy/observe
- SLIM architecture — https://github.com/agntcy/slim
- Cisco CASA overview — https://outshift.cisco.com/blog/ai-ml/continuous-agentic-semantic-authorization-for-mas
- IOC protocol repository — https://github.com/outshift-open/ioc-protocols-models
- Companion: metaharness repo ADR-240 (`agent/adr-237-agntcy-outshift-integration` branch) — build-time half of this integration
- ADR-150 (`v3/docs/adr/ADR-150-metaharness-integration-surfaces.md`) — the optional/removable-augmentation precedent this ADR follows
+3
View File
@@ -95,6 +95,9 @@ All ADRs are located in [`/v3/implementation/adrs/`](../../implementation/adrs/)
| [ADR-375](ADR-375-dream-cycle-performance-agentperf-benchmark-mixture-of-agents.md) | Agentic Inference Benchmarking Standard + Mixture-of-Agents Test-Time Scaling | Proposed |
| [ADR-376](ADR-376-dream-cycle-intelligence-heterogeneous-ensemble-api.md) | Heterogeneous Agent Ensemble Composition API | Proposed |
| [ADR-377](ADR-377-agentdb-retrieval-security.md) | AgentDB Retrieval Security Layer | Proposed |
| [ADR-378](ADR-378-npm-trusted-publishing-cicd.md) | npm Trusted Publishing for CI/CD Release Automation | Proposed |
| [ADR-379](ADR-379-statusline-optional-usage-segments.md) | Optional Context/Session/Week Usage Segments and Extra Statusline Lines | Proposed |
| [ADR-380](ADR-380-agntcy-outshift-runtime-integration.md) | AGNTCY/Outshift Runtime Integration: SLIM Transport, CASA Enforcement, IOC Coordination Events | Proposed |
## Summary Documents