oasr 0.4.2__py3-none-any.whl → 0.5.1__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.
cli.py CHANGED
@@ -13,7 +13,7 @@ from pathlib import Path
13
13
  from commands import adapter, clean, clone, config, diff, exec, find, registry, sync, update, use, validate
14
14
  from commands import help as help_cmd
15
15
 
16
- __version__ = "0.4.2"
16
+ __version__ = "0.5.1"
17
17
 
18
18
 
19
19
  def main(argv: list[str] | None = None) -> int:
commands/config.py CHANGED
@@ -3,8 +3,9 @@
3
3
  import argparse
4
4
  import sys
5
5
 
6
- from agents import detect_available_agents, get_all_agent_names
6
+ from agents import detect_available_agents
7
7
  from config import CONFIG_FILE, load_config, save_config
8
+ from config.schema import validate_agent, validate_profile_reference
8
9
 
9
10
 
10
11
  def register(subparsers: argparse._SubParsersAction) -> None:
@@ -25,6 +26,11 @@ def register(subparsers: argparse._SubParsersAction) -> None:
25
26
  )
26
27
  set_parser.add_argument("key", help="Configuration key (e.g., 'agent')")
27
28
  set_parser.add_argument("value", help="Configuration value")
29
+ set_parser.add_argument(
30
+ "--force",
31
+ action="store_true",
32
+ help="Skip validation (use carefully)",
33
+ )
28
34
  set_parser.set_defaults(func=run_set)
29
35
 
30
36
  # config get
@@ -57,40 +63,86 @@ def register(subparsers: argparse._SubParsersAction) -> None:
57
63
 
58
64
 
59
65
  def run_set(args: argparse.Namespace) -> int:
60
- """Set a configuration value."""
66
+ """Set a configuration value with validation."""
61
67
  key = args.key.lower()
62
68
  value = args.value
69
+ force = getattr(args, "force", False)
63
70
 
64
- # Only support agent for now
65
- if key == "agent":
66
- # Validate agent name
67
- valid_agents = get_all_agent_names()
68
- if value not in valid_agents:
69
- print(
70
- f"Error: Invalid agent '{value}'. Must be one of: {', '.join(valid_agents)}",
71
- file=sys.stderr,
72
- )
71
+ # Parse key (support dotted notation like "validation.strict")
72
+ if "." in key:
73
+ parts = key.split(".", 1)
74
+ if len(parts) != 2:
75
+ print(f"Error: Invalid key '{key}'. Use format 'section.field' or 'agent'", file=sys.stderr)
73
76
  return 1
77
+ section, field = parts
78
+ elif key == "agent":
79
+ # Special case: bare "agent" means "agent.default"
80
+ section, field = "agent", "default"
81
+ else:
82
+ print(f"Error: Invalid key '{key}'. Use format 'section.field' or 'agent'", file=sys.stderr)
83
+ return 1
74
84
 
75
- # Load config, update, save
76
- config = load_config(args.config if hasattr(args, "config") else None)
77
- config["agent"]["default"] = value
78
- save_config(config, args.config if hasattr(args, "config") else None)
85
+ # Type coercion based on field
86
+ original_value = value
87
+ if field == "strict":
88
+ value = value.lower() in ("true", "1", "yes", "on")
89
+ elif field == "reference_max_lines":
90
+ try:
91
+ value = int(value)
92
+ if value < 1:
93
+ print(f"Error: '{field}' must be a positive integer", file=sys.stderr)
94
+ return 1
95
+ except ValueError:
96
+ print(f"Error: '{field}' must be an integer", file=sys.stderr)
97
+ return 1
79
98
 
