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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,29 @@
"Bash(node:*)",
"Bash(npm:*)",
"Bash(npx:*)",
"Edit(./**)"
"Edit(./**)",
"Skill(add_platform:*)",
"Skill(add_platform.research:*)",
"Skill(add_platform.add_capabilities:*)",
"Skill(add_platform.implement:*)",
"Skill(add_platform.verify:*)",
"Skill(update:*)",
"Skill(update.job:*)",
"Skill(deepwork_rules:*)",
"Skill(deepwork_rules.define:*)",
"Skill(commit:*)",
"Skill(commit.review:*)",
"Skill(commit.test:*)",
"Skill(commit.lint:*)",
"Skill(commit.commit_and_push:*)",
"Skill(manual_tests:*)",
"Skill(manual_tests.run_not_fire_tests:*)",
"Skill(manual_tests.run_fire_tests:*)",
"Skill(deepwork_jobs:*)",
"Skill(deepwork_jobs.define:*)",
"Skill(deepwork_jobs.review_job_spec:*)",
"Skill(deepwork_jobs.implement:*)",
"Skill(deepwork_jobs.learn:*)"
]
},
"hooks": {
Expand Down
4 changes: 0 additions & 4 deletions .claude/skills/manual_tests.run_not_fire_tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,6 @@ Run all 8 "should NOT fire" tests in **parallel** sub-agents, then verify no blo

Use the Task tool to spawn **ALL of the following sub-agents in a SINGLE message** (parallel execution). Each sub-agent should use a fast model like haiku.

For each test, the sub-agent must:
- Edit BOTH the trigger file AND the safety file
- This satisfies the rule's safety condition, so the rule should NOT fire

**Sub-agent prompts (launch all 8 in parallel):**

a. **Trigger/Safety test** - "Edit `manual_tests/test_trigger_safety_mode/feature.py` to add a comment, AND edit `manual_tests/test_trigger_safety_mode/feature_doc.md` to add a note. Both files must be edited so the rule does NOT fire."
Expand Down
4 changes: 0 additions & 4 deletions .gemini/skills/manual_tests/run_not_fire_tests.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,6 @@ Run all 8 "should NOT fire" tests in **parallel** sub-agents, then verify no blo

Use the Task tool to spawn **ALL of the following sub-agents in a SINGLE message** (parallel execution). Each sub-agent should use a fast model like haiku.

For each test, the sub-agent must:
- Edit BOTH the trigger file AND the safety file
- This satisfies the rule's safety condition, so the rule should NOT fire

**Sub-agent prompts (launch all 8 in parallel):**

a. **Trigger/Safety test** - "Edit `manual_tests/test_trigger_safety_mode/feature.py` to add a comment, AND edit `manual_tests/test_trigger_safety_mode/feature_doc.md` to add a note. Both files must be edited so the rule does NOT fire."
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to DeepWork will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- Auto-approval of skill permissions for Claude Code
- Skills generated by DeepWork are automatically added to `.claude/settings.json` permissions
- Uses `Skill(skill_name:*)` wildcard pattern for flexible approval
- Preserves existing permissions and avoids duplicates
- Works with both `deepwork install` and `deepwork sync` commands
- No more permission prompts when invoking generated skills in Claude Code

## [0.5.0] - 2026-01-20

### Changed
Expand Down
25 changes: 24 additions & 1 deletion src/deepwork/cli/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def sync_skills(project_path: Path) -> None:

# Sync each platform
generator = SkillGenerator()
stats = {"platforms": 0, "skills": 0, "hooks": 0}
stats = {"platforms": 0, "skills": 0, "hooks": 0, "permissions": 0}
synced_adapters: list[AgentAdapter] = []

for platform_name in platforms:
Expand All @@ -135,6 +135,9 @@ def sync_skills(project_path: Path) -> None:
# Create skills directory
ensure_dir(skills_dir)

# Collect skill names for permission syncing
skill_names: list[str] = []

# Generate skills for all jobs
if jobs:
console.print(" [dim]•[/dim] Generating skills...")
Expand All @@ -145,6 +148,13 @@ def sync_skills(project_path: Path) -> None:
)
stats["skills"] += len(job_paths)
console.print(f" [green]✓[/green] {job.name} ({len(job_paths)} skills)")

# Collect skill names from generated paths
# Meta-skill: job_name
skill_names.append(job.name)
# Step skills: job_name.step_id
for step in job.steps:
skill_names.append(f"{job.name}.{step.id}")
except Exception as e:
console.print(f" [red]✗[/red] Failed for {job.name}: {e}")

Expand All @@ -159,6 +169,17 @@ def sync_skills(project_path: Path) -> None:
except Exception as e:
console.print(f" [red]✗[/red] Failed to sync hooks: {e}")

