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 +1 -1
- commands/config.py +78 -26
- commands/exec.py +62 -4
- config/__init__.py +30 -11
- config/defaults.py +24 -0
- config/env.py +248 -0
- config/schema.py +75 -0
- {oasr-0.4.2.dist-info → oasr-0.5.1.dist-info}/METADATA +1 -1
- {oasr-0.4.2.dist-info → oasr-0.5.1.dist-info}/RECORD +17 -12
- policy/__init__.py +50 -0
- policy/defaults.py +27 -0
- policy/enforcement.py +98 -0
- policy/profile.py +185 -0
- {oasr-0.4.2.dist-info → oasr-0.5.1.dist-info}/WHEEL +0 -0
- {oasr-0.4.2.dist-info → oasr-0.5.1.dist-info}/entry_points.txt +0 -0
- {oasr-0.4.2.dist-info → oasr-0.5.1.dist-info}/licenses/LICENSE +0 -0
- {oasr-0.4.2.dist-info → oasr-0.5.1.dist-info}/licenses/NOTICE +0 -0
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.
|
|
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
|
|
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
|
-
#
|
|
65
|
-
if
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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"✓
|
|
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
|
-
|
|
93
|
-
print(f"Error:
|
|
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
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
+
file_config = tomllib.load(f)
|
|
77
|
+
|
|
78
|
+
# Load environment variables
|
|
79
|
+
env_config = load_env_config()
|
|
64
80
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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,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=
|
|
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=
|
|
30
|
+
commands/config.py,sha256=4kzDEjVpwrmMPK_DPYePdQe2lGh_b8waYORZDHCDYZw,6976
|
|
31
31
|
commands/diff.py,sha256=37JMjvfAEfvK7-4X5iFbD-IGkS8ae4YSY7ZDIZF5B9E,5766
|
|
32
|
-
commands/exec.py,sha256=
|
|
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=
|
|
45
|
-
config/defaults.py,sha256=
|
|
46
|
-
config/
|
|
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.
|
|
51
|
-
oasr-0.
|
|
52
|
-
oasr-0.
|
|
53
|
-
oasr-0.
|
|
54
|
-
oasr-0.
|
|
55
|
-
oasr-0.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|