oasr 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. __init__.py +3 -0
  2. __main__.py +6 -0
  3. adapter.py +396 -0
  4. adapters/__init__.py +17 -0
  5. adapters/base.py +254 -0
  6. adapters/claude.py +82 -0
  7. adapters/codex.py +84 -0
  8. adapters/copilot.py +210 -0
  9. adapters/cursor.py +78 -0
  10. adapters/windsurf.py +83 -0
  11. agents/__init__.py +25 -0
  12. agents/base.py +96 -0
  13. agents/claude.py +25 -0
  14. agents/codex.py +25 -0
  15. agents/copilot.py +25 -0
  16. agents/opencode.py +25 -0
  17. agents/registry.py +57 -0
  18. cli.py +97 -0
  19. commands/__init__.py +6 -0
  20. commands/adapter.py +102 -0
  21. commands/add.py +435 -0
  22. commands/clean.py +30 -0
  23. commands/clone.py +178 -0
  24. commands/config.py +163 -0
  25. commands/diff.py +180 -0
  26. commands/exec.py +245 -0
  27. commands/find.py +56 -0
  28. commands/help.py +51 -0
  29. commands/info.py +152 -0
  30. commands/list.py +110 -0
  31. commands/registry.py +447 -0
  32. commands/rm.py +128 -0
  33. commands/status.py +119 -0
  34. commands/sync.py +143 -0
  35. commands/update.py +417 -0
  36. commands/use.py +45 -0
  37. commands/validate.py +74 -0
  38. config/__init__.py +119 -0
  39. config/defaults.py +40 -0
  40. config/schema.py +73 -0
  41. discovery.py +145 -0
  42. manifest.py +437 -0
  43. oasr-0.5.0.dist-info/METADATA +358 -0
  44. oasr-0.5.0.dist-info/RECORD +59 -0
  45. oasr-0.5.0.dist-info/WHEEL +4 -0
  46. oasr-0.5.0.dist-info/entry_points.txt +3 -0
  47. oasr-0.5.0.dist-info/licenses/LICENSE +187 -0
  48. oasr-0.5.0.dist-info/licenses/NOTICE +8 -0
  49. policy/__init__.py +50 -0
  50. policy/defaults.py +27 -0
  51. policy/enforcement.py +98 -0
  52. policy/profile.py +185 -0
  53. registry.py +173 -0
  54. remote.py +482 -0
  55. skillcopy/__init__.py +71 -0
  56. skillcopy/local.py +40 -0
  57. skillcopy/remote.py +98 -0
  58. tracking.py +181 -0
  59. validate.py +362 -0