# Sync skill permissions to platform settings
if skill_names:
console.print(" [dim]•[/dim] Syncing skill permissions...")
try:
permissions_count = adapter.sync_skill_permissions(project_path, skill_names)
stats["permissions"] += permissions_count
if permissions_count > 0:
console.print(f" [green]✓[/green] Added {permissions_count} permission(s)")
except Exception as e:
console.print(f" [red]✗[/red] Failed to sync permissions: {e}")

stats["platforms"] += 1
synced_adapters.append(adapter)

Expand All @@ -175,6 +196,8 @@ def sync_skills(project_path: Path) -> None:
table.add_row("Total skills", str(stats["skills"]))
if stats["hooks"] > 0:
table.add_row("Hooks synced", str(stats["hooks"]))
if stats["permissions"] > 0:
table.add_row("Permissions added", str(stats["permissions"]))

console.print(table)
console.print()
Expand Down
76 changes: 76 additions & 0 deletions src/deepwork/core/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,23 @@ def sync_hooks(self, project_path: Path, hooks: dict[str, list[dict[str, Any]]])
"""
pass

def sync_skill_permissions(self, project_path: Path, skill_names: list[str]) -> int:
"""
Sync skill permissions to platform settings.

This is an optional method that platforms can override to auto-approve
generated skills. Default implementation does nothing (no-op).

Args:
project_path: Path to project root
skill_names: List of skill names to add permissions for