80
- # Show available vs configured
81
- available = detect_available_agents()
82
- if value in available:
83
- print(f"✓ Default agent set to: {value}")
99
+ # Load config
100
+ config_path = getattr(args, "config", None)
101
+ config = load_config(config_path=config_path)
102
+
103
+ # Validate before setting (unless --force)
104
+ if not force:
105
+ # Validate agent
106
+ if section == "agent" and field == "default":
107
+ is_valid, error_msg = validate_agent(value)
108
+ if not is_valid:
109
+ print(f"Error: {error_msg}", file=sys.stderr)
110
+ print("\nTo set anyway, use: oasr config set --force agent <name>", file=sys.stderr)
111
+ return 1
112
+
113
+ # Validate profile reference
114
+ if section == "oasr" and field == "default_profile":
115
+ is_valid, error_msg = validate_profile_reference(value, config)
116
+ if not is_valid:
117
+ print(f"Error: {error_msg}", file=sys.stderr)
118
+ print("\nCreate the profile in ~/.oasr/config.toml first, or use:", file=sys.stderr)
119
+ print(f" oasr config set --force oasr.default_profile {value}", file=sys.stderr)
120
+ return 1
121
+
122
+ # Set the value
123
+ if section not in config:
124
+ config[section] = {}
125
+
126
+ config[section][field] = value
127
+
128
+ try:
129
+ save_config(config, config_path=config_path)
130
+
131
+ # Show confirmation
132
+ if section == "agent" and field == "default":
133
+ # Special handling for agent - check if available
134
+ available = detect_available_agents()
135
+ if value in available:
136
+ print(f"✓ Default agent set to: {value}")
137
+ else:
138
+ print(f"✓ Default agent set to: {value}")
139
+ print(f" Warning: '{value}' binary not found in PATH. Install it to use this agent.", file=sys.stderr)
84
140
  else:
85
- print(f"✓ Default agent set to: {value}")
86
- print(
87
- f" Warning: '{value}' binary not found in PATH. Install it to use this agent.",
88
- file=sys.stderr,
89
- )
141
+ print(f"✓ Set {section}.{field} = {original_value}")
90
142
 
91
143
  return 0
92
- else:
93
- print(f"Error: Unsupported config key '{key}'. Only 'agent' is supported.", file=sys.stderr)
144
+ except ValueError as e:
145
+ print(f"Error: {e}", file=sys.stderr)
94
146
  return 1
95
147
 
96
148
 
commands/exec.py CHANGED
@@ -4,6 +4,7 @@ import argparse
4
4
  import sys
5
5
  from pathlib import Path
6
6
 
7
+ import policy
7
8
  from agents.registry import detect_available_agents, get_driver
8
9
  from config import load_config
9
10
  from registry import load_registry
@@ -42,6 +43,21 @@ def setup_parser(subparsers):
42
43
  "--agent",
43
44
  help="Override the default agent (codex, copilot, claude, opencode)",
44
45
  )
46
+ parser.add_argument(
47
+ "--profile",
48
+ help="Execution policy profile to use (default: from config)",
49
+ )
50
+ parser.add_argument(
51
+ "-y",
52
+ "--yes",
53
+ action="store_true",
54
+ help="Skip confirmation prompt for risky operations",
55
+ )
56
+ parser.add_argument(
57
+ "--confirm",
58
+ action="store_true",
59
+ help="Force confirmation prompt even for safe operations",
60
+ )
45
61
  parser.set_defaults(func=run)
46
62
 
47
63
 
@@ -85,12 +101,54 @@ def run(args: argparse.Namespace) -> int:
85
101
  # Error already printed by _get_user_prompt
86
102
  return 1
87
103
 
88
- # Determine which agent to use
89
- agent_name = _get_agent_name(args)
90
- if agent_name is None:
91
- # Error already printed by _get_agent_name
104
+ # === POLICY ENFORCEMENT ===
105
+ # Build CLI overrides for config loading
106
+ cli_overrides = {}
107
+ if args.agent:
108
+ cli_overrides["agent"] = {"default": args.agent}
109
+ if args.profile:
110
+ cli_overrides["oasr"] = cli_overrides.get("oasr", {})
111
+ cli_overrides["oasr"]["default_profile"] = args.profile
112
+
113
+ # Load configuration with precedence: CLI > env > file > defaults
114
+ config = load_config(cli_overrides=cli_overrides)
115
+
116
+ # Determine agent and profile from merged config
117
+ agent_name = config.get("agent", {}).get("default")
118
+ profile_name = config.get("oasr", {}).get("default_profile", "safe")
119
+
120
+ # Validate agent is set
121
+ if not agent_name:
122
+ print(
123
+ "Error: No agent configured. Set OASR_AGENT, use --agent flag, or run:",
124
+ file=sys.stderr,
125
+ )
126
+ print(" oasr config set agent <name>", file=sys.stderr)
92
127
  return 1
93
128
 
