Skip to content

Commit 31d3aee

Browse files
fix(automation): protect managed skill ownership
1 parent 2f3fac9 commit 31d3aee

10 files changed

Lines changed: 164 additions & 57 deletions

File tree

.config/nextest.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
[profile.default]
2+
test-threads = 8
3+
14
[profile.ci]
25
fail-fast = false
36
retries = 1
47
flaky-result = "fail"
8+
test-threads = 8
59
slow-timeout = { period = "10s", terminate-after = 36 }
610
final-status-level = "slow"
711

@@ -22,6 +26,7 @@ filter = '(binary(=daemon_suite) & test(/^git_watch_test::(fifty_commit_rebase_n
2226
slow-timeout = { period = "60s", terminate-after = 10 }
2327

2428
[test-groups]
29+
cli-subprocess = { max-threads = 1 }
2530
windows-tracedecay-init = { max-threads = 32 }
2631
windows-init-heavy = { max-threads = 32 }
2732
windows-profile-storage = { max-threads = 32 }
@@ -47,6 +52,16 @@ threads-required = "num-cpus"
4752
filter = 'binary(=core_cli_suite) & test(/^tool_daemon_test::(daemon_socket_is_owner_only|daemon_sigterm_exits_while_project_client_is_connected)$/)'
4853
threads-required = "num-cpus"
4954

55+
[[profile.ci.overrides]]
56+
filter = '(binary(=core_cli_suite) & test(/^cli_non_interactive_test::/)) | (binary(=mcp_suite) & test(/^(mcp_cli_serve_test|serve_degraded_mode_test|serve_template_path_test)::/))'
57+
test-group = 'cli-subprocess'
58+
threads-required = "num-cpus"
59+
60+
[[profile.default.overrides]]
61+
filter = '(binary(=core_cli_suite) & test(/^cli_non_interactive_test::/)) | (binary(=mcp_suite) & test(/^(mcp_cli_serve_test|serve_degraded_mode_test|serve_template_path_test)::/))'
62+
test-group = 'cli-subprocess'
63+
threads-required = "num-cpus"
64+
5065
[[profile.ci.overrides]]
5166
filter = 'binary(=agent_suite) | (binary(=storage_suite) & test(/^branch_db_safety_test::/)) | (binary(=core_cli_suite) & test(/^cli_non_interactive_test::/)) | (binary(=mcp_suite) & test(/^(mcp_cli_serve_test|serve_template_path_test)::/))'
5267
platform = { host = 'cfg(windows)' }

src/agents/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ pub struct ManagedSkillExportReport {
9393
pub error: Option<String>,
9494
}
9595