policy/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ """Execution policy system for OASR.
2
+
3
+ Enforces host-level security boundaries for skill execution by defining what
4
+ agents can and cannot do. Policy profiles are user-defined in config.toml and
5
+ enforced at runtime before agent invocation.
6
+
7
+ Usage:
8
+ import policy
9
+
10
+ # Load profile from config
11
+ profile = policy.load(config, "safe")
12
+
13
+ # Assess risk
14
+ needs_confirm, reasons = policy.assess_risk(
15
+ profile,
16
+ stdin_used=True,
17
+ instructions_file_used=False
18
+ )
19
+
20
+ # Get confirmation if needed
21
+ if needs_confirm:
22
+ summary = policy.summarize(profile, skill_name, agent_name)
23
+ if not policy.prompt_confirmation(summary, reasons):
24
+ return 1 # aborted
25
+
26
+ This module does NOT:
27
+ - Detect prompt injection via NLP
28
+ - Rewrite or sanitize prompts
29
+ - Validate skill correctness
30
+ - Implement agent-specific permissions
31
+
32
+ It DOES:
33
+ - Load and validate policy profiles from config
34
+ - Assess execution risk based on context
35
+ - Require explicit confirmation for risky operations
36
+ - Fail closed with conservative defaults
37
+ """
38
+
39
+ from policy.defaults import SAFE as SAFE_DEFAULTS
40
+ from policy.enforcement import assess_risk, prompt_confirmation
41
+ from policy.profile import Profile, load, summarize
42
+
43
+ __all__ = [
44
+ "Profile",
45
+ "load",
46
+ "summarize",
47
+ "assess_risk",
48
+ "prompt_confirmation",
49
+ "SAFE_DEFAULTS",
50
+ ]
policy/defaults.py ADDED
@@ -0,0 +1,27 @@
1
+ """Safe default execution policy profile.
2
+
3
+ Conservative defaults that fail closed. Used when:
4
+ - No config exists
5
+ - Requested profile not found
6
+ - Config parsing errors occur
7
+ """
8
+
9
+ # Safe default profile - conservative and restrictive
10
+ SAFE = {
11
+ "fs_read_roots": ["./"],
12
+ "fs_write_roots": ["./out", "./.oasr"],
13
+ "deny_paths": [
14
+ "~/.ssh",
15
+ "~/.aws",
16
+ "~/.gnupg",
17
+ "~/.config",
18
+ ".env",
19
+ "~/.bashrc",
20
+ "~/.zshrc",
21
+ "~/.profile",
22
+ ],
23
+ "allowed_commands": ["rg", "fd", "jq", "cat"],
24
+ "deny_shell": True,
25
+ "network": False,
26
+ "allow_env": False,
27
+ }
policy/enforcement.py ADDED
@@ -0,0 +1,98 @@
1
+ """Policy enforcement logic - risk assessment and confirmation prompts.
2
+
3
+ Determines when execution requires user confirmation and handles the
4
+ confirmation flow.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+ from policy.profile import Profile
12
+
13
+
14
+ def assess_risk(
15
+ profile: Profile,
16
+ stdin_used: bool,
17
+ instructions_file_used: bool,
18
+ force_confirm: bool = False,
19
+ ) -> tuple[bool, list[str]]:
20
+ """Determine if execution requires user confirmation based on risk triggers.
21
+
22
+ Risk triggers:
23
+ 1. Input from stdin (non-interactive) or file
24
+ 2. Profile is not "safe"
25
+ 3. Environment exposure enabled
26
+ 4. Network enabled
27
+ 5. Shell execution allowed
28
+ 6. Force confirmation flag set
29
+
30
+ Args:
31
+ profile: Policy profile being used
32
+ stdin_used: True if stdin is non-tty (piped input)
33
+ instructions_file_used: True if prompt read from file
34
+ force_confirm: Force confirmation even if otherwise safe
35
+
36
+ Returns:
37
+ Tuple of (requires_confirmation, reasons)
38
+ where reasons is list of human-readable trigger descriptions
39
+ """
40
+ reasons = []
41
+
42
+ if force_confirm:
43
+ reasons.append("Explicit confirmation requested (--confirm)")
44
+
45
+ if stdin_used:
46
+ reasons.append("Input from stdin (non-interactive)")
47
+
48
+ if instructions_file_used:
49
+ reasons.append("Prompt loaded from file")
50
+
51
+ if profile.name != "safe":
52
+ reasons.append(f"Non-safe profile '{profile.name}' in use")
53
+
54
+ if profile.allow_env:
55
+ reasons.append("Environment variable access enabled")
56
+
57
+ if profile.network:
58
+ reasons.append("Network access enabled")
59
+
60
+ if not profile.deny_shell:
61
+ reasons.append("Shell execution allowed")
62
+
63
+ return (len(reasons) > 0, reasons)
64
+
65
+
66
+ def prompt_confirmation(
67
+ policy_summary: str,
68
+ reasons: list[str],
69
+ ) -> bool:
70
+ """Display policy summary and prompt for user confirmation.
71
+
72
+ Args:
73
+ policy_summary: Formatted policy summary from profile.summarize()
74
+ reasons: List of risk trigger reasons
75
+
76
+ Returns:
77
+ True if user confirms, False otherwise
78
+
79
+ Notes:
80
+ - Writes to stderr (not stdout)
81
+ - Handles Ctrl+C gracefully (returns False)
82
+ - Default answer is No (safe)
83
+ """
84
+ print(policy_summary, file=sys.stderr)
85
+ print(file=sys.stderr)
86
+
87
+ if reasons:
88
+ print("⚠ This execution requires confirmation due to:", file=sys.stderr)
89
+ for reason in reasons:
90
+ print(f" - {reason}", file=sys.stderr)
91
+ print(file=sys.stderr)
92
+
93
+ try:
94
+ response = input("Proceed? [y/N] ").strip().lower()
95
+ return response in ("y", "yes")
96
+ except (EOFError, KeyboardInterrupt):
97
+ print("\nAborted.", file=sys.stderr)
98
+ return False
policy/profile.py ADDED
@@ -0,0 +1,185 @@
1
+ """Policy profile definitions and loading.
2
+
3
+ Defines what agents can do during skill execution. Profiles are user-defined
4
+ in config.toml and enforced at runtime.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from policy.defaults import SAFE
15
+
16
+
17
+ @dataclass
18
+ class Profile:
19
+ """Execution policy profile defining agent capabilities and restrictions.
20
+
21
+ Attributes:
22
+ name: Profile identifier (e.g., "safe", "dev")
23
+ fs_read_roots: Allowed filesystem read locations
24
+ fs_write_roots: Allowed filesystem write locations
25
+ deny_paths: Explicitly denied paths (takes precedence)
26
+ allowed_commands: Permitted shell commands
27
+ deny_shell: If True, deny all shell/subprocess execution
28
+ network: Allow network access
29
+ allow_env: Allow reading environment variables
30
+ """
31
+
32
+ name: str
33
+ fs_read_roots: list[str] = field(default_factory=lambda: ["./"])
34
+ fs_write_roots: list[str] = field(default_factory=lambda: ["./out", "./.oasr"])
35
+ deny_paths: list[str] = field(
36
+ default_factory=lambda: [
37
+ "~/.ssh",
38
+ "~/.aws",
39
+ "~/.gnupg",
40
+ "~/.config",
41
+ ".env",
42
+ "~/.bashrc",
43
+ "~/.zshrc",
44
+ "~/.profile",
45
+ ]
46
+ )
47
+ allowed_commands: list[str] = field(default_factory=lambda: ["rg", "fd", "jq", "cat"])
48
+ deny_shell: bool = True
49
+ network: bool = False
50
+ allow_env: bool = False
51
+
52
+ def __post_init__(self):
53
+ """Resolve and normalize paths after initialization."""
54
+ self.fs_read_roots = [self._resolve_path(p) for p in self.fs_read_roots]
55
+ self.fs_write_roots = [self._resolve_path(p) for p in self.fs_write_roots]
56
+ self.deny_paths = [self._resolve_path(p) for p in self.deny_paths]
57
+
58
+ def _resolve_path(self, path: str) -> str:
59
+ """Resolve path: expand ~, make absolute if relative to cwd.
60
+
61
+ Relative paths are workspace-relative (relative to current directory).
62
+ Tilde (~) is expanded to user home directory.
63
+
64
+ Args:
65
+ path: Path string (may contain ~, be relative or absolute)
66
+
67
+ Returns:
68
+ Normalized absolute path string
69
+ """
70
+ p = Path(path).expanduser()
71
+ if not p.is_absolute():
72
+ p = Path.cwd() / p
73
+ return str(p.resolve())
74
+
75
+
76
+ def load(config: dict[str, Any], profile_name: str, cwd: Path | None = None) -> Profile:
77
+ """Load a policy profile from config with fallback to safe defaults.
78
+
79
+ Args:
80
+ config: Full config dict (may contain profiles.<name> tables)
81
+ profile_name: Name of profile to load (e.g., "safe", "dev")
82
+ cwd: Working directory for relative path resolution (default: current)
83
+
84
+ Returns:
85
+ Profile with merged defaults + user overrides
86
+
87
+ Notes:
88
+ - Missing profiles fall back to hardcoded safe defaults
89
+ - Malformed config falls back to safe + logs warning
90
+ - All paths are resolved to absolute paths
91
+ - Fail-closed behavior: errors → safe defaults
92
+ """
93
+ if cwd:
94
+ # Temporarily change directory context for path resolution
95
+ import os
96
+
97
+ old_cwd = os.getcwd()
98
+ try:
99
+ os.chdir(cwd)
100
+ return _load_impl(config, profile_name)
101
+ finally:
102
+ os.chdir(old_cwd)
103
+ else:
104
+ return _load_impl(config, profile_name)
105
+
106
+
107
+ def _load_impl(config: dict[str, Any], profile_name: str) -> Profile:
108
+ """Internal implementation of load()."""
109
+ # Start with safe defaults
110
+ profile_data = SAFE.copy()
111
+
112
+ # Try to load user-defined profile
113
+ try:
114
+ profiles = config.get("profiles", {})
115
+ if profile_name in profiles:
116
+ user_profile = profiles[profile_name]
117
+ # Merge user overrides
118
+ for key in SAFE.keys():
119
+ if key in user_profile:
120
+ profile_data[key] = user_profile[key]
121
+ elif profile_name != "safe":
122
+ # Warn if non-safe profile doesn't exist, but continue with safe defaults
123
+ print(
124
+ f"⚠ Warning: Profile '{profile_name}' not found in config. Using 'safe' defaults.",
125
+ file=sys.stderr,
126
+ )
127
+ except Exception as e:
128
+ # Fail closed: any error in config parsing → safe defaults
129
+ print(
130
+ f"⚠ Warning: Error loading profile config: {e}. Using 'safe' defaults.",
131
+ file=sys.stderr,
132
+ )
133
+
134
+ return Profile(name=profile_name, **profile_data)
135
+
136
+
137
+ def summarize(profile: Profile, skill_name: str, agent_name: str) -> str:
138
+ """Generate human-readable policy summary for confirmation prompt.
139
+
140
+ Args:
141
+ profile: Policy profile to summarize
142
+ skill_name: Name of skill being executed
143
+ agent_name: Name of agent being used
144
+
145
+ Returns:
146
+ Formatted multi-line string suitable for stderr output
147
+ """
148
+ lines = [
149
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
150
+ "EXECUTION POLICY REVIEW",
151
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
152
+ f"Skill: {skill_name}",
153
+ f"Agent: {agent_name}",
154
+ f"Profile: {profile.name}",
155
+ f"Network: {'allowed' if profile.network else 'denied'}",
156
+ f"Environment: {'allowed' if profile.allow_env else 'denied'}",
157
+ f"Shell: {'allowed' if not profile.deny_shell else 'denied'}",
158
+ ]
159
+
160
+ # Format allowed commands (truncate if too long)
161
+ if profile.allowed_commands:
162
+ commands = ", ".join(profile.allowed_commands)
163
+ if len(commands) > 60:
164
+ commands = commands[:57] + "..."
165
+ lines.append(f"Allowed commands: {commands}")
166
+ else:
167
+ lines.append("Allowed commands: (none)")
168
+
169
+ # Format paths (show first few, indicate if more)
170
+ lines.append(f"Read roots: {', '.join(profile.fs_read_roots[:3])}")
171
+ if len(profile.fs_read_roots) > 3:
172
+ lines.append(f" ... and {len(profile.fs_read_roots) - 3} more")
173
+
174
+ lines.append(f"Write roots: {', '.join(profile.fs_write_roots[:3])}")
175
+ if len(profile.fs_write_roots) > 3:
176
+ lines.append(f" ... and {len(profile.fs_write_roots) - 3} more")
177
+
178
+ # Show some deny paths (not all - could be long)
179
+ if profile.deny_paths:
180
+ deny_sample = profile.deny_paths[:5]
181
+ lines.append(f"Deny paths: {', '.join(deny_sample)}")
182
+ if len(profile.deny_paths) > 5:
183
+ lines.append(f" ... and {len(profile.deny_paths) - 5} more")
184
+
185
+ return "\n".join(lines)
registry.py ADDED
@@ -0,0 +1,173 @@
1
+ """Registry management for ~/.oasr/registry.toml."""
2
+
3
+ import sys
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ if sys.version_info >= (3, 11):
8
+ import tomllib
9
+ else:
10
+ import tomli as tomllib
11
+
12
+ import tomli_w
13
+
14
+ from config import OASR_DIR, ensure_skills_dir
15
+ from manifest import create_manifest, delete_manifest, save_manifest
16
+
17
+ REGISTRY_FILE = OASR_DIR / "registry.toml"
18
+
19
+
20
+ @dataclass
21
+ class SkillEntry:
22
+ """A registered skill entry."""
23
+
24
+ path: str
25
+ name: str
26
+ description: str
27
+
28
+ def to_dict(self) -> dict[str, str]:
29
+ """Convert to dictionary for TOML serialization."""
30
+ return {
31
+ "path": self.path,
32
+ "name": self.name,
33
+ "description": self.description,
34
+ }
35
+
36
+ @classmethod
37
+ def from_dict(cls, data: dict[str, str]) -> "SkillEntry":
38
+ """Create from dictionary."""
39
+ return cls(
40
+ path=data.get("path", ""),
41
+ name=data.get("name", ""),
42
+ description=data.get("description", ""),
43
+ )
44
+
45
+
46
+ def load_registry(registry_path: Path | None = None) -> list[SkillEntry]:
47
+ """Load registry from TOML file.
48
+
49
+ Args:
50
+ registry_path: Override registry file path. Defaults to ~/.oasr/registry.toml.
51
+
52
+ Returns:
53
+ List of registered skill entries.
54
+ """
55
+ path = registry_path or REGISTRY_FILE
56
+
57
+ if not path.exists():
58
+ return []
59
+
60
+ with open(path, "rb") as f:
61
+ data = tomllib.load(f)
62
+
63
+ skills = data.get("skill", [])
64
+ return [SkillEntry.from_dict(s) for s in skills]
65
+
66
+
67
+ def save_registry(entries: list[SkillEntry], registry_path: Path | None = None) -> None:
68
+ """Save registry to TOML file.
69
+
70
+ Args:
71
+ entries: List of skill entries to save.
72
+ registry_path: Override registry file path. Defaults to ~/.skills/registry.toml.
73
+ """
74
+ path = registry_path or REGISTRY_FILE
75
+ ensure_skills_dir()
76
+
77
+ data = {"skill": [e.to_dict() for e in entries]}
78
+
79
+ with open(path, "wb") as f:
80
+ tomli_w.dump(data, f)
81
+
82
+
83
+ def add_skill(
84
+ entry: SkillEntry,
85
+ registry_path: Path | None = None,
86
+ create_manifest_artifact: bool = True,
87
+ ) -> bool:
88
+ """Add or update a skill in the registry.
89
+
90
+ Args:
91
+ entry: Skill entry to add.
92
+ registry_path: Override registry file path.
93
+ create_manifest_artifact: Whether to create/update the manifest artifact.
94
+
95
+ Returns:
96
+ True if skill was added, False if it was updated (already existed).
97
+ """
98
+ entries = load_registry(registry_path)
99
+ is_new = True
100
+
101
+ for i, existing in enumerate(entries):
102
+ if existing.name == entry.name or existing.path == entry.path:
103
+ entries[i] = entry
104
+ is_new = False
105
+ break
106
+
107
+ if is_new:
108
+ entries.append(entry)
109
+
110
+ save_registry(entries, registry_path)
111
+
112
+ if create_manifest_artifact:
113
+ manifest = create_manifest(
114
+ name=entry.name,
115
+ source_path=entry.path, # Keep as string (can be URL or path)
116
+ description=entry.description,
117
+ )
118
+ save_manifest(manifest)
119
+
120
+ return is_new
121
+
122
+
123
+ def remove_skill(
124
+ name_or_path: str,
125
+ registry_path: Path | None = None,
126
+ delete_manifest_artifact: bool = True,
127
+ ) -> bool:
128
+ """Remove a skill from the registry by name or path.
129
+
130
+ Args:
131
+ name_or_path: Skill name or path to remove.
132
+ registry_path: Override registry file path.
133
+ delete_manifest_artifact: Whether to delete the manifest artifact.
134
+
135
+ Returns:
136
+ True if skill was removed, False if not found.
137
+ """
138
+ entries = load_registry(registry_path)
139
+ removed_name = None
140
+
141
+ new_entries = []
142
+ for e in entries:
143
+ if e.name == name_or_path or e.path == name_or_path:
144
+ removed_name = e.name
145
+ else:
146
+ new_entries.append(e)
147
+
148
+ if removed_name:
149
+ save_registry(new_entries, registry_path)
150
+ if delete_manifest_artifact:
151
+ delete_manifest(removed_name)
152
+ return True
153
+
154
+ return False
155
+
156
+
157
+ def find_skill(name_or_path: str, registry_path: Path | None = None) -> SkillEntry | None:
158
+ """Find a skill by name or path.
159
+
160
+ Args:
161
+ name_or_path: Skill name or path to find.
162
+ registry_path: Override registry file path.
163
+
164
+ Returns:
165
+ Skill entry if found, None otherwise.
166
+ """
167
+ entries = load_registry(registry_path)
168
+
169
+ for entry in entries:
170
+ if entry.name == name_or_path or entry.path == name_or_path:
171
+ return entry
172
+
173
+ return None