129
+ # Load the policy profile
130
+ profile = policy.load(config, profile_name)
131
+
132
+ # Detect execution context for risk assessment
133
+ stdin_used = not sys.stdin.isatty() and not args.prompt and not args.instructions
134
+ instructions_file_used = bool(args.instructions)
135
+
136
+ # Assess risk and determine if confirmation is needed
137
+ needs_confirm, reasons = policy.assess_risk(
138
+ profile=profile,
139
+ stdin_used=stdin_used,
140
+ instructions_file_used=instructions_file_used,
141
+ force_confirm=args.confirm,
142
+ )
143
+
144
+ # Require confirmation unless --yes flag is present
145
+ if needs_confirm and not args.yes:
146
+ summary = policy.summarize(profile, skill_name, agent_name)
147
+ if not policy.prompt_confirmation(summary, reasons):
148
+ return 1 # User aborted
149
+
150
+ # === END POLICY ENFORCEMENT ===
151
+
94
152
  # Get the agent driver
95
153
  try:
96
154
  driver = get_driver(agent_name)
config/__init__.py CHANGED
@@ -12,6 +12,7 @@ else:
12
12
  import tomli_w
13
13
 
14
14
  from config.defaults import DEFAULT_CONFIG
15
+ from config.env import load_env_config, merge_configs
15
16
  from config.schema import validate_config
16
17
 
17
18
  OASR_DIR = Path.home() / ".oasr"
@@ -40,34 +41,50 @@ def ensure_skills_dir() -> Path:
40
41
  return ensure_oasr_dir()
41
42
 
42
43
 
43
- def load_config(config_path: Path | None = None) -> dict[str, Any]:
44
- """Load configuration from TOML file.
44
+ def load_config(config_path: Path | None = None, cli_overrides: dict[str, Any] | None = None) -> dict[str, Any]:
45
+ """Load configuration from multiple sources with precedence.
46
+
47
+ Precedence order (highest to lowest):
48
+ 1. cli_overrides - explicit CLI flags
49
+ 2. Environment variables (OASR_*)
50
+ 3. Config file (~/.oasr/config.toml)
51
+ 4. Built-in defaults
45
52
 
46
53
  Args:
47
54
  config_path: Override config file path. Defaults to ~/.oasr/config.toml.
55
+ cli_overrides: Optional CLI flag overrides (highest precedence)
48
56
 
49
57
  Returns:
