Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/cli_agent_orchestrator/cli/commands/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def memory():
"--scope",
type=click.Choice([s.value for s in MemoryScope], case_sensitive=False),
default=None,
help="Filter by scope (global, project, session, agent).",
help="Filter by scope (global, project, session, agent, federated).",
)
@click.option(
"--type",
Expand Down Expand Up @@ -179,7 +179,7 @@ def delete(key, scope, yes):
"--scope",
type=click.Choice([s.value for s in MemoryScope], case_sensitive=False),
required=True,
help="Scope to clear (required). One of: global, project, session, agent.",
help="Scope to clear (required). One of: global, project, session, agent, federated.",
)
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
def clear(scope, yes):
Expand Down
15 changes: 12 additions & 3 deletions src/cli_agent_orchestrator/mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1093,7 +1093,10 @@ async def memory_store(
content: str = Field(description="Memory content to store (markdown supported)"),
scope: str = Field(
default="project",
description='Memory scope: "global", "project", "session", or "agent"',
description=(
'Memory scope: "global", "project", "session", "agent", or '
'"federated" (machine-wide shared tier; rejects credentials)'
),
),
memory_type: str = Field(
default="project",
Expand Down Expand Up @@ -1153,7 +1156,10 @@ async def memory_recall(
),
scope: Optional[str] = Field(
default=None,
description='Filter by scope: "global", "project", "session", "agent". Omit to search all.',
description=(
'Filter by scope: "global", "project", "session", "agent", '
'"federated". Omit to search all.'
),
),
memory_type: Optional[str] = Field(
default=None,
Expand Down Expand Up @@ -1237,7 +1243,10 @@ async def memory_forget(
key: str = Field(description="Key of the memory to remove (e.g. 'prefer-pytest')"),
scope: str = Field(
default="project",
description='Scope of the memory to remove: "global", "project", "session", or "agent"',
description=(
'Scope of the memory to remove: "global", "project", "session", '
'"agent", or "federated"'
),
),
) -> Dict[str, Any]:
"""Remove a memory by key and scope.
Expand Down
3 changes: 2 additions & 1 deletion src/cli_agent_orchestrator/models/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class MemoryScope(str, Enum):
PROJECT = "project"
SESSION = "session"
AGENT = "agent"
FEDERATED = "federated"


class MemoryType(str, Enum):
Expand All @@ -71,7 +72,7 @@ class Memory(BaseModel):
id: str = Field(..., description="Unique memory identifier")
key: str = Field(..., description="Slug identifier, e.g. 'prefer-pytest'")
memory_type: str = Field(..., description="One of: user, feedback, project, reference")
scope: str = Field(..., description="One of: global, project, session, agent")
scope: str = Field(..., description="One of: global, project, session, agent, federated")
scope_id: Optional[str] = Field(None, description="Auto-resolved scope identifier")
file_path: str = Field(..., description="Path to wiki topic file")
tags: str = Field(default="", description="Comma-separated tags")
Expand Down
8 changes: 5 additions & 3 deletions src/cli_agent_orchestrator/services/cleanup_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ def cleanup_old_data():
"agent": None,
"project": 90,
"session": 14,
"federated": None,
}
PERMANENT_MEMORY_TYPES: frozenset[str] = frozenset({"user", "feedback"})

Expand Down Expand Up @@ -124,9 +125,10 @@ async def cleanup_expired_memories() -> None:
continue

# Extract scope_id from path: .../memory/{scope_id}/wiki/index.md
# "global" dir → scope_id=None, project hash dirs → scope_id=hash
# "global"/"federated" dirs → scope_id=None (flat, machine-wide),
# project hash dirs → scope_id=hash
project_dir_name = index_path.parent.parent.name
scope_id = None if project_dir_name == "global" else project_dir_name
scope_id = None if project_dir_name in ("global", "federated") else project_dir_name

for entry in expired_entries:
try:
Expand Down Expand Up @@ -190,7 +192,7 @@ def _find_expired_entries(index_path: Path, now: datetime) -> list[dict]:
# Detect scope section headers: ## global, ## session, etc.
if line.startswith("## "):
section = line[3:].strip()
if section in ("global", "project", "session", "agent"):
if section in ("global", "project", "session", "agent", "federated"):
current_scope = section
continue

Expand Down
16 changes: 14 additions & 2 deletions src/cli_agent_orchestrator/services/memory_scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,24 @@
"project": 1,
"global": 2,
"agent": 3,
"federated": 4,
}

# Cross-scope write authorisation table. Caller may write a target
# scope iff ``SCOPE_RANK[caller] >= SCOPE_RANK[target]``. ``agent`` and
# ``project`` share rank 1 — siblings; cross-sibling writes are rejected.
#
# ``federated`` is intentionally asymmetric: it has the LOWEST recall
# precedence (4 in SCOPE_PRECEDENCE — last on recall) yet write-rank 0,
# so it is writable by every caller except ``session`` (rank 0 can only
# write its own scope). This mirrors how ``session`` is write-rank 0 but
# has the HIGHEST recall precedence (0) — the two tables are independent.
Comment on lines 47 to +55
SCOPE_RANK: dict = {
"session": 0,
"project": 1,
"agent": 1,
"global": 2,
"federated": 0,
}


Expand Down Expand Up @@ -118,8 +126,12 @@ def scope_write_allowed(caller: str, target: str) -> bool:
"""Store-time scope guard.

Returns True iff a caller running at ``caller`` scope is permitted to
write a memory at ``target`` scope. ``agent`` and ``project`` are
siblings at rank 1; cross-sibling writes are rejected.
write a memory at ``target`` scope. The rule is: ``caller == target`` OR
``SCOPE_RANK[caller] > SCOPE_RANK[target]`` (strict). ``agent`` and
``project`` are siblings at rank 1; cross-sibling writes are rejected.
Strictness matters for ``federated`` (rank 0): ``session`` (also rank 0)
cannot write it because ``0 > 0`` is False, while any higher-rank caller
can — i.e. writable by every caller except session.

Unknown scopes (not in ``SCOPE_RANK``) deny by default — fail closed.
"""
Expand Down
38 changes: 37 additions & 1 deletion src/cli_agent_orchestrator/services/memory_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ def resolve_caller_scope(terminal_context: Optional[dict]) -> str:
"project",
"agent",
"global",
"federated",
}:
return explicit
return "global"
Expand All @@ -415,6 +416,11 @@ def resolve_scope_id(
if scope == MemoryScope.GLOBAL.value:
return None

# ``federated`` is a single machine-wide tier with no per-id
# isolation — like ``global``, its scope_id is always None.
if scope == MemoryScope.FEDERATED.value:
return None

ctx = terminal_context or {}

if scope == MemoryScope.PROJECT.value:
Expand Down Expand Up @@ -538,6 +544,10 @@ def _get_project_dir(self, scope: str, scope_id: Optional[str]) -> Path:
# container directory.
if scope == MemoryScope.PROJECT.value and scope_id:
return self.base_dir / scope_id
# ``federated`` is a machine-wide shared tier living in its own
# top-level container, a sibling of ``global``.
if scope == MemoryScope.FEDERATED.value:
return self.base_dir / "federated"
return self.base_dir / "global"

def get_wiki_path(self, scope: str, scope_id: Optional[str], key: str) -> Path:
Expand Down Expand Up @@ -598,6 +608,18 @@ async def store(
MemoryScope(scope)
MemoryType(memory_type)

# Federated writes are credential-gated. The machine-wide shared
# tier rejects content matching common secret patterns. The log
# line carries the pattern NAME only — never content bytes.
if scope == MemoryScope.FEDERATED.value:
from cli_agent_orchestrator.services.secret_gate import scan_for_secrets

hit = scan_for_secrets(content)
if hit:
# Do not log detector output; emit only a constant event marker.
logger.warning("federated_secret_rejected")
raise ValueError(f"federated write rejected: matched credential pattern {hit!r}")

# Store-time cross-scope write guard. A caller may
# only write a scope it is authorised for (SCOPE_RANK). The caller
# scope defaults to "global" (operator) unless terminal_context sets
Expand Down Expand Up @@ -1897,6 +1919,7 @@ async def _metadata_recall(
MemoryScope.PROJECT.value: 1,
MemoryScope.GLOBAL.value: 2,
MemoryScope.AGENT.value: 3,
MemoryScope.FEDERATED.value: 4,
}
results.sort(key=lambda m: (precedence.get(m.scope, 99), -m.updated_at.timestamp()))

Expand Down Expand Up @@ -2134,11 +2157,24 @@ def _get_search_dirs(
if global_dir.exists():
dirs.append(global_dir)

# Include the machine-wide federated tier when present. The
# ``.exists()`` guard preserves the byte-identical search-dir
# invariant: with no federated memories on disk, the dir list is
# unchanged from pre-federation behaviour.
federated_dir = self.base_dir / "federated"
if federated_dir.exists() and federated_dir not in dirs:
dirs.append(federated_dir)

if scan_all:
# Enumerate all project-hash dirs (for CLI use where user owns the filesystem)
if self.base_dir.exists():
for child in sorted(self.base_dir.iterdir()):
if child.is_dir() and child.name != "global" and child not in dirs:
if (
child.is_dir()
and child.name != "global"
and child.name != "federated"
and child not in dirs
):
dirs.append(child)
elif terminal_context:
# Include the specific project dir for this terminal's cwd
Expand Down
55 changes: 55 additions & 0 deletions src/cli_agent_orchestrator/services/secret_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Credential pattern gate for federated memory writes.

Pure module — no I/O, no logging, no state. ``scan_for_secrets`` matches
the supplied content against a fixed, ordered list of named regexes and
returns the NAME of the first matching pattern (or ``None`` if clean).

Used ONLY to reject credentials on ``scope="federated"`` writes — the
machine-wide shared tier. This is a heuristic deny-list, not entropy
scoring; it errs toward catching common credential shapes.
"""

import re
from typing import List, Optional, Pattern, Tuple

# Ordered (name, compiled_regex) pairs. First match wins, so ordering is
# stable and reproducible across calls. No entropy scoring.
_SECRET_PATTERNS: List[Tuple[str, Pattern[str]]] = [
# AWS access key IDs — long-lived (AKIA) and temporary/STS (ASIA).
("aws_access_key", re.compile(r"(?:AKIA|ASIA)[0-9A-Z]{16}")),
# PEM-encoded private keys (RSA / EC / OpenSSH / generic).
(
"pem_private_key",
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |)?PRIVATE KEY-----"),
),
# Bearer / api-key / token assignments with a long value. The separator
# may be ':'/'=' OR whitespace, so the canonical HTTP header form
# 'Authorization: Bearer <token>' (Bearer followed by a space) is caught.
(
"bearer_token",
re.compile(r"(?i)(?:bearer|api[_-]?key|token)[\s:=]+\S{16,}"),
),
# Generic secret/password assignments.
(
"secret_assignment",
re.compile(r"(?i)(?:password|passwd|secret|pwd)\s*[:=]\s*\S{6,}"),
),
# GitHub personal access tokens (ghp_ / ghs_ ...).
("github_pat", re.compile(r"gh[ps]_[A-Za-z0-9]{36,}")),
# GitLab personal access tokens.
("gitlab_pat", re.compile(r"glpat-[A-Za-z0-9_-]{20,}")),
]


def scan_for_secrets(content: str) -> Optional[str]:
"""Return the NAME of the first credential pattern that matches.

Returns ``None`` when no pattern matches. The caller must not echo the
matched bytes — only the returned pattern name is safe to log.
"""
if not content:
return None
for name, pattern in _SECRET_PATTERNS:
if pattern.search(content):
return name
return None
26 changes: 25 additions & 1 deletion src/cli_agent_orchestrator/skills/cao-memory/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Every memory has a **scope** (where it applies) and a **type** (what kind of fac
|-------|-----------|---------|
| `project` (default) | This repo / working directory | Conventions, architecture, build rules |
| `global` | Every project | User identity, durable cross-project preferences |
| `federated` | Every project on this machine | Reusable, repo-independent lessons worth sharing across all your work (rejects credentials) |
| `session` | This run only | Short-lived task context |
| `agent` | This agent role | Role-specific working notes |

Expand All @@ -39,7 +40,7 @@ already told you, search memory first.
memory_recall(query="database widgets endpoint testing")
```

Omit `scope` to search all scopes (results follow precedence session → project → global → agent).
Omit `scope` to search all scopes (results follow precedence session → project → global → agent → federated).
Filter with `scope=` or `memory_type=` when you know where to look. Recall is for searching
*beyond* what was auto-injected (see below) — don't re-recall what's already in front of you.

Expand All @@ -66,6 +67,29 @@ memory_store(

Same `key` + `scope` upserts (updates in place) rather than duplicating.

### Share across all your projects — `scope="federated"`

When a lesson is durable **and not specific to this repo** — a reusable library gotcha, a
debugging trick, a tooling preference that holds everywhere — store it with
`scope="federated"` so it follows you into every project on this machine, not just this one.

```
memory_store(
content="tmux paste-buffer needs `-p` or multi-line input loses bracketed-paste framing.",
scope="federated",
memory_type="reference",
)
```

Federated memories sit at the **lowest recall precedence** — a project-local fact with the
same key always wins — so federating is safe: it only adds a fallback, never overrides what's
true here. To un-share, `memory_forget(key=..., scope="federated")`.

- **Never federate secrets.** Tokens, keys, and passwords are **rejected automatically** on a
federated write — and they'd be exposed to every project anyway. Keep credentials out of
memory entirely.
- **When in doubt, use `project`.** Federate only what you're confident is reusable everywhere.

## Forget — remove what's wrong or superseded

```
Expand Down
Loading
Loading