Merge branch 'simp/r3-34-E' into simp/integration3

This commit is contained in:
Teknium
2026-09-02 22:41:37 -07:00
5 changed files with 356 additions and 674 deletions
+97 -170
View File
@@ -1,22 +1,16 @@
#!/usr/bin/env python3
"""Skill Sync client -- the low-level sync layer.
Builds content-addressed objects from local skills and talks the sync wire
contract (push objects + CAS a ref, pull the owner's HEAD, three-way merge on
a 409). Driven by the debounced push hook in ``skill_manage``, the periodic
``maybe_pull_skills`` at the curator tick sites, and the ``hermes sync`` CLI.
Lives under tools/ (NOT hermes_cli/) so it never imports the CLI at module
load. ``skills_sync_client_wire`` (object model + HTTP client + tree walks) and
``skills_sync_client_org`` (org-shared skills) are re-exported here.
ACCESS GATE (pre-launch): sync is INERT unless the signed-in user is a Nous
admin, read off the ``tool_gateway_admin`` JWT claim. The claim name is NAS's
and misleading: it is the global portal-admin permission, NOT a tool-gateway
right. Replace with a real entitlement before shipping to users.
OPT-IN DEFAULT (provisional): nothing syncs unless the user marks a skill for
sync. Local intent is the ``sync`` flag in ``.usage.json``; the DURABLE
cross-device state is the committed ``sync-manifest`` blob in the plane. Only
Builds content-addressed objects from local skills and talks the sync wire contract (push
objects + CAS a ref, pull the owner's HEAD, three-way merge on a 409). Driven by the debounced
push hook in ``skill_manage``, ``maybe_pull_skills`` at the curator tick sites, and ``hermes
sync``. Lives under tools/ (NOT hermes_cli/) so it never imports the CLI at module load;
``skills_sync_client_wire`` and ``skills_sync_client_org`` are re-exported here.
ACCESS GATE (pre-launch): sync is INERT unless the signed-in user is a Nous admin, read off the
``tool_gateway_admin`` JWT claim (NAS's misleading name for the global portal-admin permission;
replace with a real entitlement before shipping). OPT-IN DEFAULT (provisional): nothing syncs
unless the user marks a skill for sync -- local intent is the ``sync`` flag in ``.usage.json``,
the DURABLE cross-device state is the committed ``sync-manifest`` blob in the plane. Only
agent-created + user-authored skills under ``~/.hermes/skills/`` are eligible.
"""
@@ -25,7 +19,6 @@ from __future__ import annotations
import json
import logging
import os
import tempfile
from contextlib import suppress
from pathlib import Path, PurePosixPath
from typing import Any, Callable, Dict, List, Optional, Tuple
@@ -37,48 +30,37 @@ from tools.skills_sync_client_wire import ( # noqa: F401 (re-exports)
assemble_root_from_skill_trees, build_commit, build_root_tree, build_sync_manifest_bytes,
build_tree, canonical_json_bytes, materialize_tree, merge_skill, nest_skill_tree,
parse_sync_manifest, read_manifest_of_root, read_ref_hash, root_tree_of_commit,
skill_trees_of_root, wire_address,
)
skill_trees_of_root, wire_address)
logger = logging.getLogger(__name__)
_merge_skill = merge_skill
_skill_trees_of_root = skill_trees_of_root
# Identity & access gate. The bearer comes from resolve_nous_runtime_credentials()
# (file lock, host allowlist, refresh -- not reimplemented); its payload is
# decoded unverified to read the gate claim.
# Wire claim name is NAS's; it means "Nous admin" (Permissions.ADMIN_ACCESS).
# Identity & access gate. The bearer comes from resolve_nous_runtime_credentials() (file lock,
# host allowlist, refresh); its payload is decoded unverified to read the gate claim, whose
# wire name is NAS's and means "Nous admin" (Permissions.ADMIN_ACCESS).
NOUS_ADMIN_CLAIM = "tool_gateway_admin"
class SyncInertError(RuntimeError):
"""Sync must no-op: not logged in, no bearer, or not a Nous admin.
Caught by the gate-and-swallow hooks."""
"""Sync must no-op: not logged in, no bearer, or not a Nous admin. Caught by the gate-and-swallow hooks."""
def resolve_identity() -> Dict[str, Any]:
"""Resolve ``{api_key, base_url, owner, nous_admin, claims}``; raises
SyncInertError if not logged in / no bearer. ``owner`` is advisory for local
ref naming only -- the server derives the real owner from the bearer.
The JWT payload is decoded WITHOUT signature verification. Safe: the claims
are never used for authz here (the server re-verifies); they only decide
whether to attempt sync at all.
"""
"""``{api_key, base_url, owner, nous_admin, claims}``; SyncInertError if not logged in / no
bearer. ``owner`` is advisory for local ref naming only -- the server derives the real owner
from the bearer. The JWT is decoded WITHOUT signature verification: safe because the claims
never drive authz here (the server re-verifies), only whether to attempt sync at all."""
try:
from hermes_cli.auth import resolve_nous_runtime_credentials
creds = resolve_nous_runtime_credentials() or {}
except Exception as e:
raise SyncInertError(f"no Nous credentials: {e}") from e
api_key = creds.get("api_key")
if not api_key:
raise SyncInertError("no bearer token available")
try:
import jwt # PyJWT, a core dependency
@@ -88,18 +70,13 @@ def resolve_identity() -> Dict[str, Any]:
claims = {}
owner = claims.get("sub") or claims.get("privy_did") or claims.get("tid") or "unknown"
return {
"api_key": api_key,
"base_url": creds.get("base_url"),
"owner": str(owner),
"nous_admin": claims.get(NOUS_ADMIN_CLAIM) is True,
"claims": claims,
}
"api_key": api_key, "base_url": creds.get("base_url"), "owner": str(owner),
"nous_admin": claims.get(NOUS_ADMIN_CLAIM) is True, "claims": claims}
# Configuration -- env-first so a Hermes Cloud instance can enable sync purely
# through environment variables. Every knob: HERMES_SYNC_<KEY> env -> config.yaml
# ``sync.<key>`` -> built-in default (keys: base_url = the sync plane, NOT the
# inference URL; enabled; default_opt_in; org_auto_propose).
# Configuration -- env-first so a Hermes Cloud instance can enable sync purely through the
# environment. Every knob: HERMES_SYNC_<KEY> env -> config.yaml ``sync.<key>`` -> default
# (keys: base_url = the sync plane, NOT the inference URL; enabled; default_opt_in; org_auto_propose).
#: Production Skill Sync plane; a normal user configures nothing.
DEFAULT_SYNC_BASE_URL = "https://gateway-gateway.nousresearch.com"
@@ -109,8 +86,7 @@ _FALSE = {"0", "false", "no", "off", ""}
def _sync_config(key: str) -> Any:
"""``sync.<key>`` from config.yaml, or None. Lazy import: this layer must not
import the CLI at module load."""
"""``sync.<key>`` from config.yaml, or None. Lazy import: must not import the CLI at module load."""
try:
from hermes_cli.config import load_config
@@ -121,9 +97,8 @@ def _sync_config(key: str) -> Any:
def resolve_sync_base_url() -> Optional[str]:
"""HERMES_SYNC_BASE_URL -> ``sync.base_url`` -> production plane, without a
trailing slash (``/v1/sync/`` is appended by the client). None only if the
default is blanked out."""
"""HERMES_SYNC_BASE_URL -> ``sync.base_url`` -> production plane, without trailing slash
(``/v1/sync/`` is appended by the client). None only if the default is blanked out."""
env = os.getenv("HERMES_SYNC_BASE_URL")
if env and env.strip():
return env.strip().rstrip("/")
@@ -134,8 +109,7 @@ def resolve_sync_base_url() -> Optional[str]:
def _parse_bool(value: Any) -> Optional[bool]:
"""Parse a config/env bool; None if unrecognized so callers fall through to
the next precedence layer."""
"""Parse a config/env bool; None if unrecognized so callers fall through to the next layer."""
if isinstance(value, bool):
return value
if value is None:
@@ -154,22 +128,19 @@ def _sync_config_bool(env_var: str, config_key: str, *, default: bool) -> bool:
def sync_feature_enabled() -> bool:
"""Master switch. Checked by the gate-and-swallow entrypoints IN ADDITION to
the Nous-admin gate and a configured base URL -- all three must hold."""
"""Master switch; the gate-and-swallow entrypoints ALSO require the Nous-admin gate and a base URL."""
return _sync_config_bool("HERMES_SYNC_ENABLED", "enabled", default=False)
def sync_org_auto_propose() -> bool:
"""False (default): edits to an org skill stay LOCAL until ``hermes sync
propose``. True: every edit is proposed right away (an admin still approves
unless the editor is one) -- for small high-trust teams."""
"""False (default): edits to an org skill stay LOCAL until ``hermes sync propose``. True: every
edit is proposed right away (an admin still approves unless the editor is one)."""
return _sync_config_bool("HERMES_SYNC_ORG_AUTO_PROPOSE", "org_auto_propose", default=False)
def sync_default_opt_in() -> bool:
"""False (default): opt-IN -- a skill syncs only after ``hermes sync enable``
or a plane manifest opting it in. True: opt-OUT -- every eligible skill
syncs unless explicitly disabled (the Hermes Cloud default). Provisional."""
"""False (default): opt-IN -- a skill syncs only after ``hermes sync enable`` or a plane manifest
opting it in. True: opt-OUT -- every eligible skill syncs unless disabled (Hermes Cloud default)."""
return _sync_config_bool("HERMES_SYNC_DEFAULT_OPT_IN", "default_opt_in", default=False)
@@ -206,10 +177,9 @@ def _skill_rel_path(skill_name: str) -> Optional[PurePosixPath]:
def is_sync_eligible(skill_name: str) -> bool:
"""Candidate for sync (before the opt-in check): present locally, NOT
bundled, NOT hub-installed, NOT external, and NOT under the ``_org/`` mirror
(enterprise content must never ride a personal push). Mirrors the curator's
exclusions (tools/skill_usage.py)."""
"""Candidate for sync (before the opt-in check): present locally, NOT bundled, NOT hub-installed,
NOT external, NOT under the ``_org/`` mirror (enterprise content must never ride a personal
push). Mirrors the curator's exclusions (tools/skill_usage.py)."""
try:
from tools.skill_usage import is_bundled, is_hub_installed, _find_skill_dir
from agent.skill_utils import is_external_skill_path
@@ -225,9 +195,9 @@ def is_sync_eligible(skill_name: str) -> bool:
def list_synced_skill_names() -> List[str]:
"""Names of skills that should sync (sorted, deduped), per ``sync_default_opt_in()``:
opt-in -> only eligible skills whose usage record has ``sync: true``;
opt-out -> every eligible skill unless its record has ``sync: false``."""
"""Names of skills that should sync (sorted, deduped), per ``sync_default_opt_in()``: opt-in ->
eligible skills whose usage record has ``sync: true``; opt-out -> every eligible skill unless
its record has ``sync: false``."""
try:
from tools.skill_usage import load_usage
except Exception:
@@ -242,9 +212,8 @@ def list_synced_skill_names() -> List[str]:
def _all_local_skill_names() -> List[str]:
"""Every locally-present skill name (a dir under ~/.hermes/skills/ with a
``SKILL.md``; frontmatter ``name`` falling back to the dir name). Eligibility
is applied by the caller."""
"""Every locally-present skill name (a dir under ~/.hermes/skills/ with a ``SKILL.md``;
frontmatter ``name`` falling back to the dir name). Eligibility is applied by the caller."""
names: List[str] = []
root = _skills_dir()
try:
@@ -272,9 +241,8 @@ def _opted_in_rel_paths() -> List[str]:
def _adopt_manifest_opt_ins(remote_manifest: Optional[Dict[str, bool]]) -> List[str]:
"""Enable local sync intent for skills the plane manifest has enabled and that
are locally curation-eligible. Enables only -- a pull never silently disables.
Returns the adopted names; best-effort."""
"""Enable local sync intent for skills the plane manifest enabled that are locally
curation-eligible. Enables only -- a pull never silently disables. Returns adopted names."""
adopted: List[str] = []
if not remote_manifest:
return adopted
@@ -293,8 +261,7 @@ def _adopt_manifest_opt_ins(remote_manifest: Optional[Dict[str, bool]]) -> List[
# Device label (commit ``author.device``; advisory, never an auth input)
def _default_device_label() -> str:
"""Short hostname + short random suffix (two machines can share a hostname);
a bare uuid if the hostname is unusable."""
"""Short hostname + random suffix (two machines can share a hostname); bare uuid if unusable."""
import socket
import uuid
@@ -307,10 +274,9 @@ def _default_device_label() -> str:
def stable_device_id() -> str:
"""Stable per-device label, persisted at ~/.hermes/skills/.sync_device_id.
An existing file always wins. Otherwise seeded from HERMES_SYNC_DEVICE_NAME
(first use only; lets Hermes Cloud name hosted instances) or a friendly
default, then persisted so a later ``set_device_name()`` still wins."""
"""Stable per-device label persisted at ~/.hermes/skills/.sync_device_id. An existing file
always wins; otherwise seeded from HERMES_SYNC_DEVICE_NAME (first use only; lets Hermes Cloud
name hosted instances) or a friendly default, then persisted so a later ``set_device_name()`` wins."""
path = _skills_dir() / ".sync_device_id"
try:
if path.exists():
@@ -334,8 +300,7 @@ def _write_device_id(val: str) -> None:
def set_device_name(name: str) -> str:
"""Overwrite the device label with the trimmed *name*; any non-empty string
is accepted. Returns the stored value; ValueError on empty."""
"""Overwrite the device label with the trimmed *name*; returns it. ValueError on empty."""
cleaned = (name or "").strip()
if not cleaned:
raise ValueError("device name must be a non-empty string")
@@ -343,10 +308,9 @@ def set_device_name(name: str) -> str:
return cleaned
# Local sync STATE: the last HEAD we pushed/pulled + the root tree at that
# point (FULL-digest namespace). Distinct from the bundled manifest
# (skills_sync.py) and from the plane's `sync-manifest` object. Lives at
# ~/.hermes/skills/.sync_state; a legacy `.sync_manifest` is migrated on read.
# Local sync STATE: the last HEAD we pushed/pulled + the root tree at that point (FULL-digest
# namespace). Distinct from the bundled manifest (skills_sync.py) and from the plane's
# `sync-manifest` object. Lives at ~/.hermes/skills/.sync_state; legacy `.sync_manifest` migrates on read.
_EMPTY_STATE: Dict[str, Any] = {"head": None, "skills": {}}
@@ -370,9 +334,9 @@ def _load_state_file(path: Path, what: str = "sync state read") -> Optional[Dict
def read_sync_state() -> Dict[str, Any]:
"""``{"head": "sha256:...|null", "skills": {...}}``; a default on missing/corrupt.
If ``.sync_state`` is absent but the legacy ``.sync_manifest`` exists, it is
read, rewritten to the new path and removed, so no head record is lost."""
"""``{"head": "sha256:...|null", "skills": {...}}``; a default on missing/corrupt. If
``.sync_state`` is absent but the legacy ``.sync_manifest`` exists it is read, rewritten to
the new path and removed, so no head record is lost."""
path = _sync_state_path()
if path.exists():
return _load_state_file(path) or dict(_EMPTY_STATE)
@@ -389,20 +353,12 @@ def read_sync_state() -> Dict[str, Any]:
def write_sync_state(data: Dict[str, Any]) -> None:
"""Write the local sync state atomically. Best-effort."""
path = _sync_state_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".sync_state_", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
with suppress(OSError):
os.unlink(tmp)
raise
from tools.skill_usage import _atomic_write
_atomic_write(
_sync_state_path(), ".sync_state_",
lambda f: json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False))
except Exception as e:
logger.debug("skills_sync_client: sync state write failed: %s", e)
@@ -413,16 +369,15 @@ def _record_head(state: Dict[str, Any], head: str, root: str) -> None:
write_sync_state(state)
# Profile snapshot -- the root tree mirrors each synced skill's relative path
# under ~/.hermes/skills/ (category dirs become intermediate trees).
# Profile snapshot -- the root tree mirrors each synced skill's relative path under
# ~/.hermes/skills/ (category dirs become intermediate trees).
def snapshot_profile(
skill_names: List[str], *, max_object_bytes: int = DEFAULT_MAX_OBJECT_BYTES
) -> Tuple[ObjectSet, str, Dict[str, str]]:
"""Build all objects for *skill_names* + the profile-root tree; returns
``(objects, root_tree_hash, {skill_name: tree_hash})``. Skills whose blobs
exceed *max_object_bytes* are skipped (logged). The root also carries the
``sync-manifest`` blob listing every included skill as ``enabled: true``."""
"""Build all objects for *skill_names* + the profile-root tree -> ``(objects, root_tree_hash,
{skill_name: tree_hash})``. Skills whose blobs exceed *max_object_bytes* are skipped (logged).
The root also carries the ``sync-manifest`` blob listing every included skill as enabled."""
from tools.skill_usage import _find_skill_dir
objects = ObjectSet()
@@ -440,7 +395,6 @@ def snapshot_profile(
continue
skill_tree_map[name] = tree_hash
nest_skill_tree(root, rel.parts, tree_hash)
manifest_hash = objects.add(KIND_BLOB, build_sync_manifest_bytes(dict.fromkeys(skill_tree_map, True)))
return objects, build_root_tree(root, objects, manifest_hash=manifest_hash), skill_tree_map
@@ -454,8 +408,7 @@ def user_head_ref(owner: str) -> str:
def _personal_client(
identity: Optional[Dict[str, Any]], client: Optional[SyncClient]
) -> Tuple[Dict[str, Any], Optional[SyncClient]]:
"""Resolve identity + client for a personal sync op. ``client`` is None when
no base URL is configured (callers return a no-op result)."""
"""Identity + client for a personal sync op; ``client`` is None when no base URL is configured."""
identity = identity if identity is not None else resolve_identity()
if client is None:
base = resolve_sync_base_url()
@@ -471,17 +424,14 @@ def push_skills(
*,
skill_names: Optional[List[str]] = None,
identity: Optional[Dict[str, Any]] = None,
message: str = "hermes skill sync",
) -> Dict[str, Any]:
"""Push opted-in skills to ``refs/user/<owner>/HEAD``: upload new objects, CAS
HEAD. A 409 with an actual head -> three-way merge + one retry; a 409 against
a NON-EXISTENT ref (stale local head, e.g. carried over from another plane)
-> redo the CAS as a create. Never raises for the inert / no-op cases."""
message: str = "hermes skill sync") -> Dict[str, Any]:
"""Push opted-in skills to ``refs/user/<owner>/HEAD``: upload new objects, CAS HEAD. A 409 with
an actual head -> three-way merge + one retry; a 409 against a NON-EXISTENT ref (stale local
head, e.g. from another plane) -> redo the CAS as a create. Never raises for inert/no-op cases."""
identity, client = _personal_client(identity, client)
if client is None:
return dict(_NO_BASE_URL)
owner = identity["owner"]
if skill_names is None:
skill_names = list_synced_skill_names()
if not skill_names:
@@ -491,18 +441,15 @@ def push_skills(
_check_version(caps)
max_bytes = int(caps.get("max_object_bytes") or DEFAULT_MAX_OBJECT_BYTES)
objects, root_hash, _ = snapshot_profile(skill_names, max_object_bytes=max_bytes)
state = read_sync_state()
base_head = state.get("head")
# Idempotency: objects are immutable, so an unchanged root tree hash means
# identical content -- skip building an empty commit.
# Idempotency: objects are immutable, so an unchanged root hash means identical content.
if base_head and state.get("root") == root_hash:
return {"ok": True, "head": base_head, "reason": "unchanged", "noop": True}
commit_hash = build_commit(
root_hash, [base_head] if base_head else [], owner=owner, device=stable_device_id(),
message=message, objects=objects,
)
message=message, objects=objects)
client.put_objects(objects.objects)
ref = user_head_ref(owner)
result = {"ok": True, "head": commit_hash, "pushed_objects": len(objects)}
@@ -519,10 +466,6 @@ def push_skills(
return result
# Three-way merge per skill against the base we forked from (see merge_skill):
# each side changing a DIFFERENT skill -> merge commit (2 parents) + CAS retry;
# both changing the SAME skill differently -> TRUE OVERLAP -> written to
# refs/user/<owner>/conflict/<n> and surfaced for out-of-band resolution.
def _resolve_push_conflict(
client: SyncClient,
identity: Dict[str, Any],
@@ -531,8 +474,10 @@ def _resolve_push_conflict(
our_commit: str,
objects: ObjectSet,
message: str,
base_head: Optional[str],
) -> Dict[str, Any]:
base_head: Optional[str]) -> Dict[str, Any]:
"""Three-way merge per skill against the base we forked from (see merge_skill): each side
changing a DIFFERENT skill -> merge commit (2 parents) + CAS retry; both changing the SAME
skill -> TRUE OVERLAP -> written to refs/user/<owner>/conflict/<n> for out-of-band resolution."""
owner = identity["owner"]
ours_trees = skill_trees_of_root(client, our_root)
theirs_trees = skill_trees_of_root(client, root_tree_of_commit(client, actual_head))
@@ -545,44 +490,37 @@ def _resolve_push_conflict(
decision = merge_skill(base_trees.get(path), o, t)
if decision == "overlap":
overlaps.append(path)
# overlap keeps OURS on the surfaced conflict head (theirs stays
# server-side); "none" = deleted on the winning side -> drop.
# overlap keeps OURS on the surfaced conflict head (theirs stays server-side);
# "none" = deleted on the winning side -> drop.
pick = {"overlap": o, "ours": o, "theirs": t, "either": o if o is not None else t}.get(decision)
if pick is not None:
merged[path] = pick
if overlaps:
conflict_ref = f"refs/user/{owner}/conflict/{_next_conflict_index(client, owner)}"
try:
with suppress(SyncConflict): # someone else grabbed this index; the head still exists
client.cas_ref(conflict_ref, None, our_commit)
except SyncConflict:
pass # someone else grabbed this index; the head still exists
return {
"ok": False, "conflict": True, "conflict_ref": conflict_ref,
"overlapping_skills": sorted(overlaps), "actual_head": actual_head,
"message": (
f"{len(overlaps)} skill(s) changed on both sides; wrote "
f"{conflict_ref}. Resolve out-of-band (hermes sync / NAS UI)."
),
}
f"{conflict_ref}. Resolve out-of-band (hermes sync / NAS UI).")}
# Merge commit (parents: actual, ours); re-add our objects so the merge
# push is self-contained.
# Merge commit (parents: actual, ours); re-add our objects so the merge push is self-contained.
merge_objects = ObjectSet()
merge_objects.objects.update(objects.objects)
merged_root = assemble_root_from_skill_trees(merged, merge_objects)
merge_commit = build_commit(
merged_root, [actual_head, our_commit], owner=owner, device=stable_device_id(),
message=f"merge: {message}", objects=merge_objects,
)
message=f"merge: {message}", objects=merge_objects)
client.put_objects(merge_objects.objects)
try:
client.cas_ref(user_head_ref(owner), actual_head, merge_commit)
except SyncConflict as c2:
return {
"ok": False, "conflict": True, "actual_head": c2.actual,
"message": f"merge CAS lost again (head now {c2.actual}); retry sync.",
}
"message": f"merge CAS lost again (head now {c2.actual}); retry sync."}
_record_head(read_sync_state(), merge_commit, merged_root)
return {"ok": True, "head": merge_commit, "merged": True}
@@ -600,44 +538,39 @@ def _next_conflict_index(client: SyncClient, owner: str) -> int:
def pull_skills(
client: Optional[SyncClient] = None, *, identity: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Pull the owner's HEAD and materialize opted-in skills under ~/.hermes/skills/
if it advanced past our recorded head. Opt-in intent is first adopted FROM
the plane manifest, then only opted-in paths are written so a pull never
resurrects a skill the user hasn't chosen. Best-effort; returns a result dict."""
"""Pull the owner's HEAD and materialize opted-in skills under ~/.hermes/skills/ if it advanced
past our recorded head. Opt-in intent is first adopted FROM the plane manifest, then only
opted-in paths are written so a pull never resurrects a skill the user hasn't chosen."""
identity, client = _personal_client(identity, client)
if client is None:
return dict(_NO_BASE_URL)
owner = identity["owner"]
_check_version(client.capabilities())
head = read_ref_hash(client, user_head_ref(owner))
if not head:
return {"ok": True, "reason": "no remote HEAD yet", "noop": True}
state = read_sync_state()
if head == state.get("head"):
return {"ok": True, "reason": "already up to date", "head": head, "noop": True}
root_tree = root_tree_of_commit(client, head)
remote_trees = skill_trees_of_root(client, root_tree)
adopted = _adopt_manifest_opt_ins(read_manifest_of_root(client, root_tree))
opted_in = set(_opted_in_rel_paths())
updated = [path for path in remote_trees if not opted_in or path in opted_in]
for path in updated:
materialize_tree(client, remote_trees[path], _skills_dir() / path)
state["head"] = head
write_sync_state(state)
return {"ok": True, "head": head, "updated": sorted(updated), "opt_in_adopted": sorted(adopted)}
# Gated public entrypoints (gate-and-swallow, like the curator's
# maybe_run_curator): best-effort, never raise, return a result dict or None.
# Gated public entrypoints (gate-and-swallow, like the curator's maybe_run_curator): best-effort,
# never raise, return a result dict or None.
def _gate_and_swallow(op: str, run: Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]):
"""Run *run(identity)* only if every background-sync gate holds (Nous admin,
feature on, base URL); None when inert or on any error."""
"""Run *run(identity)* only if every background-sync gate holds (Nous admin, feature on, base
URL); None when inert or on any error."""
try:
identity = resolve_identity()
if not identity.get("nous_admin") or not sync_feature_enabled() or not resolve_sync_base_url():
@@ -657,22 +590,18 @@ def maybe_push_skills(*, message: str = "hermes skill sync") -> Optional[Dict[st
def maybe_pull_skills() -> Optional[Dict[str, Any]]:
"""Best-effort pull (curator tick sites: gateway housekeeping + CLI startup).
Never raises."""
"""Best-effort pull (curator tick sites: gateway housekeeping + CLI startup). Never raises."""
return _gate_and_swallow("maybe_pull_skills", lambda identity: pull_skills(identity=identity))
def sync_status() -> Dict[str, Any]:
"""Status snapshot for ``hermes sync status``. Never raises.
``org_available`` False means the account isn't in a shared organisation
(the org workflow does not apply), not that anything is broken."""
"""Status snapshot for ``hermes sync status``. Never raises. ``org_available`` False means the
account isn't in a shared organisation (org workflow does not apply), not that anything is broken."""
status: Dict[str, Any] = {
"nous_admin": False, "logged_in": False, "feature_enabled": sync_feature_enabled(),
"default_opt_in": sync_default_opt_in(), "base_url": resolve_sync_base_url(),
"opted_in_skills": [], "local_head": None, "owner": None, "org_available": False,
"org_id": None, "org_role": None, "org_skills": [], "org_skills_modified": [],
}
"org_id": None, "org_role": None, "org_skills": [], "org_skills_modified": []}
try:
identity = resolve_identity()
status.update(logged_in=True, owner=identity.get("owner"), nous_admin=bool(identity.get("nous_admin")))
@@ -690,8 +619,7 @@ def sync_status() -> Dict[str, Any]:
status.update(
org_available=True, org_id=org_identity.get("org_id"), org_role=org_identity.get("org_role"),
org_skills=list_org_skill_names(),
org_skills_modified=list_locally_modified_org_skills(org_identity.get("org_id")),
)
org_skills_modified=list_locally_modified_org_skills(org_identity.get("org_id")))
except SyncInertError:
pass
except Exception as e:
@@ -699,12 +627,11 @@ def sync_status() -> Dict[str, Any]:
return status
# Org-shared skills live in skills_sync_client_org; imported last because that
# module reads this module's state lazily.
# Org-shared skills live in skills_sync_client_org; imported last because that module reads
# this module's state lazily.
from tools.skills_sync_client_org import ( # noqa: E402,F401 (re-exports)
ORG_DIR_NAME, _ORG_CAS_MAX_ATTEMPTS, _clear_active_org_marker, _org_baseline_path,
_read_org_baseline, _read_org_head, _skill_dir_fingerprint, _write_active_org_marker,
_write_org_baseline, _write_org_provenance, list_locally_modified_org_skills,
list_org_skill_names, maybe_pull_org_skills, org_head_ref, org_skill_is_locally_modified,
propose_skill, pull_org_skills, resolve_org_identity,
)
propose_skill, pull_org_skills, resolve_org_identity)
+46 -77
View File
@@ -1,13 +1,12 @@
"""Org-shared skills: org pull + propose (``~/.hermes/skills/_org/<org_id>/``).
Org skills live under a DISTINCT local namespace (read-only to the runtime; a
local edit is a personal fork until proposed). The canonical set is
``refs/org/<org_id>/HEAD`` -- the SAME object model as personal sync.
PERSONAL-ORG GATE: NAS stamps ``org_role`` ONLY for multi-member orgs; no
claim => pull/propose raise SyncInertError and personal sync is untouched.
``propose_skill`` must stay non-interactive (automation will drive it).
Module state (``_skills_dir``, ``_org_dir``, base URL, device id) stays in
``tools.skills_sync_client`` and is read lazily so tests can monkeypatch it.
Org skills live under a DISTINCT local namespace (read-only to the runtime; a local edit is a
personal fork until proposed). The canonical set is ``refs/org/<org_id>/HEAD`` -- the SAME
object model as personal sync. PERSONAL-ORG GATE: NAS stamps ``org_role`` ONLY for multi-member
orgs; no claim => pull/propose raise SyncInertError and personal sync is untouched.
``propose_skill`` must stay non-interactive (automation will drive it). Module state
(``_skills_dir``, ``_org_dir``, base URL, device id) stays in ``tools.skills_sync_client`` and
is read lazily so tests can monkeypatch it.
"""
from __future__ import annotations
@@ -22,15 +21,14 @@ from typing import Any, Callable, Dict, List, Optional
from tools.skills_sync_client_wire import (
DEFAULT_MAX_OBJECT_BYTES, ObjectSet, SyncClient, SyncConflict, SyncError, _check_version,
assemble_root_from_skill_trees, build_commit, build_tree, materialize_tree, read_ref_hash,
root_tree_of_commit, skill_trees_of_root,
)
root_tree_of_commit, skill_trees_of_root)
logger = logging.getLogger("tools.skills_sync_client")
ORG_DIR_NAME = "_org"
# Propose re-splices onto a moved org HEAD at most this many times. Small:
# contention means other members are actively proposing; unbounded would spin.
# Propose re-splices onto a moved org HEAD at most this many times. Small: contention means
# other members are actively proposing; unbounded would spin.
_ORG_CAS_MAX_ATTEMPTS = 5
@@ -45,11 +43,8 @@ def org_head_ref(org_id: str) -> str:
def resolve_org_identity() -> Dict[str, Any]:
"""``resolve_identity()`` extended with ``org_id`` + ``org_role``.
Raises SyncInertError when the token carries no ``org_role`` claim (personal
org / issuer predates org support): org sync is unavailable, NOT an error.
"""
"""``resolve_identity()`` extended with ``org_id`` + ``org_role``. Raises SyncInertError when
the token carries no ``org_role`` claim (personal org / old issuer): unavailable, NOT an error."""
from tools.skills_sync_client import SyncInertError, resolve_identity
identity = resolve_identity()
@@ -66,8 +61,8 @@ def resolve_org_identity() -> Dict[str, Any]:
def _org_client(identity: Optional[Dict[str, Any]], client: Optional[SyncClient]):
"""Resolve (identity, client, caps) for an org operation; raises SyncInertError
when the base URL is missing or the server lacks the ``org`` feature."""
"""(identity, client, caps) for an org operation; SyncInertError when the base URL is
missing or the server lacks the ``org`` feature."""
ssc = _ssc()
identity = identity or resolve_org_identity()
if client is None:
@@ -104,8 +99,8 @@ def _write_sidecar(what: str, path_fn: Callable[[], Path], text: str) -> None:
def _skill_dir_fingerprint(path: Path) -> str:
"""Stable content hash of a materialized skill dir (sorted relative path +
bytes, independent of filesystem order and mtimes). "" on read failure."""
"""Stable content hash of a materialized skill dir (sorted relative path + bytes,
independent of filesystem order and mtimes). "" on read failure."""
h = hashlib.sha256()
try:
for f in sorted(p for p in path.rglob("*") if p.is_file()):
@@ -139,15 +134,11 @@ def _read_org_baseline(org_id: str) -> Dict[str, Any]:
def _write_org_baseline(org_id: str, baseline: Dict[str, Any]) -> None:
_write_sidecar(
"baseline", lambda: _org_baseline_path(org_id), json.dumps(baseline, indent=2, sort_keys=True)
)
_write_sidecar("baseline", lambda: _org_baseline_path(org_id), json.dumps(baseline, indent=2, sort_keys=True))
def _write_org_provenance(org_id: str, data: Dict[str, Any]) -> None:
_write_sidecar(
"org provenance", lambda: _sidecar_path(org_id, "ORG_PROVENANCE_FILE"), json.dumps(data, indent=2)
)
_write_sidecar("org provenance", lambda: _sidecar_path(org_id, "ORG_PROVENANCE_FILE"), json.dumps(data, indent=2))
def _write_active_org_marker(org_id: str) -> None:
@@ -163,16 +154,14 @@ def _clear_active_org_marker() -> None:
marker.unlink()
logger.info(
"skills_sync_client: cleared active-org marker "
"(token has no org workflow); org skills no longer resolve"
)
"(token has no org workflow); org skills no longer resolve")
except Exception as e:
logger.debug("skills_sync_client: marker clear failed: %s", e)
def org_skill_is_locally_modified(skill_rel_path: str, org_id: str) -> bool:
"""True when the local copy of an org skill differs from what upstream sent.
No recorded baseline (pre-existing mirror) => unmodified; the next pull
records one."""
"""True when the local copy of an org skill differs from what upstream sent. No recorded
baseline (pre-existing mirror) => unmodified; the next pull records one."""
dest = _mirror_root(org_id) / PurePosixPath(skill_rel_path)
if not dest.is_dir():
return False
@@ -218,10 +207,9 @@ def list_org_skill_names() -> List[str]:
def pull_org_skills(
client: Optional[SyncClient] = None, *, identity: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Pull the org canonical set into the local mirror (fast-forward only; no
client merge on the org path). A mirrored skill with LOCAL edits is never
clobbered: it is skipped, and reported in ``conflicted`` when upstream also
moved; the member's change of record is ``propose_skill``.
"""Pull the org canonical set into the local mirror (fast-forward only; no client merge on
the org path). A mirrored skill with LOCAL edits is never clobbered: skipped, and reported
in ``conflicted`` when upstream also moved; the member's change of record is ``propose_skill``.
Returns ``{ok, org_id, head, updated, conflicted}``."""
identity = identity or resolve_org_identity()
if "org_id" not in identity:
@@ -230,16 +218,14 @@ def pull_org_skills(
org_id = identity["org_id"]
head = _read_org_head(client, org_id)
# Token-gated marker: written HERE because this runs only after the token's
# org_id + org_role were verified. A stale mirror from a previous org stops
# resolving the moment a pull runs under a different org.
# Token-gated marker: written HERE because this runs only after the token's org_id + org_role
# were verified. A stale mirror from a previous org stops resolving on a pull under another org.
_write_active_org_marker(org_id)
if not head:
return {"ok": True, "org_id": org_id, "head": None, "updated": []}
head_commit = client.get_commit_json(head, org_scope=True)
skill_trees = skill_trees_of_root(client, head_commit["tree"], org_scope=True)
dest_root = _mirror_root(org_id)
updated: List[str] = []
conflicted: List[str] = []
@@ -259,8 +245,8 @@ def pull_org_skills(
updated.append(rel_path)
except Exception as e:
logger.warning("skills_sync_client: org skill materialize failed for %s: %s", rel_path, e)
# Provenance for the skill_view header: the HEAD author is token-verified
# by the plane at push time, so it is trustworthy to display.
# Provenance for the skill_view header: the HEAD author is token-verified by the plane at
# push time, so it is trustworthy to display.
author = head_commit.get("author") or {}
_write_org_provenance(org_id, {
"org_id": org_id, "head": head, "author_user_id": author.get("owner", ""),
@@ -270,8 +256,7 @@ def pull_org_skills(
if conflicted:
logger.warning(
"skills_sync_client: %d org skill(s) have local edits AND upstream "
"changes; left untouched: %s", len(conflicted), ", ".join(conflicted),
)
"changes; left untouched: %s", len(conflicted), ", ".join(conflicted))
return {"ok": True, "org_id": org_id, "head": head, "updated": updated, "conflicted": conflicted}
@@ -280,20 +265,16 @@ def propose_skill(
client: Optional[SyncClient] = None,
*,
identity: Optional[Dict[str, Any]] = None,
message: Optional[str] = None,
) -> Dict[str, Any]:
message: Optional[str] = None) -> Dict[str, Any]:
"""Propose a local (personal) skill's content to the org canonical set.
Snapshots the skill dir as an org-scoped commit splicing that ONE skill
subtree into the current org HEAD (proposals are per-skill deltas, never a
wholesale replace), uploads with ``?scope=org``, then CAS-es the org HEAD:
ADMIN/OWNER -> server merges -> ``{ok, merged: True}``; MEMBER -> 202
proposal -> ``{ok, proposal_pending: True, proposal_id, ref}``, never
presented as live.
If HEAD moves between read and CAS, the skill is re-spliced onto the NEW
head (not replayed from the old root, which would drop the other member's
skill) up to ``_ORG_CAS_MAX_ATTEMPTS`` times.
Snapshots the skill dir as an org-scoped commit splicing that ONE skill subtree into the
current org HEAD (proposals are per-skill deltas, never a wholesale replace), uploads with
``?scope=org``, then CAS-es the org HEAD: ADMIN/OWNER -> server merges -> ``{ok, merged:
True}``; MEMBER -> 202 proposal -> ``{ok, proposal_pending: True, proposal_id, ref}``, never
presented as live. If HEAD moves between read and CAS the skill is re-spliced onto the NEW
head (not replayed from the old root, which would drop the other member's skill) up to
``_ORG_CAS_MAX_ATTEMPTS`` times.
"""
ssc = _ssc()
identity, client, caps = _org_client(identity, client)
@@ -309,14 +290,11 @@ def propose_skill(
objects = ObjectSet()
skill_tree = build_tree(skill_dir, objects, max_object_bytes=max_bytes)
for attempt in range(1, _ORG_CAS_MAX_ATTEMPTS + 1):
base_head = _read_org_head(client, org_id)
skill_map = (
skill_trees_of_root(client, root_tree_of_commit(client, base_head, org_scope=True), org_scope=True)
if base_head
else {}
)
if base_head else {})
skill_map[str(rel)] = skill_tree
root_hash = assemble_root_from_skill_trees(skill_map, objects)
commit_hash = build_commit(
@@ -335,30 +313,21 @@ def propose_skill(
"the race — run the command again",
status=409,
) from conflict
logger.debug(
"propose_skill: org HEAD moved (actual=%r), re-splicing (attempt %d)", conflict.actual, attempt
)
logger.debug("propose_skill: org HEAD moved (actual=%r), re-splicing (attempt %d)", conflict.actual, attempt)
if result.get("proposal_pending"):
return {
"ok": True, "proposal_pending": True, "proposal_id": result.get("proposal_id"),
"ref": result.get("ref"), "commit": commit_hash, "org_id": org_id,
}
return {
"ok": True, "merged": True, "head": result.get("hash", commit_hash),
"commit": commit_hash, "org_id": org_id,
}
"ref": result.get("ref"), "commit": commit_hash, "org_id": org_id}
return {"ok": True, "merged": True, "head": result.get("hash", commit_hash), "commit": commit_hash, "org_id": org_id}
def maybe_pull_org_skills() -> Optional[Dict[str, Any]]:
"""Best-effort org pull if all gates hold (logged in, org_role claim,
feature enabled, base URL). Never raises; None when inert.
Marker hygiene: when the token VERIFIABLY lacks the org claim (personal org
/ left the org) the active-org marker is cleared so mirrored org skills stop
resolving. When identity cannot be resolved at all (offline, logged out) the
marker is left alone -- offline grace keeps pulled org skills working.
"""
"""Best-effort org pull if all gates hold (logged in, org_role claim, feature enabled, base
URL). Never raises; None when inert. Marker hygiene: when the token VERIFIABLY lacks the org
claim (personal org / left the org) the active-org marker is cleared so mirrored org skills
stop resolving; when identity cannot be resolved at all (offline, logged out) the marker is
left alone -- offline grace keeps pulled org skills working."""
ssc = _ssc()
try:
identity = resolve_org_identity()
+86 -130
View File
@@ -1,11 +1,9 @@
"""Skill Sync wire model: content-addressed objects, the HTTP client, tree walks.
Everything here is independent of local skill state (no ``~/.hermes`` reads);
``tools/skills_sync_client.py`` orchestrates push/pull on top of it and
re-exports these names.
Wire contract (version 1). ``hsp_version`` / ``X-HSP-Object-Type`` are deployed
protocol identifiers and are NOT renamed with the product name "Skill Sync".
Independent of local skill state (no ``~/.hermes`` reads); ``tools/skills_sync_client.py``
orchestrates push/pull on top of it and re-exports these names. Wire contract version 1:
``hsp_version`` / ``X-HSP-Object-Type`` are deployed protocol identifiers and are NOT
renamed with the product name "Skill Sync".
"""
from __future__ import annotations
@@ -35,35 +33,32 @@ MODE_DIR = "dir"
ARTIFACT_TYPE_SKILL = "skill"
_EXEC_BITS = _stat.S_IXUSR | _stat.S_IXGRP | _stat.S_IXOTH
# `sync-manifest`: per-skill opt-in is CONTENT in the object model, not a
# device-local flag -- a root-level blob in the tree at refs/user/<owner>/HEAD
# recording {name, enabled}. The plane manifest is authoritative; the local
# `.usage.json` `sync` flag is only the editable intent (reconciled FROM it on
# pull, TO it on push). Shape MUST match gateway-gateway src/sync/manifest.ts.
# `sync-manifest`: per-skill opt-in is CONTENT in the object model, not a device-local flag --
# a root-level blob in the tree at refs/user/<owner>/HEAD recording {name, enabled}. The plane
# manifest is authoritative; the local `.usage.json` `sync` flag is only the editable intent
# (reconciled FROM it on pull, TO it on push). Shape MUST match gateway-gateway src/sync/manifest.ts.
SYNC_MANIFEST_ENTRY_NAME = "sync-manifest"
SYNC_MANIFEST_TYPE = "sync-manifest"
SYNC_MANIFEST_VERSION = 1
# Content addressing. The wire uses the FULL 64-hex sha256 -- a different
# namespace from the truncated 16-hex local `content_hash` (skills_guard.py).
# Content addressing: the wire uses the FULL 64-hex sha256 -- a different namespace from the
# truncated 16-hex local `content_hash` (skills_guard.py).
def wire_address(data: bytes) -> str:
"""Return ``sha256:<64-hex>`` -- the wire address of ``data``."""
"""``sha256:<64-hex>`` -- the wire address of ``data``."""
return "sha256:" + hashlib.sha256(data).hexdigest()
def canonical_json_bytes(obj: Dict[str, Any]) -> bytes:
"""Canonical JSON for tree/commit hashing: UTF-8, sorted keys, no whitespace,
no trailing newline. Arrays must already be in contract order (tree entries
by name, commit parents by significance). Client and server MUST produce
byte-identical output or a push fails ``422 hash_mismatch``."""
"""Canonical JSON for tree/commit hashing: UTF-8, sorted keys, no whitespace, no trailing
newline. Arrays must already be in contract order (tree entries by name, commit parents by
significance). Client and server MUST agree byte-for-byte or a push fails ``422 hash_mismatch``."""
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def build_sync_manifest_bytes(skills: Dict[str, bool]) -> bytes:
"""Serialize ``{name: enabled}`` into canonical ``sync-manifest`` bytes
(entries sorted by name for a stable content address)."""
"""Canonical ``sync-manifest`` bytes for ``{name: enabled}`` (sorted by name for a stable address)."""
return canonical_json_bytes({
"type": SYNC_MANIFEST_TYPE, "version": SYNC_MANIFEST_VERSION,
"skills": [{"name": name, "enabled": bool(enabled)} for name, enabled in sorted(skills.items())],
@@ -72,20 +67,15 @@ def build_sync_manifest_bytes(skills: Dict[str, bool]) -> bytes:
def parse_sync_manifest(data: bytes) -> Optional[Dict[str, bool]]:
"""Parse ``sync-manifest`` bytes into ``{name: enabled}``, or None if malformed.
Strict (mirrors gateway-gateway ``parseSyncManifest``): unknown type, version
!= 1, non-list skills, or a malformed entry all reject -- a malformed
manifest must not be mistaken for "no skills opted in".
"""
Strict (mirrors gateway-gateway ``parseSyncManifest``): unknown type, version != 1, non-list
skills or a malformed entry all reject -- a bad manifest must not read as "nothing opted in"."""
try:
value = json.loads(data.decode("utf-8"))
except Exception:
return None
if (
not isinstance(value, dict)
or value.get("type") != SYNC_MANIFEST_TYPE
or value.get("version") != SYNC_MANIFEST_VERSION
or not isinstance(value.get("skills"), list)
not isinstance(value, dict) or value.get("type") != SYNC_MANIFEST_TYPE
or value.get("version") != SYNC_MANIFEST_VERSION or not isinstance(value.get("skills"), list)
):
return None
out: Dict[str, bool] = {}
@@ -135,10 +125,9 @@ def _file_mode(path: Path) -> str:
def build_tree(dir_path: Path, objects: ObjectSet, *, max_object_bytes: int) -> str:
"""Recursively build objects for *dir_path*; return the tree address. Files
-> blobs, subdirs -> nested trees; symlinks/special files are skipped
(contract: no symlinks). A blob over *max_object_bytes* raises ValueError
so the caller can surface / skip the artifact (server -> 413)."""
"""Recursively build objects for *dir_path*; return the tree address. Files -> blobs, subdirs
-> nested trees; symlinks/special files are skipped (contract: no symlinks). A blob over
*max_object_bytes* raises ValueError so the caller can surface / skip the artifact (server -> 413)."""
entries: List[Dict[str, str]] = []
for child in sorted(dir_path.iterdir(), key=lambda p: p.name):
if child.is_symlink():
@@ -149,36 +138,27 @@ def build_tree(dir_path: Path, objects: ObjectSet, *, max_object_bytes: int) ->
elif child.is_file():
data = child.read_bytes()
if len(data) > max_object_bytes:
raise ValueError(
f"file {child} is {len(data)} bytes > max_object_bytes {max_object_bytes}"
)
raise ValueError(f"file {child} is {len(data)} bytes > max_object_bytes {max_object_bytes}")
entries.append(_entry(child.name, KIND_BLOB, objects.add(KIND_BLOB, data), _file_mode(child)))
return _add_tree(entries, objects)
def build_commit(
tree_hash: str, parents: List[str], *, owner: str, device: str, message: str,
objects: ObjectSet, ts: Optional[str] = None,
) -> str:
"""Build a commit object and return its address.
``parents``: 0 for the first commit, 1 for an edit, 2 for a merge (order
significant: parents[0] = base fast-forwarded from, parents[1] = other head).
"""
objects: ObjectSet, ts: Optional[str] = None) -> str:
"""Build a commit object and return its address. ``parents``: 0 for the first commit, 1 for
an edit, 2 for a merge (parents[0] = base fast-forwarded from, parents[1] = other head)."""
return objects.add(KIND_COMMIT, canonical_json_bytes({
"type": KIND_COMMIT, "tree": tree_hash, "parents": list(parents),
"author": {"owner": owner, "device": device},
"ts": ts or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"message": message, "artifact_type": ARTIFACT_TYPE_SKILL,
}))
"message": message, "artifact_type": ARTIFACT_TYPE_SKILL}))
def build_root_tree(node: Dict[str, Any], objects: ObjectSet, *, manifest_hash: Optional[str] = None) -> str:
"""Canonicalize a nested ``{name: {"__tree__": hash} | subdict}`` root into trees.
``manifest_hash`` (top level only) adds the root ``sync-manifest`` BLOB entry.
It cannot collide with a skill dir: skill entries are trees, this is a blob.
"""
``manifest_hash`` (top level only) adds the root ``sync-manifest`` BLOB entry; it cannot
collide with a skill dir because skill entries are trees."""
entries: List[Dict[str, str]] = []
for name, child in node.items():
if isinstance(child, dict) and "__tree__" in child and len(child) == 1:
@@ -199,11 +179,8 @@ def nest_skill_tree(root: Dict[str, Any], rel_parts: Tuple[str, ...], tree_hash:
def assemble_root_from_skill_trees(skill_trees: Dict[str, str], objects: ObjectSet) -> str:
"""Build a profile-root tree from ``{posix_rel_path: tree_hash}``.
The skill trees are assumed already durable (they came from either side of
a merge / the org HEAD); only the new intermediate/root trees are added.
"""
"""Profile-root tree from ``{posix_rel_path: tree_hash}``. The skill trees are assumed already
durable (either side of a merge / the org HEAD); only the new intermediate/root trees are added."""
root: Dict[str, Any] = {}
for path, tree_hash in skill_trees.items():
nest_skill_tree(root, PurePosixPath(path).parts, tree_hash)
@@ -221,19 +198,14 @@ class SyncError(RuntimeError):
class SyncConflict(RuntimeError):
"""CAS lost (409). NOT a rejection -- pushed objects are already durable.
``actual`` is the current head to merge against, or None when the ref does
not exist server-side (the server sends ""). None means "retry as a create";
it must never be fetched as an object -- normalized here, not per call site.
"""
"""CAS lost (409). NOT a rejection -- pushed objects are already durable. ``actual`` is the
current head to merge against, or None when the ref does not exist server-side (the server
sends ""): "retry as a create"; it must never be fetched as an object -- normalized here."""
def __init__(self, actual: Optional[str]):
self.actual: Optional[str] = actual or None
super().__init__(
f"CAS conflict; actual head {self.actual}"
if self.actual
else "CAS conflict; the ref does not exist yet"
f"CAS conflict; actual head {self.actual}" if self.actual else "CAS conflict; the ref does not exist yet"
)
@@ -243,17 +215,19 @@ def _check_version(caps: Dict[str, Any]) -> None:
if ver.split(".", 1)[0] != WIRE_VERSION:
raise SyncError(
f"this server speaks sync version {ver!r}, but this Hermes speaks "
f"{WIRE_VERSION} — update Hermes to sync with it"
)
f"{WIRE_VERSION} — update Hermes to sync with it")
def _body(r) -> Dict[str, Any]:
return r.json() if r.content else {}
class SyncClient:
"""Sync client bound to a base URL + Nous bearer.
Org refs/objects live behind SEPARATE ``org/`` routes, not a prefix filter:
the personal routes are hard-scoped to the token's owner and would silently
answer an org query with personal data. Callers reading org content MUST
pass ``org_scope=True`` on every hop.
Org refs/objects live behind SEPARATE ``org/`` routes, not a prefix filter: the personal
routes are hard-scoped to the token's owner and would silently answer an org query with
personal data. Callers reading org content MUST pass ``org_scope=True`` on every hop.
"""
def __init__(self, base_url: str, api_key: str, *, timeout: float = 30.0):
@@ -268,41 +242,34 @@ class SyncClient:
def _url(self, path: str) -> str:
return f"{self.base}/v1/sync/{path.lstrip('/')}"
@staticmethod
def _check(r, op: str, ok=(200,), errors: Optional[Dict[int, str]] = None) -> None:
"""Raise SyncError unless the status is in *ok*; *errors* maps specific
statuses to their message, anything else gets ``"<op> failed: <code>"``."""
def _request(self, method: str, path: str, op: str, *, ok=(200,), errors: Optional[Dict[int, Any]] = None, **kw):
"""One wire call. Raises SyncError unless the status is in *ok*; *errors* maps specific
statuses to a message (str or ``fn(response)``), anything else gets ``"<op> failed: <code>"``."""
r = self._session.request(method, self._url(path), timeout=self.timeout, **kw)
if r.status_code in ok:
return
return r
msg = (errors or {}).get(r.status_code)
if callable(msg):
msg = msg(r)
raise SyncError(msg or f"{op} failed: {r.status_code}", status=r.status_code)
def capabilities(self) -> Dict[str, Any]:
"""GET capabilities (no auth required)."""
r = self._session.get(self._url("capabilities"), timeout=self.timeout)
self._check(r, "capabilities")
return r.json()
return self._request("GET", "capabilities", "capabilities").json()
def get_refs(self, prefix: str, *, org_scope: bool = False) -> List[Dict[str, str]]:
"""GET refs?prefix=... (or org/refs, filtered client-side by *prefix*)."""
path = "org/refs" if org_scope else "refs"
params = None if org_scope else {"prefix": prefix}
r = self._session.get(self._url(path), params=params, timeout=self.timeout)
self._check(r, "get_refs")
refs = (r.json() or {}).get("refs", [])
path, params = ("org/refs", None) if org_scope else ("refs", {"prefix": prefix})
refs = (self._request("GET", path, "get_refs", params=params).json() or {}).get("refs", [])
if org_scope:
refs = [r_ for r_ in refs if str(r_.get("name", "")).startswith(prefix)]
return refs
def get_object(self, obj_hash: str, *, org_scope: bool = False) -> Tuple[str, bytes]:
"""GET objects/:hash -> ``(kind, bytes)``. Kind comes from the object-type
header; a blob (octet-stream) is returned as ``blob``."""
path = f"org/objects/{obj_hash}" if org_scope else f"objects/{obj_hash}"
r = self._session.get(self._url(path), timeout=self.timeout)
self._check(
r, "get_object",
errors={404: f"object {obj_hash} not found", 403: f"object {obj_hash} not readable"},
)
"""GET objects/:hash -> ``(kind, bytes)``; kind from the object-type header, blob default."""
r = self._request(
"GET", f"org/objects/{obj_hash}" if org_scope else f"objects/{obj_hash}", "get_object",
errors={404: f"object {obj_hash} not found", 403: f"object {obj_hash} not readable"})
return r.headers.get("X-HSP-Object-Type") or KIND_BLOB, r.content
def _get_json_of_kind(self, obj_hash: str, expected: str, org_scope: bool) -> Dict[str, Any]:
@@ -318,39 +285,30 @@ class SyncClient:
return self._get_json_of_kind(tree_hash, KIND_TREE, org_scope)
def put_objects(self, objects: Dict[str, Tuple[str, bytes]], *, org_scope: bool = False) -> Dict[str, Any]:
"""POST objects -- batch upload as multipart/form-data: field name = the
claimed ``sha256:<hex>``, ``filename`` = object type, body = raw bytes
(the contract requires raw bytes, not base64-in-JSON). The server
recomputes every hash and rejects the whole batch with 422 on mismatch;
known hashes are idempotent no-ops. ``org_scope`` adds ``?scope=org`` so
objects land org-readable (required before an org CAS/propose)."""
"""POST objects -- batch multipart upload: field name = the claimed ``sha256:<hex>``,
``filename`` = object type, body = raw bytes (the contract forbids base64-in-JSON). The
server recomputes every hash and rejects the whole batch with 422 on mismatch; known
hashes are idempotent no-ops. ``org_scope`` adds ``?scope=org`` so objects land
org-readable (required before an org CAS/propose)."""
files = [(h, (kind, data, "application/octet-stream")) for h, (kind, data) in objects.items()]
r = self._session.post(
self._url("objects"), files=files, params={"scope": "org"} if org_scope else None, timeout=self.timeout
)
self._check(
r, "put_objects", ok=(200, 201),
errors={413: "object too large (413)", 422: f"hash_mismatch (422): {r.text}"},
)
return r.json() if r.content else {}
r = self._request(
"POST", "objects", "put_objects", ok=(200, 201), files=files,
params={"scope": "org"} if org_scope else None,
errors={413: "object too large (413)", 422: lambda r: f"hash_mismatch (422): {r.text}"})
return _body(r)
def cas_ref(self, name: str, from_hash: Optional[str], to_hash: str) -> Dict[str, Any]:
"""POST refs/:name -- atomic compare-and-swap. Raises SyncConflict on 409.
A non-admin member's CAS on an org HEAD is converted server-side to a
proposal (202) and surfaced as ``{"proposal_pending": True, ...}``: a
SUCCESS-shaped outcome that must never be presented as live/merged.
"""
r = self._session.post(
self._url(f"refs/{name}"), json={"from": from_hash, "to": to_hash}, timeout=self.timeout
)
"""POST refs/:name -- atomic compare-and-swap. Raises SyncConflict on 409. A non-admin
member's CAS on an org HEAD is converted server-side to a proposal (202) and surfaced as
``{"proposal_pending": True, ...}``: SUCCESS-shaped but never to be presented as live/merged."""
r = self._request(
"POST", f"refs/{name}", "cas_ref", ok=(200, 202, 409), json={"from": from_hash, "to": to_hash},
errors={403: "forbidden (403) -- owner/permission"})
if r.status_code == 202:
return {"proposal_pending": True, **(r.json() if r.content else {})}
if r.status_code == 409:
# "" actual = the ref does not exist server-side (SyncConflict -> None).
return {"proposal_pending": True, **_body(r)}
if r.status_code == 409: # "" actual = the ref does not exist server-side (-> None)
raise SyncConflict((r.json() or {}).get("actual", ""))
self._check(r, "cas_ref", errors={403: "forbidden (403) -- owner/permission"})
return r.json() if r.content else {}
return _body(r)
# Reading remote trees
@@ -366,9 +324,8 @@ def root_tree_of_commit(client: SyncClient, commit_hash: str, *, org_scope: bool
def skill_trees_of_root(client: SyncClient, root_tree_hash: str, *, org_scope: bool = False) -> Dict[str, str]:
"""Flatten a profile-root tree into ``{posix_rel_path: skill_tree_hash}``. A
skill tree is any subtree containing a ``SKILL.md`` blob, keyed by its path
so category nesting is preserved."""
"""Flatten a profile-root tree into ``{posix_rel_path: skill_tree_hash}``: a skill tree is any
subtree containing a ``SKILL.md`` blob, keyed by its path so category nesting is preserved."""
result: Dict[str, str] = {}
def _walk(tree_hash: str, prefix: str) -> None:
@@ -385,8 +342,8 @@ def skill_trees_of_root(client: SyncClient, root_tree_hash: str, *, org_scope: b
def read_manifest_of_root(client: SyncClient, root_tree_hash: str) -> Optional[Dict[str, bool]]:
"""``{name: enabled}`` from the root-level ``sync-manifest`` blob, or None if
absent/malformed. This is how a device learns another device's opt-ins."""
"""``{name: enabled}`` from the root-level ``sync-manifest`` blob, or None if absent/malformed.
This is how a device learns another device's opt-ins."""
try:
tree = client.get_tree_json(root_tree_hash)
except Exception as e:
@@ -404,9 +361,9 @@ def read_manifest_of_root(client: SyncClient, root_tree_hash: str) -> Optional[D
def materialize_tree(client: SyncClient, tree_hash: str, dest: Path, *, org_scope: bool = False) -> None:
"""Write the tree at *tree_hash* into *dest* (created if needed): blobs ->
files (+x restored for ``exec``), trees -> subdirectories. Does NOT delete
files absent from the tree (caller decides). Refuses path traversal."""
"""Write the tree at *tree_hash* into *dest* (created if needed): blobs -> files (+x restored
for ``exec``), trees -> subdirectories. Does NOT delete files absent from the tree (caller
decides). Refuses path traversal."""
dest.mkdir(parents=True, exist_ok=True)
for entry in client.get_tree_json(tree_hash, org_scope=org_scope).get("entries", []):
name = entry.get("name", "")
@@ -426,10 +383,9 @@ def materialize_tree(client: SyncClient, tree_hash: str, dest: Path, *, org_scop
def merge_skill(base: Optional[str], ours: Optional[str], theirs: Optional[str]) -> str:
"""Three-way decision for one skill's tree hash: ``ours`` / ``theirs`` /
``either`` / ``overlap`` / ``none``. A side "modified" the skill when its
hash differs from the common base (same semantics as skills_sync.py's
origin/user/incoming block)."""
"""Three-way decision for one skill's tree hash: ``ours`` / ``theirs`` / ``either`` / ``overlap``
/ ``none``. A side "modified" the skill when its hash differs from the common base (same
semantics as skills_sync.py's origin/user/incoming block)."""
if ours == theirs:
return "either" if ours is not None else "none"
if theirs == base: # only we moved
+39 -99
View File
@@ -1,20 +1,13 @@
"""Shared threat-pattern library for context window security scanning.
Single source of truth for prompt-injection / promptware / exfiltration
patterns used by ``agent/prompt_builder.py``, ``tools/memory_tool.py`` and
``agent/tool_dispatch_helpers.py``.
Each pattern is a ``(regex, pattern_id, scope)`` tuple. Scope controls which
scanners use it: ``"all"`` everywhere; ``"context"`` adds promptware / C2 /
role hijack for context files, memory and tool results (warn-level, since
tool results contain content the user did not author); ``"strict"`` adds
aggressive checks only for user-mediated writes (memory, skill installs)
where blocking can be resolved interactively.
New patterns must anchor on C2-specific vocabulary or unambiguous attack
behavior, NOT bossy English ("you must", "you are obligated to" are common in
legitimate AGENTS.md / CLAUDE.md content). Filler between key tokens is the
bounded ``_FILLER`` — unbounded ``(?:\\w+\\s+)*`` backtracks badly.
Single source of truth for prompt-injection / promptware / exfiltration patterns
(``agent/prompt_builder.py``, ``tools/memory_tool.py``, ``agent/tool_dispatch_helpers.py``).
Each pattern is ``(regex, pattern_id, scope)``. Scope is cumulative: ``"all"`` everywhere;
``"context"`` adds promptware / C2 / role hijack for context files, memory and tool results
(warn-level: tool results carry content the user did not author); ``"strict"`` adds aggressive
checks only for user-mediated writes (memory, skill installs) where a block is resolvable.
New patterns must anchor on C2-specific vocabulary or unambiguous attack behavior, NOT bossy
English ("you must" is common in legitimate AGENTS.md); filler is the bounded ``_FILLER``.
"""
from __future__ import annotations
@@ -23,19 +16,17 @@ import re
import unicodedata
from typing import List, Optional, Tuple
# Hard cap on scanned text: scanners are advisory guards, and bounding input
# keeps worst-case runtime predictable while catching injection near the start.
# Hard cap on scanned text: scanners are advisory, so bound worst-case runtime.
MAX_SCAN_CHARS = 65_536
# Bounded filler between key attack words (up to eight words of obfuscation).
# Bounded filler between key attack words (unbounded ``(?:\w+\s+)*`` backtracks badly).
_FILLER = r"(?:\w+\s+){0,8}"
# Env var reference ending in a secret-ish suffix (see exfil comment below).
_SECRET_VAR = r"\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)S?\b"
# Verb prefix for "modify agent config" patterns.
_MODIFY = r"(update|modify|edit|write|change|append|add\s+to)\s+[^\n]{0,2048}"
# Each entry: (regex, pattern_id, scope)
# scope ∈ {"all", "context", "strict"}
# (regex, pattern_id, scope); scope ∈ {"all", "context", "strict"}
_PATTERNS: List[Tuple[str, str, str]] = [
# ── Classic prompt injection (applies everywhere) ────────────────
(rf'ignore\s+{_FILLER}(previous|all|above|prior)\s+{_FILLER}instructions', "prompt_injection", "all"),
@@ -47,57 +38,41 @@ _PATTERNS: List[Tuple[str, str, str]] = [
(r'translate\s+[^\n]{0,512}\s+into\s+[^\n]{0,512}\s+and\s+(execute|run|eval)', "translate_execute", "all"),
(rf'do\s+not\s+{_FILLER}tell\s+{_FILLER}the\s+user', "deception_hide", "all"),
# ── Role-play / identity hijack (context + strict; common attack
# surface in scraped web content and poisoned context files) ──
# ── Role-play / identity hijack (scraped web content, poisoned context files) ──
(rf'you\s+are\s+{_FILLER}now\s+(?:a|an|the)\s+', "role_hijack", "context"),
(rf'pretend\s+{_FILLER}(you\s+are|to\s+be)\s+', "role_pretend", "context"),
(rf'output\s+{_FILLER}(system|initial)\s+prompt', "leak_system_prompt", "context"),
(rf'(respond|answer|reply)\s+without\s+{_FILLER}(restrictions|limitations|filters|safety)', "remove_filters", "context"),
(rf'you\s+have\s+been\s+{_FILLER}(updated|upgraded|patched)\s+to', "fake_update", "context"),
# "name yourself X" is a Brainworm-specific tell — identity override
# via spec instead of jailbreak. Anchored on the verb pair so it
# doesn't match "name your variables" etc.
# Brainworm tell: identity override via spec. Verb pair anchored so "name your variables" is safe.
(r'\bname\s+yourself\s+\w+', "identity_override", "context"),
# ── C2 / Brainworm-style promptware (context scope) ──────────────
# These anchor on C2-specific vocabulary. "register as a node" appears
# in legitimate distributed-systems docs, but in combination with the
# other patterns the signal is strong; we WARN, not block, so a security
# researcher reading the Brainworm post in a webpage doesn't break their
# session.
# Anchored on C2 vocabulary. "register as a node" appears in legitimate distributed-systems
# docs, so this is WARN not block: a researcher reading the Brainworm post keeps their session.
(r'register\s+(as\s+)?a?\s*node', "c2_node_registration", "context"),
(r'(heartbeat|beacon|check[\s\-]?in)\s+(to|with)\s+', "c2_heartbeat", "context"),
(r'pull\s+(down\s+)?(?:new\s+)?task(?:ing|s)?\b', "c2_task_pull", "context"),
(r'connect\s+to\s+the\s+network\b', "c2_network_connect", "context"),
# Verb-anchored "you must register/connect/report/beacon" — the verbs
# are C2-specific so this avoids the broader "you must X" false positive.
# C2-specific verbs avoid the broader "you must X" false positive.
(r'you\s+must\s+(?:\w+\s+){0,3}(register|connect|report|beacon)\b', "forced_action", "context"),
# Anti-forensic instructions ("never write to disk", "one-liners only")
# — extremely unusual in legitimate content; near-zero false positive.
# Anti-forensic instructions: near-zero false positive in legitimate content.
(r'only\s+use\s+one[\s\-]?liners?\b', "anti_forensic_oneliner", "context"),
(rf'never\s+{_FILLER}(?:create|write)\s+{_FILLER}(?:script|file)\s+{_FILLER}disk', "anti_forensic_disk", "context"),
# Environment-variable unsetting targeting known agent runtimes —
# this is pure attack behavior (Brainworm sub-session bypass).
# Unsetting agent-runtime env vars is pure attack behavior (Brainworm sub-session bypass).
(r'unset\s+\w*(?:CLAUDE|CODEX|HERMES|AGENT|OPENAI|ANTHROPIC)\w*', "env_var_unset_agent", "context"),
# ── Known C2 / red-team framework names (near-zero false positive
# outside security research; warn-only by default) ─────────────
# NOTE: do not add common English words here. Every token must be a
# distinctive offensive-security tool brand, otherwise legitimate
# AGENTS.md / SOUL.md content false-positives and the whole file is
# blocked. "praxis" was removed for exactly this reason — it's a common
# word and a legitimate agent name (Greek for practice/action), not a
# C2-specific tell like the brands below.
# ── Known C2 / red-team framework names (warn-only) ─────────────
# Every token must be a distinctive offensive-security brand: a common English word here
# (e.g. "praxis", also a legitimate agent name) false-positives whole AGENTS.md / SOUL.md files.
(r'\b(?:cobalt\s*strike|sliver|havoc|mythic|metasploit|brainworm)\b', "known_c2_framework", "context"),
(r'\bc2\s+(?:server|channel|infrastructure|beacon)\b', "c2_explicit", "context"),
(r'\bcommand\s+and\s+control\b', "c2_explicit_long", "context"),
# ── Exfiltration via curl/wget/cat with secrets (applies everywhere) ──
# Anchor env var name end with \b to avoid false positives on legitimate
# env vars like $TRILLIUM_ETAPI_URL that contain KEY/TOKEN/API as
# substrings. API is dropped from the alternation outright: mid-name API
# is ubiquitous in benign var names, and every real secret shape it
# caught ($OPENAI_API_KEY) already ends in KEY/TOKEN.
# The var name ends with \b so benign names containing KEY/TOKEN as substrings
# ($TRILLIUM_ETAPI_URL) pass. API is deliberately absent: mid-name API is ubiquitous in
# benign vars, and every real secret it caught ($OPENAI_API_KEY) already ends in KEY/TOKEN.
(rf'curl\s+[^\n]{{0,2048}}{_SECRET_VAR}', "exfil_curl", "all"),
(rf'wget\s+[^\n]{{0,2048}}{_SECRET_VAR}', "exfil_wget", "all"),
(r'cat\s+[^\n]{0,2048}(\.env|credentials|\.netrc|\.pgpass|\.npmrc|\.pypirc)', "read_secrets", "all"),
@@ -115,34 +90,14 @@ _PATTERNS: List[Tuple[str, str, str]] = [
(r'(?:api[_-]?key|token|secret|password)\s*[=:]\s*["\'][A-Za-z0-9+/=_-]{20,}', "hardcoded_secret", "strict"),
]
# Invisible / bidirectional unicode characters used in injection attacks.
# Aligned with skills_guard.py INVISIBLE_CHARS — directional isolates
# (U+2066-U+2069) and invisible math operators (U+2062-U+2064) are real
# attack tools.
INVISIBLE_CHARS = frozenset({
'\u200b', # zero-width space
'\u200c', # zero-width non-joiner
'\u200d', # zero-width joiner
'\u2060', # word joiner
'\u2062', # invisible times
'\u2063', # invisible separator
'\u2064', # invisible plus
'\ufeff', # zero-width no-break space (BOM)
'\u202a', # left-to-right embedding
'\u202b', # right-to-left embedding
'\u202c', # pop directional formatting
'\u202d', # left-to-right override
'\u202e', # right-to-left override
'\u2066', # left-to-right isolate
'\u2067', # right-to-left isolate
'\u2068', # first strong isolate
'\u2069', # pop directional isolate
})
# Invisible / bidirectional unicode used in injection attacks (aligned with skills_guard.py
# INVISIBLE_CHARS): zero-width space/non-joiner/joiner, word joiner, invisible times/separator/
# plus, BOM, LTR/RTL embedding + pop + overrides, LTR/RTL/first-strong isolates + pop.
INVISIBLE_CHARS = frozenset(
"\u200b\u200c\u200d\u2060\u2062\u2063\u2064\ufeff"
"\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069")
# Compiled pattern sets by scope, built once at import. Scope inclusion is
# cumulative: "all" patterns land in every set, "context" in context + strict,
# "strict" in strict only.
# Compiled per scope at import; inclusion is cumulative (all ⊂ context ⊂ strict).
_SCOPE_SETS = {"all": ("all", "context", "strict"), "context": ("context", "strict"), "strict": ("strict",)}
@@ -161,25 +116,16 @@ _COMPILED = _compile()
def scan_for_threats(content: str, scope: str = "context") -> List[str]:
"""Return matched pattern IDs in ``content`` for ``scope`` (see module docstring).
Invisible unicode characters are reported as ``"invisible_unicode_U+XXXX"``
so callers can surface the offending codepoint.
"""
"""Matched pattern IDs in ``content`` for ``scope``; invisible codepoints are
reported as ``"invisible_unicode_U+XXXX"``. Raises ValueError on an unknown scope."""
if not content:
return []
content = content[:MAX_SCAN_CHARS]
# Invisible unicode is checked on the RAW content: NFKC normalisation below
# can strip some of these codepoints.
# Invisible unicode is checked on the RAW content: NFKC below can strip these codepoints.
findings: List[str] = [f"invisible_unicode_U+{ord(ch):04X}" for ch in set(content) & INVISIBLE_CHARS]
# NFKC folds full-width / compatibility variants (cat → cat) so homograph
# substitution can't bypass keyword checks. It does NOT fold cross-script
# confusables (Cyrillic ``а`` U+0430) — that would need a TR#39 database.
# NFKC folds full-width / compatibility variants (cat → cat) against homograph bypass.
# It does NOT fold cross-script confusables (Cyrillic ``а``) — that needs a TR#39 database.
normalised = unicodedata.normalize("NFKC", content)
patterns = _COMPILED.get(scope)
if patterns is None:
raise ValueError(f"scan_for_threats: unknown scope {scope!r}")
@@ -188,7 +134,7 @@ def scan_for_threats(content: str, scope: str = "context") -> List[str]:
def first_threat_message(content: str, scope: str = "strict") -> Optional[str]:
"""Return a user-facing error for the first threat found, or None (block-on-first-hit paths)."""
"""User-facing error for the first threat found, or None (block-on-first-hit paths)."""
findings = scan_for_threats(content, scope=scope)
if not findings:
return None
@@ -199,13 +145,7 @@ def first_threat_message(content: str, scope: str = "strict") -> Optional[str]:
return (
f"Blocked: content matches threat pattern '{pid}'. "
f"Content is injected into the system prompt and must not contain "
f"injection or exfiltration payloads."
)
f"injection or exfiltration payloads.")
__all__ = [
"INVISIBLE_CHARS",
"MAX_SCAN_CHARS",
"scan_for_threats",
"first_threat_message",
]
__all__ = ["INVISIBLE_CHARS", "MAX_SCAN_CHARS", "scan_for_threats", "first_threat_message"]
+88 -198
View File
@@ -1,17 +1,12 @@
"""Tirith pre-exec security scanning wrapper.
Runs the tirith binary as a subprocess to scan commands for content-level
threats (homograph URLs, pipe-to-interpreter, terminal injection, etc.).
Exit code is the verdict source of truth: 0 = allow, 1 = block, 2 = warn.
JSON stdout enriches findings/summary but never overrides the verdict.
Operational failures (spawn error, timeout, unknown exit code) respect the
fail_open config setting. Programming errors propagate.
Auto-install: if tirith is not on PATH or at the configured path it is
downloaded from GitHub releases to $HERMES_HOME/bin/tirith in a background
thread. SHA-256 is always verified; cosign provenance is verified when cosign
is on PATH.
Runs the tirith binary as a subprocess to scan commands for content-level threats
(homograph URLs, pipe-to-interpreter, terminal injection, ...). The exit code is the
verdict source of truth (0 allow, 1 block, 2 warn); JSON stdout only enriches findings.
Operational failures (spawn error, timeout, unknown exit) respect ``fail_open``;
programming errors propagate. Auto-install: if tirith is not on PATH / the configured
path it is downloaded from GitHub releases to $HERMES_HOME/bin/tirith in a background
thread. SHA-256 is always verified; cosign provenance when cosign is on PATH.
"""
import hashlib
@@ -33,8 +28,7 @@ from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
_REPO = "sheeki03/tirith"
# Cosign provenance verification — pinned to the specific release workflow
# Cosign provenance pinned to the release workflow, not the whole repo.
_COSIGN_IDENTITY_REGEXP = f"^https://github.com/{_REPO}/\\.github/workflows/release\\.yml@refs/tags/v"
_COSIGN_ISSUER = "https://token.actions.githubusercontent.com"
@@ -47,64 +41,54 @@ def _env_bool(key: str, default: bool) -> bool:
def _env_int(key: str, default: int) -> int:
val = os.getenv(key)
if val is None:
return default
try:
return int(val)
return default if val is None else int(val)
except ValueError:
return default
def _load_security_config() -> dict:
"""Load security settings from config.yaml, with env var overrides."""
"""Security settings from config.yaml, with env var overrides."""
try:
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly().get("security", {}) or {}
except Exception:
cfg = {}
return {
"tirith_enabled": _env_bool("TIRITH_ENABLED", cfg.get("tirith_enabled", True)),
"tirith_path": os.getenv("TIRITH_BIN", cfg.get("tirith_path", "tirith")),
"tirith_timeout": _env_int("TIRITH_TIMEOUT", cfg.get("tirith_timeout", 5)),
"tirith_fail_open": _env_bool("TIRITH_FAIL_OPEN", cfg.get("tirith_fail_open", True)),
}
"tirith_fail_open": _env_bool("TIRITH_FAIL_OPEN", cfg.get("tirith_fail_open", True))}
# --- Module state ---
# Cached path after first resolution. _INSTALL_FAILED means "we tried and
# failed" — distinct from None ("not yet tried") — so we don't retry per command.
# Cached path after first resolution. _INSTALL_FAILED means "tried and failed" (distinct
# from None = "not yet tried") so a failed install is not retried per command.
_resolved_path: str | None | bool = None
_INSTALL_FAILED = False
_install_failure_reason: str = "" # reason tag when _resolved_path is _INSTALL_FAILED
# Circuit breaker: after _CRASH_LIMIT consecutive spawn/execution failures,
# disable tirith for the rest of the process so a broken binary can't turn
# every tool call into a fail-open retry loop that hangs the user for minutes.
# Reset on success. Lock-free on purpose (like mcp_tool.py error counters): a
# racing double-increment only opens the breaker one call early; no corruption
# or security bypass is possible.
# Circuit breaker: after _CRASH_LIMIT consecutive spawn/execution failures tirith is disabled
# for the rest of the process so a broken binary can't turn every tool call into a fail-open
# retry loop. Reset on success. Lock-free on purpose: a racing double-increment only opens the
# breaker one call early; no corruption or security bypass is possible.
_CRASH_LIMIT = 3
_crash_count: int = 0
_circuit_open: bool = False
# Background install thread coordination
_install_lock = threading.Lock()
_install_thread: threading.Thread | None = None
# Warning de-duplication: spawn/path warnings are in the hot path and would
# otherwise repeat once per terminal command while tirith is unavailable
# (e.g. Windows with the install thread still running fills errors.log).
# Warn-once: spawn/path warnings sit in the hot path and would otherwise repeat once per
# terminal command while tirith is unavailable (e.g. install thread still running).
_warned_messages: set[str] = set()
_warned_lock = threading.Lock()
# Disk-persistent failure marker — avoids retry across process restarts
_MARKER_TTL = 86400 # 24 hours
_MARKER_TTL = 86400 # disk failure marker validity (24h) -- avoids retry across restarts
def _record_tirith_crash() -> None:
"""Increment the crash counter and open the circuit breaker if needed."""
global _crash_count, _circuit_open
_crash_count += 1
if _crash_count >= _CRASH_LIMIT:
@@ -112,8 +96,7 @@ def _record_tirith_crash() -> None:
logger.warning(
"tirith circuit breaker opened after %d consecutive failures; "
"disabling for the rest of the process",
_crash_count,
)
_crash_count)
def _warn_once(key: str, message: str, *args) -> None:
@@ -132,7 +115,7 @@ def _reset_spawn_warning_state() -> None:
def _cached_path() -> str | None:
"""Fast path: the path resolved on a previous call, or None if unresolved/failed."""
"""The path resolved on a previous call, or None if unresolved/failed."""
if _resolved_path is None or _resolved_path is _INSTALL_FAILED:
return None
return _resolved_path
@@ -140,14 +123,12 @@ def _cached_path() -> str | None:
def _set_resolved(path: str) -> None:
global _resolved_path, _install_failure_reason
_resolved_path = path
_install_failure_reason = ""
_resolved_path, _install_failure_reason = path, ""
def _set_failed(reason: str) -> None:
global _resolved_path, _install_failure_reason
_resolved_path = _INSTALL_FAILED
_install_failure_reason = reason
_resolved_path, _install_failure_reason = _INSTALL_FAILED, reason
# --- Disk failure marker ---
@@ -157,7 +138,7 @@ def _failure_marker_path() -> str:
def _read_failure_reason() -> str | None:
"""Return the marker's reason, or None if absent or older than _MARKER_TTL."""
"""The marker's reason, or None if absent or older than _MARKER_TTL."""
try:
p = _failure_marker_path()
if (time.time() - os.path.getmtime(p)) >= _MARKER_TTL:
@@ -170,9 +151,7 @@ def _read_failure_reason() -> str | None:
def _is_install_failed_on_disk() -> bool:
"""True if a recent install failure was persisted and is still non-retryable.
A 'cosign_missing' marker is auto-cleared once cosign appears on PATH.
"""
A 'cosign_missing' marker is auto-cleared once cosign appears on PATH."""
reason = _read_failure_reason()
if reason is None:
return False
@@ -203,11 +182,8 @@ def _clear_install_failed():
def _disk_marker_blocks_install() -> bool:
"""Apply a still-valid disk failure marker to module state; True if install must be skipped.
Preserves the marker's real reason so in-memory retry logic can detect
retryable causes (cosign_missing) without a restart.
"""
"""Apply a still-valid disk marker to module state; True if install must be skipped.
Keeps the marker's real reason so in-process retry can detect cosign_missing."""
disk_reason = _read_failure_reason()
if disk_reason is None or not _is_install_failed_on_disk():
return False
@@ -218,21 +194,20 @@ def _disk_marker_blocks_install() -> bool:
# --- Auto-install ---
def _hermes_bin_dir() -> str:
"""Return $HERMES_HOME/bin, creating it if needed."""
"""$HERMES_HOME/bin, created if needed."""
d = os.path.join(str(get_hermes_home()), "bin")
os.makedirs(d, exist_ok=True)
return d
# Rust target triple components. Android (Termux) is ABI-compatible with Linux.
# Windows is absent on purpose — tirith ships no Windows build; callers treat
# None as "never available here" and fall back to pattern-matching guards.
# Rust target triple components. Android (Termux) is ABI-compatible with Linux. Windows is
# absent on purpose (no tirith build): None = "never available here", pattern guards still run.
_TARGET_PLATFORMS = {"Darwin": "apple-darwin", "Linux": "unknown-linux-gnu", "Android": "unknown-linux-gnu"}
_TARGET_ARCHES = {"x86_64": "x86_64", "amd64": "x86_64", "aarch64": "aarch64", "arm64": "aarch64"}
def _detect_target() -> str | None:
"""Return the Rust target triple for this platform, or None if tirith has no build for it."""
"""Rust target triple for this platform, or None if tirith has no build for it."""
plat = _TARGET_PLATFORMS.get(platform.system())
arch = _TARGET_ARCHES.get(platform.machine().lower())
return f"{arch}-{plat}" if plat and arch else None
@@ -244,7 +219,6 @@ def is_platform_supported() -> bool:
def _download_file(url: str, dest: str, timeout: int = 10):
"""Download a URL to a local file."""
req = urllib.request.Request(url)
from agent.secret_scope import get_secret
token = get_secret("GITHUB_TOKEN")
@@ -255,47 +229,32 @@ def _download_file(url: str, dest: str, timeout: int = 10):
def _verify_cosign(checksums_path: str, sig_path: str, cert_path: str) -> bool | None:
"""Verify cosign provenance on checksums.txt.
Returns True (verified), False (cosign rejected it), or None (cosign not
on PATH / failed to execute).
"""
"""Verify cosign provenance on checksums.txt: True verified, False rejected,
None when cosign is not on PATH / failed to execute."""
cosign = shutil.which("cosign")
if not cosign:
logger.info("cosign not found on PATH")
return None
try:
result = subprocess.run(
[cosign, "verify-blob",
"--certificate", cert_path,
"--signature", sig_path,
[cosign, "verify-blob", "--certificate", cert_path, "--signature", sig_path,
"--certificate-identity-regexp", _COSIGN_IDENTITY_REGEXP,
"--certificate-oidc-issuer", _COSIGN_ISSUER,
checksums_path],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
timeout=15,
stdin=subprocess.DEVNULL,
)
"--certificate-oidc-issuer", _COSIGN_ISSUER, checksums_path],
capture_output=True, text=True, encoding='utf-8', errors='replace',
timeout=15, stdin=subprocess.DEVNULL)
except (OSError, subprocess.TimeoutExpired) as exc:
logger.warning("cosign execution failed: %s", exc)
return None
if result.returncode == 0:
logger.info("cosign provenance verification passed")
return True
logger.warning("cosign verification failed (exit %d): %s",
result.returncode, result.stderr.strip())
logger.warning("cosign verification failed (exit %d): %s", result.returncode, result.stderr.strip())
return False
def _verify_release_provenance(base_url: str, tmpdir: str, checksums_path: str, log) -> tuple[bool, str]:
"""Cosign step of the install: returns ``(cosign_verified, failure_reason)``.
Cosign provenance is preferred but not mandatory: only an explicit cosign
rejection aborts (non-empty reason); a missing/broken cosign or missing
signature artifacts fall back to SHA-256 only.
"""
"""Cosign step of the install -> ``(cosign_verified, failure_reason)``. Only an explicit
cosign rejection aborts; missing/broken cosign or artifacts fall back to SHA-256 only."""
if not shutil.which("cosign"):
logger.info("cosign not on PATH — installing tirith with SHA-256 verification only "
"(install cosign for full supply chain verification)")
@@ -325,7 +284,6 @@ def _verify_checksum(archive_path: str, checksums_path: str, archive_name: str)
if not expected:
logger.warning("No checksum entry for %s", archive_name)
return False
sha = hashlib.sha256()
with open(archive_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
@@ -338,7 +296,7 @@ def _verify_checksum(archive_path: str, checksums_path: str, archive_name: str)
def _extract_tirith_binary(tar: tarfile.TarFile, dest_dir: str, log) -> tuple[str | None, str]:
"""Extract the tirith binary from a release archive into dest_dir."""
"""Extract the tirith binary from a release archive into dest_dir -> ``(path, reason)``."""
for member in tar.getmembers():
is_tirith = member.name == "tirith" or member.name.endswith("/tirith")
if not is_tirith or ".." in member.name:
@@ -354,28 +312,20 @@ def _extract_tirith_binary(tar: tarfile.TarFile, dest_dir: str, log) -> tuple[st
with src_file, open(dest_path, "wb") as out:
shutil.copyfileobj(src_file, out)
return dest_path, ""
log("tirith binary not found in archive")
return None, "binary_not_in_archive"
def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]:
"""Download and install tirith to $HERMES_HOME/bin/tirith.
Returns (installed_path, failure_reason); failure_reason is "" on success,
otherwise a short tag the disk marker uses to decide retryability.
"""
"""Download and install tirith to $HERMES_HOME/bin/tirith -> ``(installed_path,
failure_reason)``; the reason ("" on success) is the disk marker's retryability tag."""
log = logger.warning if log_failures else logger.debug
target = _detect_target()
if not target:
logger.info("tirith auto-install: unsupported platform %s/%s",
platform.system(), platform.machine())
logger.info("tirith auto-install: unsupported platform %s/%s", platform.system(), platform.machine())
return None, "unsupported_platform"
archive_name = f"tirith-{target}.tar.gz"
base_url = f"https://github.com/{_REPO}/releases/latest/download"
try:
tmpdir = tempfile.mkdtemp(prefix="tirith-install-")
except OSError as exc:
@@ -384,7 +334,6 @@ def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]:
try:
archive_path = os.path.join(tmpdir, archive_name)
checksums_path = os.path.join(tmpdir, "checksums.txt")
logger.info("tirith not found — downloading latest release for %s...", target)
try:
_download_file(f"{base_url}/{archive_name}", archive_path)
@@ -392,39 +341,33 @@ def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]:
except Exception as exc:
log("tirith download failed: %s", exc)
return None, "download_failed"
cosign_verified, reason = _verify_release_provenance(base_url, tmpdir, checksums_path, log)
if reason:
return None, reason
if not _verify_checksum(archive_path, checksums_path, archive_name):
return None, "checksum_failed"
with tarfile.open(archive_path, "r:gz") as tar:
src, reason = _extract_tirith_binary(tar, tmpdir, log)
if src is None:
return None, reason
dest = os.path.join(_hermes_bin_dir(), "tirith")
try:
shutil.move(src, dest)
except OSError:
# Cross-device move (Docker, NFS): shutil.move's copy2 metadata step
# can raise PermissionError, so fall back to plain copy + chmod.
# Cross-device move (Docker, NFS): copy2's metadata step can raise PermissionError,
# so fall back to plain copy + chmod; a partial dest is removed to avoid a
# non-executable retry loop.
try:
shutil.copy(src, dest)
except OSError:
# Clean up partial dest to prevent a non-executable retry loop
try:
os.unlink(dest)
except OSError:
pass
return None, "cross_device_copy_failed"
os.chmod(dest, os.stat(dest).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
logger.info("tirith installed to %s (%s)", dest,
"cosign + SHA-256" if cosign_verified else "SHA-256 only")
logger.info("tirith installed to %s (%s)", dest, "cosign + SHA-256" if cosign_verified else "SHA-256 only")
return dest, ""
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
@@ -445,18 +388,13 @@ def _find_local_tirith() -> str | None:
def _resolve_locally(configured_path: str, *, warn_missing: bool) -> tuple[str | None, bool]:
"""Network-free resolution shared by _resolve_tirith_path and ensure_installed.
Returns ``(path, may_install)``. ``path`` is set when resolved (module state
updated). Otherwise ``may_install`` is False when the miss is terminal — an
explicit path that doesn't exist, or a cached non-retryable failure — and
True when the caller may proceed to the disk marker / install step.
"""
"""Network-free resolution shared by _resolve_tirith_path and ensure_installed ->
``(path, may_install)``. ``path`` set = resolved (module state updated). Otherwise
``may_install`` is False when the miss is terminal (explicit path missing, cached
non-retryable failure) and True when the disk marker / install step may proceed."""
global _resolved_path, _install_failure_reason
expanded = os.path.expanduser(configured_path)
# Explicit (non-"tirith") path is authoritative: file or bare name on PATH;
# never auto-download a replacement.
# An explicit (non-"tirith") path is authoritative: never auto-download a replacement.
if configured_path != "tirith":
found = expanded if _is_executable(expanded) else shutil.which(expanded)
if found:
@@ -466,22 +404,19 @@ def _resolve_locally(configured_path: str, *, warn_missing: bool) -> tuple[str |
logger.warning("Configured tirith path %r not found; scanning disabled", configured_path)
_set_failed("explicit_path_missing")
return None, False
# Always re-run cheap local checks so a manual install is picked up even
# after a previous network failure (long-lived gateway recovers without restart).
# Always re-run the cheap local checks so a manual install is picked up even after a
# previous network failure (a long-lived gateway recovers without restart).
found = _find_local_tirith()
if found:
_set_resolved(found)
_clear_install_failed()
return found, False
# Previous install failed: skip the network retry unless the retryable
# cosign_missing cause has been resolved in-process.
# Previous install failed: skip the network retry unless the retryable cosign_missing
# cause has been resolved in-process.
if _resolved_path is _INSTALL_FAILED:
if _install_failure_reason != "cosign_missing" or not shutil.which("cosign"):
return None, False
_resolved_path = None
_install_failure_reason = ""
_resolved_path, _install_failure_reason = None, ""
_clear_install_failed()
return None, True
@@ -497,33 +432,24 @@ def _record_install_result(installed: str | None, reason: str) -> None:
def _resolve_tirith_path(configured_path: str) -> str:
"""Resolve the tirith binary path, auto-installing synchronously if necessary.
Default "tirith": PATH → $HERMES_HOME/bin/tirith → auto-install. Failed
installs are cached for the process (and on disk for 24h). On any miss the
expanded configured path is returned so the spawn fails open via the
dedupe'd OSError handler.
"""
Default "tirith": PATH → $HERMES_HOME/bin/tirith → auto-install; failed installs are
cached for the process (and on disk for 24h). On any miss the expanded configured path
is returned so the spawn fails open via the dedupe'd OSError handler."""
cached = _cached_path()
if cached:
return cached
expanded = os.path.expanduser(configured_path)
# No tirith build for this platform: cache the verdict; the spawn loop
# fails open once, then the fast path above short-circuits.
# No tirith build for this platform: cache the verdict; the spawn fails open once, then
# the fast path above short-circuits.
if configured_path == "tirith" and not is_platform_supported():
_set_failed("unsupported_platform")
return expanded
found, may_install = _resolve_locally(configured_path, warn_missing=True)
if found or not may_install:
return found or expanded
# A background install is running — don't start a parallel one; fail-open
# applies until it finishes.
# A background install is running: don't start a parallel one; fail-open until it finishes.
if (_install_thread is not None and _install_thread.is_alive()) or _disk_marker_blocks_install():
return expanded
installed, reason = _install_tirith()
_record_install_result(installed, reason)
return installed or expanded
@@ -542,40 +468,29 @@ def _background_install(*, log_failures: bool = True):
def ensure_installed(*, log_failures: bool = True):
"""Ensure tirith is available, downloading in a daemon thread if needed.
Local checks are synchronous; the download never blocks startup. Returns
the resolved path if available now, else None. Safe to call repeatedly.
"""
"""Ensure tirith is available, downloading in a daemon thread if needed. Local checks
are synchronous; the download never blocks startup. Returns the resolved path if
available now, else None. Safe to call repeatedly."""
global _install_thread
cfg = _load_security_config()
if not cfg["tirith_enabled"]:
return None
cached = _cached_path()
if cached:
return cached if _is_executable(cached) else None
# No tirith build here (e.g. Windows): stay silent — no PATH probe, no
# download thread, no disk marker. Pattern-matching guards still run.
# No tirith build here (e.g. Windows): stay silent -- no PATH probe, no download thread,
# no disk marker. Pattern-matching guards still run.
if not is_platform_supported():
_set_failed("unsupported_platform")
return None
found, may_install = _resolve_locally(cfg["tirith_path"], warn_missing=False)
if found or not may_install or _disk_marker_blocks_install():
return found
if _install_thread is None or not _install_thread.is_alive():
_install_thread = threading.Thread(
target=_background_install,
kwargs={"log_failures": log_failures},
daemon=True,
)
target=_background_install, kwargs={"log_failures": log_failures}, daemon=True)
_install_thread.start()
return None # Not available yet; commands will fail-open until ready
return None # not available yet; commands fail-open until ready
# --- Main API ---
@@ -586,8 +501,7 @@ _EXIT_ACTIONS = {0: "allow", 1: "block", 2: "warn"}
# Summary when tirith's JSON is unparseable and only the exit code is known.
_NO_DETAILS_SUMMARY = {
"block": "security issue detected (details unavailable)",
"warn": "security warning detected (details unavailable)",
}
"warn": "security warning detected (details unavailable)"}
def _verdict(action: str, summary: str = "", findings: list | None = None) -> dict:
@@ -599,50 +513,32 @@ def _fail(fail_open: bool, open_summary: str, closed_summary: str) -> dict:
def check_command_security(command: str) -> dict:
"""Run tirith security scan on a command.
Exit code determines action (0=allow, 1=block, 2=warn); JSON enriches
findings/summary. Spawn failures and timeouts respect fail_open.
Returns:
{"action": "allow"|"warn"|"block", "findings": [...], "summary": str}
"""
"""Run the tirith scan on a command -> ``{"action": allow|warn|block, "findings", "summary"}``.
Exit code determines the action; JSON enriches. Spawn failures/timeouts respect fail_open."""
global _crash_count
cfg = _load_security_config()
if not cfg["tirith_enabled"]:
return _verdict("allow")
if _circuit_open:
return _verdict("allow", "tirith disabled (circuit breaker)")
# No binary for this platform, ever — skip the resolver so we never spawn.
# No binary for this platform, ever: skip the resolver so we never spawn.
if not is_platform_supported():
return _verdict("allow")
tirith_path = _resolve_tirith_path(cfg["tirith_path"])
timeout = cfg["tirith_timeout"]
fail_open = cfg["tirith_fail_open"]
if tirith_path is None:
_warn_once("tirith_path_none", "tirith path resolved to None; scanning disabled")
return _fail(fail_open, "tirith path unavailable", "tirith path unavailable (fail-closed)")
try:
result = subprocess.run(
[tirith_path, "check", "--json", "--non-interactive",
"--shell", "posix", "--", command],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
timeout=timeout,
stdin=subprocess.DEVNULL,
)
[tirith_path, "check", "--json", "--non-interactive", "--shell", "posix", "--", command],
capture_output=True, text=True, encoding='utf-8', errors='replace',
timeout=timeout, stdin=subprocess.DEVNULL)
except OSError as exc:
# FileNotFoundError, PermissionError, exec format error. Dedupe by
# (exc class, errno) so each failure mode surfaces once, not per command.
spawn_key = f"tirith_spawn_failed:{type(exc).__name__}:{getattr(exc, 'errno', '')}"
_warn_once(spawn_key, "tirith spawn failed: %s", exc)
# FileNotFoundError / PermissionError / exec format error: dedupe by (class, errno)
# so each failure mode surfaces once, not per command.
_warn_once(f"tirith_spawn_failed:{type(exc).__name__}:{getattr(exc, 'errno', '')}", "tirith spawn failed: %s", exc)
_record_tirith_crash()
return _fail(fail_open, f"tirith unavailable: {exc}", f"tirith spawn failed (fail-closed): {exc}")
except subprocess.TimeoutExpired:
@@ -653,15 +549,13 @@ def check_command_security(command: str) -> dict:
exit_code = result.returncode
action = _EXIT_ACTIONS.get(exit_code)
if action is None:
# Unknown exit code (includes signal-killed, e.g. -11) — respect fail_open
# Unknown exit code (includes signal-killed, e.g. -11): respect fail_open.
logger.warning("tirith returned unexpected exit code %d", exit_code)
_record_tirith_crash()
return _fail(fail_open, f"tirith exit code {exit_code} (fail-open)",
f"tirith exit code {exit_code} (fail-closed)")
return _fail(fail_open, f"tirith exit code {exit_code} (fail-open)", f"tirith exit code {exit_code} (fail-closed)")
if action == "allow":
_crash_count = 0 # successful execution resets the circuit breaker
# JSON enriches findings/summary; parse failure never changes the verdict.
# JSON enriches findings/summary; a parse failure never changes the verdict.
findings = []
summary = ""
try:
@@ -671,13 +565,10 @@ def check_command_security(command: str) -> dict:
except (json.JSONDecodeError, AttributeError):
logger.debug("tirith JSON parse failed, using exit code only")
summary = _NO_DETAILS_SUMMARY.get(action, "")
# .app is a legitimate gTLD; a warn consisting solely of lookalike_tld
# findings for .app is a known false positive and is downgraded to allow.
# Any other finding (including lookalike_tld for other TLDs) keeps the warn.
# .app is a legitimate gTLD: a warn consisting solely of lookalike_tld findings for .app is a
# known false positive and is downgraded to allow. Any other finding keeps the warn.
if action == "warn" and findings and all(_is_app_tld_finding(f) for f in findings):
return _verdict("allow")
return _verdict(action, summary, findings)
@@ -687,5 +578,4 @@ def _is_app_tld_finding(finding: dict) -> bool:
return False
return any(
val is not None and ".app" in str(val).lower()
for val in (finding.get(k) for k in ("value", "tld", "detail", "description", "message"))
)
for val in (finding.get(k) for k in ("value", "tld", "detail", "description", "message")))