50
- Configuration dictionary with defaults applied.
58
+ Merged configuration dictionary with all sources applied.
51
59
  """
52
60
  path = config_path or CONFIG_FILE
61
+ cli_overrides = cli_overrides or {}
53
62
 
54
63
  # Deep copy defaults
55
- config = {
64
+ defaults = {
56
65
  "validation": DEFAULT_CONFIG["validation"].copy(),
57
66
  "adapter": DEFAULT_CONFIG["adapter"].copy(),
58
67
  "agent": DEFAULT_CONFIG["agent"].copy(),
68
+ "oasr": DEFAULT_CONFIG["oasr"].copy(),
69
+ "profiles": {k: v.copy() for k, v in DEFAULT_CONFIG["profiles"].items()},
59
70
  }
60
71
 
72
+ # Load config file
73
+ file_config = {}
61
74
  if path.exists():
62
75
  with open(path, "rb") as f:
63
- loaded = tomllib.load(f)
76
+ file_config = tomllib.load(f)
77
+
78
+ # Load environment variables
79
+ env_config = load_env_config()
64
80
 
65
- if "validation" in loaded:
66
- config["validation"].update(loaded["validation"])
67
- if "adapter" in loaded:
68
- config["adapter"].update(loaded["adapter"])
69
- if "agent" in loaded:
70
- config["agent"].update(loaded["agent"])
81
+ # Merge all sources with precedence
82
+ config = merge_configs(
83
+ cli_overrides=cli_overrides,
84
+ env_config=env_config,
85
+ file_config=file_config,
86
+ defaults=defaults,
87
+ )
71
88
 
72
89
  return config
73
90
 
@@ -106,4 +123,6 @@ def get_default_config() -> dict[str, Any]:
106
123
  "validation": DEFAULT_CONFIG["validation"].copy(),
107
124
  "adapter": DEFAULT_CONFIG["adapter"].copy(),
108
125
  "agent": DEFAULT_CONFIG["agent"].copy(),
126
+ "oasr": DEFAULT_CONFIG["oasr"].copy(),
127
+ "profiles": {k: v.copy() for k, v in DEFAULT_CONFIG["profiles"].items()},
109
128
  }
config/defaults.py CHANGED
@@ -13,4 +13,28 @@ DEFAULT_CONFIG: dict[str, Any] = {
13
13
  "agent": {
14
14
  "default": None,
15
15
  },
16
+ "oasr": {
17
+ "default_profile": "safe",
18
+ },
19
+ "profiles": {
20
+ # Built-in safe profile (always available as fallback)
21
+ "safe": {
22
+ "fs_read_roots": ["./"],
23
+ "fs_write_roots": ["./out", "./.oasr"],
24
+ "deny_paths": [
25
+ "~/.ssh",
26
+ "~/.aws",
27
+ "~/.gnupg",
28
+ "~/.config",
29
+ ".env",
30
+ "~/.bashrc",
31
+ "~/.zshrc",
32
+ "~/.profile",
33
+ ],
34
+ "allowed_commands": ["rg", "fd", "jq", "cat"],
35
+ "deny_shell": True,
36
+ "network": False,
37
+ "allow_env": False,
38
+ },
39
+ },
16
40
  }
config/env.py ADDED
@@ -0,0 +1,248 @@
1
+ """Environment variable configuration support.
2
+
3
+ This module provides functionality to load OASR configuration from environment
4
+ variables, following the pattern: OASR_<SECTION>_<KEY>
5
+
6
+ Precedence order:
7
+ 1. CLI flags (highest)
8
+ 2. Environment variables
9
+ 3. Config file
10
+ 4. Built-in defaults (lowest)
11
+
12
+ Environment variable naming:
13
+ - Prefix: OASR_
14
+ - Format: OASR_<SECTION>_<KEY> (uppercase, underscore-separated)
15
+ - Examples:
16
+ OASR_AGENT=codex → agent.default = "codex"
17
+ OASR_PROFILE=dev → oasr.default_profile = "dev"
18
+ OASR_VALIDATION_STRICT=true → validation.strict = true
19
+
20
+ Type handling:
21
+ - Strings: as-is
22
+ - Booleans: true/false, 1/0, yes/no, on/off (case-insensitive)
23
+ - Integers: parsed with int()
24
+ - Lists: comma-separated values
25
+ """
26
+
27
+ import os
28
+ from typing import Any
29
+
30
+ # Mapping of environment variable names to config paths
31
+ ENV_VAR_MAP = {
32
+ "OASR_AGENT": ("agent", "default"),
33
+ "OASR_PROFILE": ("oasr", "default_profile"),
34
+ "OASR_VALIDATION_STRICT": ("validation", "strict"),
35
+ "OASR_VALIDATION_MAX_LINES": ("validation", "reference_max_lines"),
36
+ "OASR_ADAPTER_TARGETS": ("adapter", "default_targets"),
37
+ }
38
+
39
+
40
+ def parse_bool(value: str) -> bool:
41
+ """Parse boolean from string.
42
+
43
+ Args:
44
+ value: String value to parse
45
+
46
+ Returns:
47
+ Boolean value
48
+
49
+ Raises:
50
+ ValueError: If value cannot be parsed as boolean
51
+ """
52
+ value_lower = value.lower()
53
+ if value_lower in ("true", "1", "yes", "on"):
54
+ return True
55
+ if value_lower in ("false", "0", "no", "off"):
56
+ return False
57
+ raise ValueError(f"Cannot parse '{value}' as boolean")
58
+
59
+
60
+ def parse_int(value: str) -> int:
61
+ """Parse integer from string.
62
+
63
+ Args:
64
+ value: String value to parse
65
+
66
+ Returns:
67
+ Integer value
68
+
69
+ Raises:
70
+ ValueError: If value cannot be parsed as integer
71
+ """
72
+ return int(value)
73
+
74
+
75
+ def parse_list(value: str) -> list[str]:
76
+ """Parse list from comma-separated string.
77
+
78
+ Args:
79
+ value: Comma-separated string
80
+
81
+ Returns:
82
+ List of strings (trimmed)
83
+ """
84
+ return [item.strip() for item in value.split(",") if item.strip()]
85
+
86
+
87
+ def parse_value(value: str, expected_type: type) -> Any:
88
+ """Parse environment variable value based on expected type.
89
+
90
+ Args:
91
+ value: String value from environment
92
+ expected_type: Expected Python type
93
+
94
+ Returns:
95
+ Parsed value of appropriate type
96
+
97
+ Raises:
98
+ ValueError: If value cannot be parsed to expected type
99
+ """
100
+ if expected_type is bool:
101
+ return parse_bool(value)
102
+ elif expected_type is int:
103
+ return parse_int(value)
104
+ elif expected_type is list:
105
+ return parse_list(value)
106
+ else:
107
+ return value # String, return as-is
108
+
109
+
110
+ def load_env_config() -> dict[str, dict[str, Any]]:
111
+ """Load configuration from environment variables.
112
+
113
+ Reads all OASR_* environment variables and converts them to a nested
114
+ dictionary structure matching the config file format.
115
+
116
+ Returns:
117
+ Nested dictionary with config values from environment variables
118
+ Example: {"agent": {"default": "codex"}, "oasr": {"default_profile": "safe"}}
119
+
120
+ Notes:
121
+ - Only processes variables defined in ENV_VAR_MAP
122
+ - Silently skips variables with invalid values (logs warning)
123
+ - Returns empty sections if no relevant env vars set
124
+ """
125
+ config = {}
126
+
127
+ for env_var, (section, key) in ENV_VAR_MAP.items():
128
+ value = os.getenv(env_var)
129
+ if value is None:
130
+ continue
131
+
132
+ # Determine expected type based on key
133
+ # This is a heuristic - we infer type from key name
134
+ expected_type = str # Default
135
+ if key == "strict":
136
+ expected_type = bool
137
+ elif key == "reference_max_lines":
138
+ expected_type = int
139
+ elif key == "default_targets":
140
+ expected_type = list
141
+
142
+ try:
143
+ parsed_value = parse_value(value, expected_type)
144
+
145
+ # Create section if it doesn't exist
146
+ if section not in config:
147
+ config[section] = {}
148
+
149
+ config[section][key] = parsed_value
150
+
151
+ except (ValueError, TypeError) as e:
152
+ # Log warning but continue (fail-safe)
153
+ import sys
154
+
155
+ print(
156
+ f"⚠ Warning: Invalid value for {env_var}='{value}': {e}. Skipping.",
157
+ file=sys.stderr,
158
+ )
159
+ continue
160
+
161
+ return config
162
+
163
+
164
+ def merge_configs(
165
+ cli_overrides: dict[str, Any],
166
+ env_config: dict[str, dict[str, Any]],
167
+ file_config: dict[str, dict[str, Any]],
168
+ defaults: dict[str, dict[str, Any]],
169
+ ) -> dict[str, dict[str, Any]]:
170
+ """Merge configurations from multiple sources with correct precedence.
171
+
172
+ Precedence order (highest to lowest):
173
+ 1. cli_overrides - explicit CLI flags
174
+ 2. env_config - environment variables
175
+ 3. file_config - config file values
176
+ 4. defaults - built-in defaults
177
+
178
+ Args:
179
+ cli_overrides: Values explicitly set via CLI flags
180
+ env_config: Values from environment variables
181
+ file_config: Values from config file
182
+ defaults: Built-in default values
183
+
184
+ Returns:
185
+ Merged configuration dictionary
186
+
187
+ Notes:
188
+ - CLI overrides take precedence over everything
189
+ - Environment variables override config file
190
+ - Config file overrides defaults
191
+ - Sections are merged independently
192
+ """
193
+ result = {}
194
+
195
+ # Get all sections from all sources
196
+ all_sections = set()
197
+ for config in [defaults, file_config, env_config, cli_overrides]:
198
+ all_sections.update(config.keys())
199
+
200
+ # Merge each section with precedence
201
+ for section in all_sections:
202
+ result[section] = {}
203
+
204
+ # Start with defaults
205
+ if section in defaults:
206
+ result[section].update(defaults[section])
207
+
208
+ # Override with file config
209
+ if section in file_config:
210
+ result[section].update(file_config[section])
211
+
212
+ # Override with env config
213
+ if section in env_config:
214
+ result[section].update(env_config[section])
215
+
216
+ # Override with CLI (if present)
217
+ if section in cli_overrides:
218
+ result[section].update(cli_overrides[section])
219
+
220
+ return result
221
+
222
+
223
+ def get_config_source(
224
+ section: str,
225
+ key: str,
226
+ cli_overrides: dict[str, Any],
227
+ env_config: dict[str, dict[str, Any]],
228
+ file_config: dict[str, dict[str, Any]],
229
+ ) -> str:
230
+ """Determine the source of a config value.
231
+
232
+ Args:
233
+ section: Config section name
234
+ key: Config key name
235
+ cli_overrides: CLI flag overrides
236
+ env_config: Environment variable config
237
+ file_config: Config file values
238
+
239
+ Returns:
240
+ Source string: "cli flag", "env var", "config file", or "default"
241
+ """
242
+ if section in cli_overrides and key in cli_overrides[section]:
243
+ return "cli flag"
244
+ if section in env_config and key in env_config.get(section, {}):
245
+ return "env var"
246
+ if section in file_config and key in file_config.get(section, {}):
247
+ return "config file"
248
+ return "default"
config/schema.py CHANGED
@@ -5,6 +5,44 @@ from typing import Any
5
5
  VALID_AGENTS = {"codex", "copilot", "claude", "opencode"}
6
6
 
7
7
 
8
+ def validate_agent(agent: str | None) -> tuple[bool, str | None]:
9
+ """Validate agent configuration value.
10
+
11
+ Args:
12
+ agent: Agent name to validate
13
+
14
+ Returns:
15
+ Tuple of (is_valid, error_message)
16
+ """
17
+ if agent is None:
18
+ return (True, None)
19
+
20
+ if agent not in VALID_AGENTS:
21
+ sorted_agents = ", ".join(sorted(VALID_AGENTS))
22
+ return (False, f"Invalid agent '{agent}'. Valid agents: {sorted_agents}")
23
+
24
+ return (True, None)
25
+
26
+
27
+ def validate_profile_reference(profile_name: str, config: dict[str, Any]) -> tuple[bool, str | None]:
28
+ """Validate that a profile reference exists in config.
29
+
30
+ Args:
31
+ profile_name: Profile name to validate
32
+ config: Full config dictionary
33
+
34
+ Returns:
35
+ Tuple of (is_valid, error_message)
36
+ """
37
+ profiles = config.get("profiles", {})
38
+
39
+ if profile_name not in profiles:
40
+ available = ", ".join(sorted(profiles.keys()))
41
+ return (False, f"Profile '{profile_name}' not found. Available profiles: {available}")
42
+
43
+ return (True, None)
44
+
45
+
8
46
  def validate_config(config: dict[str, Any]) -> None:
9
47
  """Validate configuration dictionary.
