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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions shiroe/adapters/capabilities/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ def invoke(self, *, capability_id: str, action: str, inputs: dict,
ok=False, error=f"no version record for {capability_id!r}",
)
manifest = json.loads(row[0])
source = Path(row[1])
# ponytail: source_location + entrypoint.command are project-relative
# (REDACT scrubs absolute paths from event log). Resolve here.
root_path = Path(root)
raw_src = Path(row[1])
source = raw_src if raw_src.is_absolute() else (root_path / raw_src).resolve()

# 2. Resolve command from the approved manifest only.
raw_cmd = manifest.get("entrypoint", {}).get("command")
Expand All @@ -73,7 +77,13 @@ def invoke(self, *, capability_id: str, action: str, inputs: dict,
ok=False,
error="capability manifest entrypoint.command must be a non-empty list of strings",
)
argv: list[str] = list(raw_cmd)
argv: list[str] = []
for i, token in enumerate(raw_cmd):
token_path = Path(token)
if i == 0 and not token_path.is_absolute() and (root_path / token_path).exists():
argv.append(str((root_path / token_path).resolve()))
else:
argv.append(token)
raw_args = inputs.get("args", ())
if raw_args:
contract = manifest.get("entrypoint", {}).get("args")
Expand Down
9 changes: 7 additions & 2 deletions shiroe/capabilities/gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ def assert_executable(root: Path | str, capability_id: str) -> None:
def _resolve_source(store: CapabilityStore, capability_id: str,
expected_digest: str) -> dict:
"""Look up the on-disk location for the capability, recompute its digest,
and (if different) refresh the store — which will re-quarantine."""
and (if different) refresh the store — which will re-quarantine.

ponytail: source_location is project-relative (REDACT.md scrubs absolute
paths from event log). Resolve against store.root when relative.
"""
row = store.conn.execute(
"SELECT source_location FROM capability_versions "
"WHERE capability_id=? ORDER BY created_at DESC LIMIT 1",
Expand All @@ -57,7 +61,8 @@ def _resolve_source(store: CapabilityStore, capability_id: str,
raise CapabilityGateError(
f"no version record for capability {capability_id!r}"
)
location = Path(row[0])
raw = Path(row[0])
location = raw if raw.is_absolute() else (store.root / raw).resolve()
if not location.exists():
raise CapabilityGateError(
f"capability {capability_id!r} source no longer exists at {location}"
Expand Down
114 changes: 114 additions & 0 deletions shiroe/capabilities/onboarding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Composite capability onboarding flow (Step 3).

Chains inspection -> manifest inference -> registration -> inspected
lifecycle -> a human approval request. Single CLI call walks a
capability from "found on disk" to "waiting on a human decision"
without executing it or auto-approving it.

All persisted paths are project-relative. EventLog.append scrubs
absolute filesystem paths (REDACT.md internal_paths class); gate
and adapters resolve relative paths against the project root at read.
"""

from __future__ import annotations

import re
from pathlib import Path

from shiroe.capabilities.inspection import inspect_source
from shiroe.capabilities.manifest import infer_manifest, validate_manifest
from shiroe.capabilities.store import CapabilityStore
from shiroe.storage.events import EventEnvelope
from shiroe.storage import projections
from shiroe.policy.approval_service import ApprovalService


class OnboardingError(ValueError):
"""Raised when a capability source cannot be onboarded."""


_ID_SAFE = re.compile(r"[^a-z0-9]+")


def _derive_capability_id(rel_path: Path) -> str:
stem = rel_path.stem or rel_path.name
slug = _ID_SAFE.sub("_", stem.lower()).strip("_") or "capability"
return f"local.{slug}"


def onboard(project_root: Path, source: Path) -> dict:
"""Onboard one capability source into the registry, pending approval."""
project_root = Path(project_root).resolve()
source = Path(source).resolve()
if not source.exists():
raise OnboardingError(f"capability source does not exist: {source}")
try:
rel = source.relative_to(project_root)
except ValueError as exc:
raise OnboardingError(
f"capability source {source} is outside project root {project_root}"
) from exc

trust = inspect_source(source)
capability_id = _derive_capability_id(rel)

# Manifest carries the RELATIVE path so REDACT does not scrub it.
manifest = infer_manifest(
rel,
capability_id=capability_id,
name=rel.name,
type_="script" if source.is_file() else "capability",
)
validate_manifest(manifest)

store = CapabilityStore(project_root)
try:
existing = store.get(capability_id)
if existing is None:
store.upsert_capability(
capability_id=capability_id,
name=manifest["name"],
type_=manifest["type"],
lifecycle="quarantined",
digest=trust.digest,
manifest=manifest,
source_kind=manifest["source"]["kind"],
source_location=str(rel),
license_=trust.license,
)
env = store.events.append(EventEnvelope(
event_type="capability.quarantined",
actor="capability-manager",
target=f"capability:{capability_id}",
payload={"reason": "new discovery"},
))
projections.apply_event(store.conn, env)
store.conn.commit()
else:
store.refresh_digest(capability_id, trust.digest)

store.set_lifecycle(capability_id, "inspected", actor="onboarding")
row = store.get(capability_id)
finally:
store.close()

approvals = ApprovalService(project_root)
try:
approval = approvals.request(
approval_type="capability",
requested_action=f"approve {capability_id}",
scope={"capability_id": capability_id, "digest": row["current_digest"]},
reason="onboarding",
risk="medium",
action_kind="capability.approve",
)
finally:
approvals.close()

return {
"capability_id": capability_id,
"lifecycle": row["lifecycle"],
"digest": row["current_digest"],
"approval_id": approval.id,
"trust_report": trust.to_dict(),
}
Loading
Loading