Skip to content
Merged
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]
### Added

- **Skills**: `[skills.trust] require_integrity_check_on_promote` (default `true`) automatically
arms the per-invocation BLAKE3 integrity re-check (`requires_trust_check`) whenever a skill is
promoted to `trusted`/`verified`, at both the CLI (`zeph skill trust`) and in-session
(`/skill trust`) promotion handlers. Previously the re-check could only be armed manually via
`--require-check`, so an operator who forgot the flag left a promoted skill's tampered-on-disk
`SKILL.md` undetected between promotions (#6087). `--require-check`/`--no-require-check`
(mutually exclusive) continue to override the config default per command; promotion to
`quarantined`/`blocked` leaves `requires_trust_check` untouched. A migration step (80) adds a
commented advisory for the new key to existing configs that already declare `[skills.trust]`.
Self-learning/heuristic auto-promotion and reload trust-assignment are intentionally out of
scope for this change — see the PR description.

### Testing

- `zeph-llm`: reduced the discoverability of bypassing the Claude no-prefill funnel introduced
Expand Down
36 changes: 30 additions & 6 deletions book/src/advanced/skill-trust.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,21 +66,41 @@ BLAKE3 hash is recomputed and compared against the stored hash on *every* dispat
`invoke_skill`, and `zeph skill invoke`), not just at promotion time. A mismatch aborts the
invocation and demotes the skill to `quarantined` immediately, before its body is ever returned.

Arm it with `--require-check` on the trust-setting commands:
### Automatic activation on promotion (default)

Promoting a skill to `trusted` or `verified` — via `zeph skill trust <name> trusted|verified` or
`/skill trust <name> trusted|verified` — arms `requires_trust_check` **automatically** unless
disabled, per `[skills.trust] require_integrity_check_on_promote` (default `true`). This closes
the "operator forgot to pass `--require-check`" gap: Trusted/Verified is the trust tier whose body
is dispatched verbatim (no sanitization), so it is the choke point where a tampered `SKILL.md`
matters most.

Promoting to `quarantined` or `blocked` never touches `requires_trust_check` — a previously armed
flag survives a temporary demotion and re-promotion.

Override per command:

```bash
# CLI
# CLI — force the check on even if the config default is false
zeph skill trust my-skill trusted --require-check

# CLI — skip arming even though the config default is true
zeph skill trust my-skill trusted --no-require-check
```

```text
# In-session
# In-session — same two overrides
/skill trust my-skill trusted --require-check
/skill trust my-skill trusted --no-require-check
```

`--require-check` and `--no-require-check` are mutually exclusive and always win over the config
default. With neither flag, the command falls back to
`[skills.trust] require_integrity_check_on_promote`.

`/skill trust <name>` (no level argument) shows the flag's current state as
`requires_trust_check=true|false`. There is no command to clear it yet — the only way is to
manually update the `requires_trust_check` column in the `skill_trust` SQLite table directly.
`requires_trust_check=true|false`. There is no command to clear it once armed — the only way is
to manually update the `requires_trust_check` column in the `skill_trust` SQLite table directly.
This is a known usability gap, not a security one (the flag only ever makes enforcement
stricter).

Expand All @@ -100,7 +120,7 @@ External skills installed via `zeph skill install` are stored in `~/.config/zeph
|---------|-------------|
| `/skill trust` | List all skills with their trust level, source, and hash |
| `/skill trust <name>` | Show trust details for a specific skill |
| `/skill trust <name> <level>` | Set trust level (`trusted`, `verified`, `quarantined`, `blocked`); append `--require-check` to also arm the [per-invocation integrity re-check](#per-invocation-integrity-re-check) |
| `/skill trust <name> <level>` | Set trust level (`trusted`, `verified`, `quarantined`, `blocked`). Promotion to `trusted`/`verified` arms the [per-invocation integrity re-check](#per-invocation-integrity-re-check) by default; append `--require-check`/`--no-require-check` to force it on/off |
| `/skill block <name>` | Block a skill (all tool access denied) |
| `/skill unblock <name>` | Unblock a skill (reverts to `quarantined`) |
| `/skill install <url\|path>` | Install an external skill (git URL or local path) with hot reload |
Expand Down Expand Up @@ -128,6 +148,9 @@ default_level = "quarantined"
local_level = "trusted"
# Trust level assigned after BLAKE3 hash mismatch on hot-reload
hash_mismatch_level = "quarantined"
# Arm the per-invocation integrity re-check by default on promotion to trusted/verified
# (see "Automatic activation on promotion" above)
require_integrity_check_on_promote = true
```