10
48
 
@@ -34,3 +72,40 @@ def validate_config(config: dict[str, Any]) -> None:
34
72
  targets = config["adapter"]["default_targets"]
35
73
  if not isinstance(targets, list):
36
74
  raise ValueError("adapter.default_targets must be a list")
75
+
76
+ if "oasr" in config:
77
+ if "default_profile" in config["oasr"]:
78
+ profile = config["oasr"]["default_profile"]
79
+ if not isinstance(profile, str):
80
+ raise ValueError("oasr.default_profile must be a string")
81
+
82
+ if "profiles" in config:
83
+ if not isinstance(config["profiles"], dict):
84
+ raise ValueError("profiles must be a table (dictionary)")
85
+
86
+ # Validate each profile structure
87
+ for profile_name, profile_data in config["profiles"].items():
88
+ if not isinstance(profile_data, dict):
89
+ raise ValueError(f"Profile '{profile_name}' must be a table (dictionary)")
90
+
91
+ # Validate profile fields if present
92
+ if "fs_read_roots" in profile_data and not isinstance(profile_data["fs_read_roots"], list):
93
+ raise ValueError(f"Profile '{profile_name}': fs_read_roots must be a list")
94
+
95
+ if "fs_write_roots" in profile_data and not isinstance(profile_data["fs_write_roots"], list):
96
+ raise ValueError(f"Profile '{profile_name}': fs_write_roots must be a list")
97
+
98
+ if "deny_paths" in profile_data and not isinstance(profile_data["deny_paths"], list):
99
+ raise ValueError(f"Profile '{profile_name}': deny_paths must be a list")
100
+
101
+ if "allowed_commands" in profile_data and not isinstance(profile_data["allowed_commands"], list):
102
+ raise ValueError(f"Profile '{profile_name}': allowed_commands must be a list")
103
+
104
+ if "deny_shell" in profile_data and not isinstance(profile_data["deny_shell"], bool):
105
+ raise ValueError(f"Profile '{profile_name}': deny_shell must be a boolean")
106
+
107
+ if "network" in profile_data and not isinstance(profile_data["network"], bool):
108
+ raise ValueError(f"Profile '{profile_name}': network must be a boolean")
109
+
110
+ if "allow_env" in profile_data and not isinstance(profile_data["allow_env"], bool):
111
+ raise ValueError(f"Profile '{profile_name}': allow_env must be a boolean")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oasr
3
- Version: 0.4.2
3
+ Version: 0.5.1
4
4
  Summary: CLI for managing agent skills across IDE integrations