96+
pub(crate) fn uses_default_user_profile(home: &Path, profile_root: &Path) -> bool {
97+
profile_root == home.join(".tracedecay")
98+
}
99+
96100
/// Re-runs the managed-skill overlay/prompt-index export for every agent
97101
/// integration that already has tracedecay installed under `home`, so a
98102
/// lifecycle change (approve/disable/archive/restore) deploys without
@@ -106,6 +110,9 @@ pub fn export_managed_skills_to_agents(
106110
home: &Path,
107111
profile_root: &Path,
108112
) -> Vec<ManagedSkillExportReport> {
113+
if !uses_default_user_profile(home, profile_root) {
114+
return Vec::new();
115+
}
109116
let mut reports = Vec::new();
110117
for ag in all_integrations() {
111118
match ag.export_managed_skills(home, profile_root) {
@@ -136,6 +143,9 @@ pub fn export_managed_skills_to_agent_hosts(
136143
project_root: &Path,
137144
profile_root: &Path,
138145
) -> Vec<ManagedSkillExportReport> {
146+
if !uses_default_user_profile(home, profile_root) {
147+
return Vec::new();
148+
}
139149
let mut reports = Vec::new();
140150
for ag in all_integrations() {
141151
let mut exports = Vec::new();

src/automation/skill_materialization.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1723,6 +1723,9 @@ pub fn reconcile_detected_scopes(
17231723
home: &Path,
17241724
project_root: &Path,
17251725
) -> (Vec<ScopeReconcileResult>, Vec<String>) {
1726+
if !crate::agents::uses_default_user_profile(home, profile_root) {
1727+
return (Vec::new(), Vec::new());
1728+
}
17261729
let mut results = Vec::new();
17271730
let mut errors = Vec::new();
17281731
let skills = match load_active_managed_skills(profile_root) {

src/automation/skill_writer.rs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,7 @@ fn refresh_managed_skill_exports_after_auto_enable(profile_root: &Path) -> Value
553553
let Some(home) = crate::agents::home_dir() else {
554554
return json!({"status": "skipped", "reason": "home_unavailable"});
555555
};
556-
if !should_refresh_managed_skill_exports(profile_root, &home) {
556+
if !crate::agents::uses_default_user_profile(&home, profile_root) {
557557
return json!({"status": "skipped", "reason": "non_default_profile_root"});
558558
}
559559
let start = std::env::current_dir().unwrap_or_else(|_| home.clone());
@@ -582,10 +582,6 @@ fn refresh_managed_skill_exports_after_auto_enable(profile_root: &Path) -> Value
582582
})
583583
}
584584

585-
fn should_refresh_managed_skill_exports(profile_root: &Path, home: &Path) -> bool {
586-
profile_root == home.join(".tracedecay")
587-
}
588-
589585
fn accepted_skill_approval_status(
590586
action: SkillProposalAction,
591587
auto_enable_skills: bool,
@@ -688,6 +684,9 @@ fn skill_update_from_proposal(
688684
let existing = existing_skills
689685
.get(&id)
690686
.ok_or_else(|| format!("managed skill id '{id}' does not exist"))?;
687+
if existing.metadata.provenance.source != ManagedSkillSource::AutomationRun {
688+
return Err(format!("managed skill '{id}' is not automation-owned"));
689+
}
691690
let base_checksum = required_proposal_string(object.get("base_checksum"), "base_checksum")?;
692691
if base_checksum != existing.metadata.checksum {
693692
return Err(format!(
@@ -870,13 +869,13 @@ mod tests {
870869
#[test]
871870
fn managed_skill_exports_only_refresh_for_the_user_profile() {
872871
let home = Path::new("/home/test-user");
873-
assert!(should_refresh_managed_skill_exports(
874-
Path::new("/home/test-user/.tracedecay"),
872+
assert!(crate::agents::uses_default_user_profile(
875873
home,
874+
Path::new("/home/test-user/.tracedecay"),
876875
));
877-
assert!(!should_refresh_managed_skill_exports(
878-
Path::new("/tmp/tracedecay-test-profile"),
876+
assert!(!crate::agents::uses_default_user_profile(
879877
home,
878+
Path::new("/tmp/tracedecay-test-profile"),
880879
));
881880
}
882881

src/automation/skill_writer/consolidation.rs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,8 @@ fn consolidation_guard<'a>(
4949
"managed skill '{id}' is pinned and exempt from consolidation"
5050
));
5151
}
52-
if skill.metadata.provenance.source == ManagedSkillSource::UserDraft {
53-
return Err(format!(
54-
"managed skill '{id}' is user-authored and exempt from consolidation"
55-
));
52+
if skill.metadata.provenance.source != ManagedSkillSource::AutomationRun {
53+
return Err(format!("managed skill '{id}' is not automation-owned"));
5654
}
5755
if skill.metadata.state == ManagedSkillState::Archived {
5856
return Err(format!("managed skill '{id}' is already archived"));
@@ -302,6 +300,7 @@ mod tests {
302300
fixture_skill("workflow-b", ManagedSkillSource::AutomationRun, false),
303301
fixture_skill("pinned-skill", ManagedSkillSource::AutomationRun, true),
304302
fixture_skill("user-skill", ManagedSkillSource::UserDraft, false),
303+
fixture_skill("imported-skill", ManagedSkillSource::Import, false),
305304
archived,
306305
]
307306
.into_iter()
@@ -392,7 +391,19 @@ mod tests {
392391
}),
393392
&skills,
394393
),
395-
"managed skill 'user-skill' is user-authored and exempt from consolidation",
394+
"managed skill 'user-skill' is not automation-owned",
395+
);
396+
assert_err_eq(
397+
skill_archive_from_proposal(
398+
&json!({
399+
"action": "archive",
400+
"id": "imported-skill",
401+
"base_checksum": checksum(&skills, "imported-skill"),
402+
"reason": "x"
403+
}),
404+
&skills,
405+
),
406+
"managed skill 'imported-skill' is not automation-owned",
396407
);
397408
assert_err_eq(
398409
skill_archive_from_proposal(

0 commit comments

Comments
 (0)