Environment variable overrides:
Expand All @@ -136,4 +159,5 @@ Environment variable overrides:
export ZEPH_SKILLS_TRUST_DEFAULT_LEVEL=quarantined
export ZEPH_SKILLS_TRUST_LOCAL_LEVEL=trusted
export ZEPH_SKILLS_TRUST_HASH_MISMATCH_LEVEL=quarantined
export ZEPH_SKILLS_TRUST_REQUIRE_INTEGRITY_CHECK_ON_PROMOTE=true
```
8 changes: 4 additions & 4 deletions book/src/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Manage external skills. Installed skills are stored in `~/.config/zeph/skills/`.
| `skill remove <name>` | Remove an installed skill by name |
| `skill list` | List installed skills with trust level and source metadata |
| `skill verify [name]` | Verify BLAKE3 integrity of one or all installed skills |
| `skill trust <name> [level]` | Show or set trust level (`trusted`, `verified`, `quarantined`, `blocked`); add `--require-check` when setting a level to also arm the per-invocation BLAKE3 integrity re-check (see [Skill Trust Levels](../advanced/skill-trust.md#per-invocation-integrity-re-check)) |
| `skill trust <name> [level]` | Show or set trust level (`trusted`, `verified`, `quarantined`, `blocked`). Promoting to `trusted`/`verified` arms the per-invocation BLAKE3 integrity re-check by default (`[skills.trust] require_integrity_check_on_promote`); add `--require-check`/`--no-require-check` to force it on/off (see [Skill Trust Levels](../advanced/skill-trust.md#per-invocation-integrity-re-check)) |
| `skill block <name>` | Block a skill (deny all tool access) |
| `skill unblock <name>` | Unblock a skill (revert to `quarantined`) |
| `skill promote-heuristics [--skill <name>]` | Dry-run: show skills eligible for A6 heuristic → full promotion (requires `[skills.learning.heuristic_promotion_enabled = true]`) |
Expand All @@ -108,12 +108,12 @@ zeph skill install /path/to/my-skill
# List installed skills
zeph skill list

# Verify integrity and promote trust
# Verify integrity and promote trust (arms the per-invocation integrity re-check by default)
zeph skill verify my-skill
zeph skill trust my-skill trusted

# Promote and also arm the per-invocation integrity re-check
zeph skill trust my-skill trusted --require-check
# Promote without arming the per-invocation integrity re-check
zeph skill trust my-skill trusted --no-require-check

# Remove a skill
zeph skill remove my-skill
Expand Down
4 changes: 4 additions & 0 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,10 @@ local_level = "trusted"
hash_mismatch_level = "quarantined"
# Scan skill body content for injection patterns at load time (advisory, secure by default)
scan_on_load = true
# Arm the per-invocation blake3 integrity re-check when a skill is promoted to trusted/verified
# (secure by default). `--require-check`/`--no-require-check` on `skill trust`/`/skill trust`
# always override this default (#6087).
require_integrity_check_on_promote = true

[skills.trust.scanner]
# Scan for injection patterns in skill body (advisory, logs warnings)
Expand Down
63 changes: 62 additions & 1 deletion crates/zeph-config/src/migrate/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

use regex::Regex;

use super::{MigrateError, MigrationResult, insert_after_section};
use super::{MigrateError, MigrationResult, insert_after_section, section_header_present};

/// Regex matching the `[tui]` section header line (used by step 67).
static TUI_HEADER_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Expand Down Expand Up @@ -846,3 +846,64 @@ pub fn migrate_skills_registry(toml_src: &str) -> Result<MigrationResult, Migrat
sections_changed: vec!["skills.registry".to_owned()],
})
}

/// Regex matching the `[skills.trust]` section header line (used by step 80).
static SKILLS_TRUST_HEADER_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"(?m)^[ \t]*\[skills\.trust\][ \t]*(?:#[^\r\n]*)?\r?\n").expect("static pattern")
});

/// Step 80 — add a commented `require_integrity_check_on_promote = true` advisory to an
/// existing active `[skills.trust]` table (#6087).
///
/// The field has `#[serde(default = "default_true")]`, so existing configs already behave as
/// if it were `true` without this migration — this step only surfaces the new key for
/// discoverability on configs that already declare `[skills.trust]` explicitly. No-op when
/// `[skills.trust]` is absent (nothing to annotate) or the key (active or commented) is
/// already present.
///
/// # Errors
///
/// Returns [`MigrateError`] if the source is not valid TOML.
pub fn migrate_skill_trust_require_check(toml_src: &str) -> Result<MigrationResult, MigrateError> {
if !section_header_present(toml_src, "skills.trust") {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}

let already_present = toml_src.lines().any(|l| {
l.trim()
.trim_start_matches('#')
.trim()
.starts_with("require_integrity_check_on_promote")
});
if already_present || !SKILLS_TRUST_HEADER_RE.is_match(toml_src) {
return Ok(MigrationResult {
output: toml_src.to_owned(),
changed_count: 0,
sections_changed: Vec::new(),
});
}

let comment = "# require_integrity_check_on_promote = true # arm per-invocation blake3 \
re-check on promotion to trusted/verified; override with --no-require-check (#6087)\n";
let output = SKILLS_TRUST_HEADER_RE
.replacen(toml_src, 1, |caps: &regex::Captures| {
format!("{}{comment}", &caps[0])
})
.into_owned();

let changed = output != toml_src;
let changed_count = usize::from(changed);
Ok(MigrationResult {
output,
changed_count,
sections_changed: if changed {
vec!["skills.trust.require_integrity_check_on_promote".to_owned()]
} else {
Vec::new()
},
})
}
11 changes: 7 additions & 4 deletions crates/zeph-config/src/migrate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ pub use features::{
migrate_deep_link_config, migrate_five_signal_config, migrate_goals_config,
migrate_knowledge_config, migrate_magic_docs_config, migrate_microcompact_config,
migrate_orchestration_asset_sensitivity, migrate_orchestration_persistence,
migrate_skills_registry, migrate_tui_delights, migrate_tui_mouse, migrate_tui_theme_config,
migrate_tui_theme_defaults,
migrate_skill_trust_require_check, migrate_skills_registry, migrate_tui_delights,
migrate_tui_mouse, migrate_tui_theme_config, migrate_tui_theme_defaults,
};
pub use infra::*;
/// Advisory `GonkaGate` migration is crate-internal (registered via the [`MIGRATIONS`] registry).
Expand Down Expand Up @@ -618,8 +618,8 @@ use steps::{
MigrateQualityConfig, MigrateSandboxConfig, MigrateSandboxEgressFilter, MigrateSchedulerDaemon,
MigrateSecretMaskingConfig, MigrateServeConfig, MigrateSessionPersistProviderOverrides,
MigrateSessionPersistenceConfig, MigrateSessionProviderPersistence, MigrateSessionRecapConfig,
MigrateShellCheckpointsConfig, MigrateShellTransactional, MigrateSkillsRegistry,
MigrateSttToProvider, MigrateSupervisorConfig, MigrateTelemetryConfig,
MigrateShellCheckpointsConfig, MigrateShellTransactional, MigrateSkillTrustRequireCheck,
MigrateSkillsRegistry, MigrateSttToProvider, MigrateSupervisorConfig, MigrateTelemetryConfig,
MigrateToolsCompressionConfig, MigrateTraceMetadata, MigrateTuiDelights, MigrateTuiMouse,
MigrateTuiThemeConfig, MigrateTuiThemeDefaults, MigrateUtilityHighGainTools,
MigrateVigilConfig, MigrateWorktreeConfig, MigrateWorktreeGitTimeout,
Expand Down Expand Up @@ -767,6 +767,9 @@ pub static MIGRATIONS: std::sync::LazyLock<Vec<Box<dyn Migration + Send + Sync>>
Box::new(MigrateSkillsRegistry),
// Step 79 — add shared_db = false advisory to an existing active [durable] table (#5996)
Box::new(MigrateDurableSharedDb),
// Step 80 — add require_integrity_check_on_promote advisory to an existing active
// [skills.trust] table (#6087)
Box::new(MigrateSkillTrustRequireCheck),
]
});