5
5
  Project-URL: Homepage, https://github.com/jgodau/asr
6
6
  Project-URL: Repository, https://github.com/jgodau/asr
@@ -1,7 +1,7 @@
1
1
  __init__.py,sha256=cYuwXNht5J2GDPEbHz57rmXRyWzaUgAaCXz8okR0rKE,84
2
2
  __main__.py,sha256=Due_Us-4KNlLZhf8MkmoP1hWS5qMWmpZvz2ZaCqPHT4,120
3
3
  adapter.py,sha256=WEpYkKDTb7We0zU9i6Z-r5ydtUdghNhxTZ5Eq58h4fU,10027
4
- cli.py,sha256=gb2A9vxiD4q67oRynUJr9W0vHUHm02S2qR56SwAU1MM,2672
4
+ cli.py,sha256=xghN20mgfEeh3vR_Vaiv0i2f1MBN6gk8J0uU3kXRK8g,2672
5
5
  discovery.py,sha256=WWF8SN2LH88mOUBJLavM7rvXcxi6uDQGpqRK20GysxA,3298
6
6
  manifest.py,sha256=feNCjkFWfhoVubevKjLtKoIEuzT1YGQn6wWgs9XM8_o,12229
7
7
  registry.py,sha256=zGutwVP39xaYqc3KDEXMWCV1tORYpqc5JISO8OaWP1Q,4470