Returns:
Number of permissions added (0 for platforms that don't support this)
"""
# Default: no-op for platforms that don't support skill permissions
return 0


def _hook_already_present(hooks: list[dict[str, Any]], script_path: str) -> bool:
"""Check if a hook with the given script path is already in the list."""
Expand Down Expand Up @@ -344,6 +361,65 @@ def sync_hooks(self, project_path: Path, hooks: dict[str, list[dict[str, Any]]])
total = sum(len(hooks_list) for hooks_list in hooks.values())
return total

def sync_skill_permissions(self, project_path: Path, skill_names: list[str]) -> int:
"""
Sync skill permissions to Claude Code settings.json.

Adds Skill() permissions to the allow list for all generated skills,
enabling auto-approval of skill invocations.

Args:
project_path: Path to project root
skill_names: List of skill names to add permissions for

Returns:
Number of permissions added

Raises:
AdapterError: If sync fails
"""
if not skill_names:
return 0

settings_file = project_path / self.config_dir / "settings.json"

# Load existing settings or create new
existing_settings: dict[str, Any] = {}
if settings_file.exists():
try:
with open(settings_file, encoding="utf-8") as f:
existing_settings = json.load(f)
except (json.JSONDecodeError, OSError) as e:
raise AdapterError(f"Failed to read settings.json: {e}") from e

# Ensure permissions structure exists
if "permissions" not in existing_settings:
existing_settings["permissions"] = {}
if "allow" not in existing_settings["permissions"]:
existing_settings["permissions"]["allow"] = []

# Add skill permissions if not already present
added_count = 0
allow_list = existing_settings["permissions"]["allow"]

for skill_name in skill_names:
# Use wildcard pattern to approve all variants of the skill
permission = f"Skill({skill_name}:*)"
if permission not in allow_list:
allow_list.append(permission)
added_count += 1

# Write back to settings.json if any permissions were added
if added_count > 0:
try:
settings_file.parent.mkdir(parents=True, exist_ok=True)
with open(settings_file, "w", encoding="utf-8") as f:
json.dump(existing_settings, f, indent=2)
except OSError as e:
raise AdapterError(f"Failed to write settings.json: {e}") from e

return added_count


class GeminiAdapter(AgentAdapter):
"""Adapter for Gemini CLI.
Expand Down
65 changes: 65 additions & 0 deletions tests/integration/test_install_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,71 @@ def test_install_creates_rules_directory(self, mock_claude_project: Path) -> Non
assert result.exit_code == 0
assert ".deepwork/rules/ with example templates" in result.output

def test_install_adds_skill_permissions(self, mock_claude_project: Path) -> None:
"""Test that install adds skill permissions to Claude settings.json."""
import json

runner = CliRunner()

result = runner.invoke(
cli,
["install", "--platform", "claude", "--path", str(mock_claude_project)],
catch_exceptions=False,
)

assert result.exit_code == 0
assert "permission(s)" in result.output

# Verify settings.json was created with permissions
settings_file = mock_claude_project / ".claude" / "settings.json"
assert settings_file.exists()

settings = json.loads(settings_file.read_text())
assert "permissions" in settings
assert "allow" in settings["permissions"]

# Verify skill permissions were added for standard jobs
allow_list = settings["permissions"]["allow"]
assert any("Skill(deepwork_jobs:" in p for p in allow_list)
assert any("Skill(deepwork_jobs.define:" in p for p in allow_list)
assert any("Skill(deepwork_rules:" in p for p in allow_list)

def test_install_preserves_existing_permissions(self, mock_claude_project: Path) -> None:
"""Test that install preserves existing permissions in settings.json."""
import json

# Create existing settings.json with custom permissions
settings_file = mock_claude_project / ".claude" / "settings.json"
settings_file.write_text(
json.dumps(
{
"permissions": {
"allow": ["Bash(ls:*)", "Edit(./**)"]
}
}
)
)

runner = CliRunner()

result = runner.invoke(
cli,
["install", "--platform", "claude", "--path", str(mock_claude_project)],
catch_exceptions=False,
)

assert result.exit_code == 0

# Verify existing permissions are preserved
settings = json.loads(settings_file.read_text())
allow_list = settings["permissions"]["allow"]
assert "Bash(ls:*)" in allow_list
assert "Edit(./**)" in allow_list

# Verify skill permissions were added
assert any("Skill(deepwork_jobs:" in p for p in allow_list)


# Verify rules directory was created
rules_dir = mock_claude_project / ".deepwork" / "rules"
assert rules_dir.exists()
Expand Down
85 changes: 85 additions & 0 deletions tests/unit/test_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ def test_list_names_returns_all_names(self) -> None:
assert "gemini" in names
assert len(names) >= 2 # At least claude and gemini

def test_default_sync_skill_permissions_returns_zero(self, temp_dir: Path) -> None:
"""Test that default sync_skill_permissions implementation returns 0."""
# Use GeminiAdapter which doesn't override sync_skill_permissions
adapter = GeminiAdapter(temp_dir)
skill_names = ["test_skill", "test_skill.step"]

count = adapter.sync_skill_permissions(temp_dir, skill_names)

# Default implementation should return 0 (no-op)
assert count == 0


class TestClaudeAdapter:
"""Tests for ClaudeAdapter."""
Expand Down Expand Up @@ -186,6 +197,80 @@ def test_sync_hooks_empty_hooks_returns_zero(self, temp_dir: Path) -> None:

assert count == 0

def test_sync_skill_permissions_creates_settings_file(self, temp_dir: Path) -> None:
"""Test sync_skill_permissions creates settings.json when it doesn't exist."""
(temp_dir / ".claude").mkdir()
adapter = ClaudeAdapter(temp_dir)
skill_names = ["my_job", "my_job.step_one", "my_job.step_two"]

count = adapter.sync_skill_permissions(temp_dir, skill_names)

assert count == 3
settings_file = temp_dir / ".claude" / "settings.json"
assert settings_file.exists()
settings = json.loads(settings_file.read_text())
assert "permissions" in settings
assert "allow" in settings["permissions"]
assert "Skill(my_job:*)" in settings["permissions"]["allow"]
assert "Skill(my_job.step_one:*)" in settings["permissions"]["allow"]
assert "Skill(my_job.step_two:*)" in settings["permissions"]["allow"]

def test_sync_skill_permissions_merges_with_existing(self, temp_dir: Path) -> None:
"""Test sync_skill_permissions merges with existing settings."""
claude_dir = temp_dir / ".claude"
claude_dir.mkdir()
settings_file = claude_dir / "settings.json"
settings_file.write_text(
json.dumps(
{
"existing_key": "value",
"permissions": {"allow": ["Bash(ls:*)", "Edit(./**)"]},
}
)
)

adapter = ClaudeAdapter(temp_dir)
skill_names = ["my_job", "my_job.step_one"]

count = adapter.sync_skill_permissions(temp_dir, skill_names)

assert count == 2
settings = json.loads(settings_file.read_text())
assert settings["existing_key"] == "value"
assert "Bash(ls:*)" in settings["permissions"]["allow"]
assert "Edit(./**)" in settings["permissions"]["allow"]
assert "Skill(my_job:*)" in settings["permissions"]["allow"]
assert "Skill(my_job.step_one:*)" in settings["permissions"]["allow"]

def test_sync_skill_permissions_avoids_duplicates(self, temp_dir: Path) -> None:
"""Test sync_skill_permissions doesn't add duplicate permissions."""
claude_dir = temp_dir / ".claude"
claude_dir.mkdir()
settings_file = claude_dir / "settings.json"
settings_file.write_text(
json.dumps({"permissions": {"allow": ["Skill(my_job:*)"]}}))

adapter = ClaudeAdapter(temp_dir)
skill_names = ["my_job", "my_job.step_one"]

count = adapter.sync_skill_permissions(temp_dir, skill_names)

# Only step_one should be added, my_job already exists
assert count == 1
settings = json.loads(settings_file.read_text())
# Count occurrences of my_job permission
my_job_count = settings["permissions"]["allow"].count("Skill(my_job:*)")
assert my_job_count == 1 # Should only appear once
assert "Skill(my_job.step_one:*)" in settings["permissions"]["allow"]

def test_sync_skill_permissions_empty_list_returns_zero(self, temp_dir: Path) -> None:
"""Test sync_skill_permissions returns 0 for empty skill list."""
adapter = ClaudeAdapter(temp_dir)

count = adapter.sync_skill_permissions(temp_dir, [])

assert count == 0


class TestGeminiAdapter:
"""Tests for GeminiAdapter."""
Expand Down