Expand Down
29 changes: 22 additions & 7 deletions crates/zeph-config/src/migrate/steps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
//! step 77 adds a commented `[[acp.auth_clients]]` advisory block (#5868);
//! step 78 adds a commented `[skills.registry]` advisory block (spec-045, #5869);
//! step 79 adds a commented `shared_db = false` advisory to an existing active `[durable]`
//! table (INV-8 `encryption_gate`, #5996).
//! table (INV-8 `encryption_gate`, #5996);
//! step 80 adds a commented `require_integrity_check_on_promote = true` advisory to an
//! existing active `[skills.trust]` table (#6087).
//!
//! Each struct is a zero-size type that delegates to the corresponding free function in
//! `super`. They exist solely to satisfy the object-safe [`super::Migration`] trait so the
Expand Down Expand Up @@ -72,12 +74,12 @@ use super::{
migrate_scheduler_daemon_config, migrate_secret_masking_config, migrate_serve_config,
migrate_session_persist_provider_overrides, migrate_session_persistence_config,
migrate_session_provider_persistence, migrate_session_recap_config,
migrate_shell_checkpoints_config, migrate_shell_transactional, migrate_skills_registry,
migrate_stt_to_provider, migrate_supervisor_config, migrate_telemetry_config,
migrate_tools_compression_config, migrate_trace_metadata, migrate_tui_delights,
migrate_tui_mouse, migrate_tui_theme_config, migrate_tui_theme_defaults,
migrate_utility_high_gain_tools, migrate_vigil_config, migrate_worktree_config,
migrate_worktree_git_timeout,
migrate_shell_checkpoints_config, migrate_shell_transactional,
migrate_skill_trust_require_check, migrate_skills_registry, migrate_stt_to_provider,
migrate_supervisor_config, migrate_telemetry_config, migrate_tools_compression_config,
migrate_trace_metadata, migrate_tui_delights, migrate_tui_mouse, migrate_tui_theme_config,
migrate_tui_theme_defaults, migrate_utility_high_gain_tools, migrate_vigil_config,
migrate_worktree_config, migrate_worktree_git_timeout,
};