@@ -27,9 +27,9 @@ commands/adapter.py,sha256=_68v3t-dRU0mszzL4udKs1bKennyg7RfBTaK2fDGTsE,3215
27
27
  commands/add.py,sha256=NJLQ-8-3zy7o6S9VLfL_wauP-Vz0oNGwN3nvtiwxNYM,15255
28
28
  commands/clean.py,sha256=RQBAfe6iCLsjMqUyVR55JdYX9MBqgrUuIrA8rFKs1J0,1102
29
29
  commands/clone.py,sha256=4APH34-yHjiXQIQwBnKOSEQ_sxV24_GKypcOJMfncvs,5912
30
- commands/config.py,sha256=PKuOX7CPRAy2j5NG1rhHYDFJT1XvZnOTF2qJW04v34Q,4940
30
+ commands/config.py,sha256=4kzDEjVpwrmMPK_DPYePdQe2lGh_b8waYORZDHCDYZw,6976
31
31
  commands/diff.py,sha256=37JMjvfAEfvK7-4X5iFbD-IGkS8ae4YSY7ZDIZF5B9E,5766
32
- commands/exec.py,sha256=ePbU25PiD9_kYBB0WdY3-A8yDHbSxZXbO3YhNFdlVzg,6706
32
+ commands/exec.py,sha256=zFmxxclpHQF39sqDpR5436XQiEYo334BGcQ5a8gbR9I,8711
33
33
  commands/find.py,sha256=zgqwUnaG5aLX6gJIU2ZeQzxsFh2s7oDNNtmV-e-62Jg,1663
34
34
  commands/help.py,sha256=5yhIpgGs1xPs2f39lg-ELE7D0tV_uUTjxQsgkWusIwo,1449
35
35
  commands/info.py,sha256=zywaUQsrvcPXcX8W49P7Jqnr90pX8nBPqnH1XcIs0Uk,4396
@@ -41,15 +41,20 @@ commands/sync.py,sha256=ZQoB5hBqrzvM6LUQVlKqHQVJib4dB5qe5M-pVG2vtGM,4946
41
41
  commands/update.py,sha256=bOWjdTNyeYg-hvXv5GfUzEtsTA7gU9JLM592GI9Oq68,11939
42
42
  commands/use.py,sha256=ggB28g2BDg3Lv3nF40wnDAJ7p0mo6C1pc1KgahvQYXM,1452
43
43
  commands/validate.py,sha256=Y8TLHxW4Z98onmzu-h-kDIET-48lVaIdQXOvuyBemLw,2361
44
- config/__init__.py,sha256=hNR-z3XcEI0nVdYbLAD3gvegNRejRJaNwksn1Au2ls0,2885
45
- config/defaults.py,sha256=mCtLnk641l5wnmEyVfJLozqkBnHheap2Px8A1KTROmA,308
46
- config/schema.py,sha256=KUebMCokJkymPyNcT2Bwm5BKwXk0SVGdoW24PRNX350,1387
44
+ config/__init__.py,sha256=glSjT1_y4aOfhZ8odrUWCGF1hBbY_huTjVp6suepHDY,3647
45
+ config/defaults.py,sha256=JfCltQYoE7EqBYlxsNrSITLmwifTvRrJe5lqL0Ys7Cs,986
46
+ config/env.py,sha256=WgnQXjhfvV7m1oxZCK9WdIX_rqLy_-BOSuPjbpjdI1c,7163
47
+ config/schema.py,sha256=VlvmiYWjU2hExBJfME90Oyqp-H4OHcUs_hvvp54K9jA,4498
48
+ policy/__init__.py,sha256=0sPJaruOyc9ioNyIcrTW72RgpaE64FgibS0h5mQELb8,1353
49
+ policy/defaults.py,sha256=9GMQM2l2OKTmhXlKwyTfcICR5vD9qEvyvqaR5KrN7ZI,620
50
+ policy/enforcement.py,sha256=djsosjjfdyr0SjnHF2kz4u3glvMNgd1CJztN6yZE-fM,2749
51
+ policy/profile.py,sha256=WDKaUagsWnBPGz5a_OOcxTsdZ66WjaIaR0R7ITVqy8g,6790
47
52
  skillcopy/__init__.py,sha256=YUglUkDzKfnCt4ar_DU33ksI9fGyn2UYbV7qn2c_BcU,2322
48
53
  skillcopy/local.py,sha256=QH6484dCenjg8pfNOyTRbQQBklEWhkkTnfQok5ssf_4,1049
49
54
  skillcopy/remote.py,sha256=83jRA2VfjtSDGO-YM1x3WGJjKvWzK1RmSTL7SdUOz8s,3155
50
- oasr-0.4.2.dist-info/METADATA,sha256=kzX9QzNlvAXMGO7RuiMpXUBbKj0Zj8LgdobXV3leyoE,17924
51
- oasr-0.4.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
52
- oasr-0.4.2.dist-info/entry_points.txt,sha256=VnMuOi6XYMbzAD2bP0X5uV1sQXjOqoDWJ33Lsxwq8u8,52
53
- oasr-0.4.2.dist-info/licenses/LICENSE,sha256=nQ1j9Ldb8FlJ-z7y2WuXPIlyfnYC7YPasjGdOBgcfP4,10561
54
- oasr-0.4.2.dist-info/licenses/NOTICE,sha256=EsfkCN0ZRDS0oj3ADvMKeKrAXaPlC8YfpSjvjGVv9jE,207
55
- oasr-0.4.2.dist-info/RECORD,,
55
+ oasr-0.5.1.dist-info/METADATA,sha256=fAfdWjbFnZGfRpQ7gJx1IgpKLbaXgpFZz6ddjdzumxc,17924
56
+ oasr-0.5.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
57
+ oasr-0.5.1.dist-info/entry_points.txt,sha256=VnMuOi6XYMbzAD2bP0X5uV1sQXjOqoDWJ33Lsxwq8u8,52
58
+ oasr-0.5.1.dist-info/licenses/LICENSE,sha256=nQ1j9Ldb8FlJ-z7y2WuXPIlyfnYC7YPasjGdOBgcfP4,10561
59
+ oasr-0.5.1.dist-info/licenses/NOTICE,sha256=EsfkCN0ZRDS0oj3ADvMKeKrAXaPlC8YfpSjvjGVv9jE,207
60
+ oasr-0.5.1.dist-info/RECORD,,
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)
File without changes