// ── Wrapper structs for all 73 sequential migration steps ───────────────────────────────────────
Expand Down Expand Up @@ -968,3 +970,16 @@ impl Migration for MigrateDurableSharedDb {
migrate_durable_shared_db(toml_src)
}
}

/// Step 80 — adds a commented `require_integrity_check_on_promote = true` advisory to an
/// existing active `[skills.trust]` table (#6087).
pub(super) struct MigrateSkillTrustRequireCheck;
impl Migration for MigrateSkillTrustRequireCheck {
fn name(&self) -> &'static str {
"migrate_skill_trust_require_check"
}

fn apply(&self, toml_src: &str) -> Result<MigrationResult, MigrateError> {
migrate_skill_trust_require_check(toml_src)
}
}
87 changes: 84 additions & 3 deletions crates/zeph-config/src/migrate/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ use super::*;
fn migrations_registry_has_all_steps() {
assert_eq!(
MIGRATIONS.len(),
79,
"MIGRATIONS registry must contain all 79 sequential steps"
80,
"MIGRATIONS registry must contain all 80 sequential steps"
);
for m in MIGRATIONS.iter() {
assert!(
Expand Down Expand Up @@ -1753,7 +1753,7 @@ fn migrate_focus_auto_consolidate_noop_when_only_commented_section() {

#[test]
fn registry_has_fifty_entries() {
assert_eq!(MIGRATIONS.len(), 79);
assert_eq!(MIGRATIONS.len(), 80);
}

#[test]
Expand Down Expand Up @@ -1872,6 +1872,7 @@ fn registry_preserves_order_matches_dispatch() {
"migrate_acp_auth_clients_config",
"migrate_skills_registry",
"migrate_durable_shared_db",
"migrate_skill_trust_require_check",
];
let actual: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
assert_eq!(actual, expected);
Expand Down Expand Up @@ -4000,3 +4001,83 @@ fn step_79_idempotent_on_own_output() {
"output unchanged on second run"
);
}

// ── migrate_skill_trust_require_check tests (step 80, #6087) ─────────────────

#[test]
fn step_80_adds_commented_advisory_when_skills_trust_active_and_missing_field() {
let src = "[skills.trust]\ndefault_level = \"quarantined\"\nlocal_level = \"trusted\"\n";
let result = migrate_skill_trust_require_check(src).expect("migrate");
assert_eq!(result.changed_count, 1);
assert!(
result
.output
.contains("# require_integrity_check_on_promote = true")
);
assert!(
!result
.output
.contains("\nrequire_integrity_check_on_promote =")
);
assert!(result.output.contains("default_level = \"quarantined\""));
assert!(result.output.contains("local_level = \"trusted\""));
assert_eq!(
result.sections_changed,
vec!["skills.trust.require_integrity_check_on_promote".to_owned()]
);
}

#[test]
fn step_80_noop_when_require_integrity_check_on_promote_already_present() {
let src = "[skills.trust]\nrequire_integrity_check_on_promote = false\n";
let result = migrate_skill_trust_require_check(src).expect("migrate");
assert_eq!(result.changed_count, 0);
assert_eq!(result.output, src);
}

#[test]
fn step_80_noop_when_require_integrity_check_on_promote_comment_already_present() {
let src = "[skills.trust]\n# require_integrity_check_on_promote = true\n";
let result = migrate_skill_trust_require_check(src).expect("migrate");
assert_eq!(result.changed_count, 0);
assert_eq!(result.output, src);
}

#[test]
fn step_80_noop_when_skills_trust_section_absent() {
let src = "[skills]\ndefault_level = \"quarantined\"\n";
let result = migrate_skill_trust_require_check(src).expect("migrate");
assert_eq!(result.changed_count, 0);
assert_eq!(result.output, src);
}

#[test]
fn step_80_noop_when_skills_trust_only_commented_section() {
let src = "# [skills.trust]\n# scan_on_load = true\n";
let result = migrate_skill_trust_require_check(src).expect("migrate");
assert_eq!(result.changed_count, 0);
assert_eq!(result.output, src);
}

#[test]
fn step_80_does_not_match_skills_trust_scanner_subtable() {
let src = "[skills.trust.scanner]\ncapability_escalation_check = false\n";
let result = migrate_skill_trust_require_check(src).expect("migrate");
assert_eq!(
result.changed_count, 0,
"[skills.trust.scanner] alone must not count as an active [skills.trust] table"
);
}

#[test]
fn step_80_idempotent_on_own_output() {
let src = "[skills.trust]\ndefault_level = \"quarantined\"\n";
let first = migrate_skill_trust_require_check(src).expect("first migrate");
assert_eq!(first.changed_count, 1);
let second = migrate_skill_trust_require_check(&first.output).expect("second migrate");
assert_eq!(second.changed_count, 0, "second run must be a no-op");
assert_eq!(
second.output, first.output,
"output unchanged on second run"
);
}
Loading
Loading