Skip to content

Commit 54d175b

Browse files
committed
feat(skills): add secure remote install and upstream watch
1 parent 6706d52 commit 54d175b

24 files changed

Lines changed: 1308 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ jobs:
2323
run: |
2424
python3 -m unittest \
2525
scripts.tests.test_ci_workflows \
26+
scripts.tests.test_check_openharness_upstream \
2627
scripts.tests.test_verify_version_changelog \
2728
scripts.tests.test_verify_release_consistency \
2829
scripts.tests.test_resolve_release_tag \
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: Open-Harness Watch
2+
3+
on:
4+
schedule:
5+
- cron: "0 3 * * 1"
6+
workflow_dispatch:
7+
8+
jobs:
9+
upstream-watch:
10+
name: upstream watch (open-harness)
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
16+
- name: Set up Python
17+
uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.x"
20+
21+
- name: Check upstream head delta
22+
id: watch
23+
continue-on-error: true
24+
run: |
25+
python3 scripts/check_openharness_upstream.py \
26+
--state-file docs/internal/competitive/open-harness-upstream.json \
27+
--out-json .tmp/open-harness-watch/report.json \
28+
--out-markdown .tmp/open-harness-watch/report.md \
29+
--fail-on-change
30+
31+
- name: Upload upstream watch report
32+
if: always()
33+
uses: actions/upload-artifact@v4
34+
with:
35+
name: open-harness-watch
36+
path: .tmp/open-harness-watch/
37+
38+
- name: Fail when upstream moved
39+
if: steps.watch.outcome == 'failure'
40+
run: |
41+
echo "open-harness upstream HEAD changed. Review artifact and refresh internal baseline."
42+
exit 1

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ All notable user-visible changes are documented in this file.
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- `loopforge skills install <url>` for remote skill archive install into workspace `.loopforge/skills`.
10+
11+
### Changed
12+
13+
- Skills archive extraction is now hardened with canonical-root write boundaries and archive traversal defenses:
14+
- reject parent traversal (`../`) and absolute paths
15+
- reject symlink/hardlink entries during extraction
16+
- regression tests cover zip-slip and tar-slip style payloads
17+
718
## [1.3.0] - 2026-03-07
819

920
### Added

Cargo.lock

Lines changed: 72 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ axum = "0.8"
2525
base64 = "0.22"
2626
clap = { version = "4", features = ["derive"] }
2727
dirs = "5"
28+
flate2 = "1"
2829
hmac = "0.12"
2930
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
3031
rusqlite = { version = "0.31", features = ["bundled"] }
@@ -33,9 +34,11 @@ serde_json = "1"
3334
serial_test = "3"
3435
semver = { version = "1", features = ["serde"] }
3536
sha2 = "0.10"
37+
tar = "0.4"
3638
tempfile = "3"
3739
thiserror = "2"
3840
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "process", "time"] }
3941
toml = "0.8"
4042
tower = { version = "0.5", features = ["util"] }
4143
uuid = { version = "1", features = ["v4"] }
44+
zip = { version = "0.6", default-features = false, features = ["deflate"] }

crates/loopforge-cli/src/cli.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ mod tests;
55

66
pub(crate) use commands::{
77
AcpCommand, AgentCommand, AgentKind, ChannelCommand, Cli, Command, ConfigCommand, CronCommand,
8-
DaemonCommand, HarnessCommand, McpCommand, ReleaseCommand, SessionCommand, SkillsCommand,
8+
DaemonCommand, HarnessCommand, McpCommand, ReleaseCommand, SessionCommand, SkillsArchiveFormat,
9+
SkillsCommand,
910
};

crates/loopforge-cli/src/cli/commands.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ pub(crate) use harness::HarnessCommand;
2525
pub(crate) use mcp::McpCommand;
2626
pub(crate) use release::ReleaseCommand;
2727
pub(crate) use session::SessionCommand;
28-
pub(crate) use skills::SkillsCommand;
28+
pub(crate) use skills::{SkillsArchiveFormat, SkillsCommand};
2929

3030
#[derive(Debug, Parser)]
3131
#[command(name = "loopforge")]

crates/loopforge-cli/src/cli/commands/skills.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ use std::path::PathBuf;
22

33
use super::agent::AgentKind;
44

5+
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
6+
pub(crate) enum SkillsArchiveFormat {
7+
Auto,
8+
Zip,
9+
Tar,
10+
TarGz,
11+
}
12+
513
#[derive(Debug, clap::Subcommand)]
614
pub(crate) enum SkillsCommand {
715
/// List discovered skills (workspace + ~/.codex/skills)
@@ -36,6 +44,23 @@ pub(crate) enum SkillsCommand {
3644
#[arg(long)]
3745
strict: bool,
3846
},
47+
/// Install one skill archive from URL into workspace .loopforge/skills
48+
Install {
49+
/// Skill archive URL (zip/tar/tar.gz)
50+
url: String,
51+
/// Workspace root directory
52+
#[arg(long, default_value = ".")]
53+
workspace: PathBuf,
54+
/// Archive format (auto detects by bytes when omitted)
55+
#[arg(long, value_enum, default_value_t = SkillsArchiveFormat::Auto)]
56+
format: SkillsArchiveFormat,
57+
/// Replace an existing skill with the same manifest name
58+
#[arg(long)]
59+
force: bool,
60+
/// Print JSON output (machine-readable)
61+
#[arg(long)]
62+
json: bool,
63+
},
3964
/// Execute one skill with real runtime tools and model routing
4065
Run {
4166
/// Skill name

crates/loopforge-cli/src/cli/tests.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,26 @@ fn cli_parses_skills_run_subcommand() {
117117
);
118118
}
119119

120+
#[test]
121+
fn cli_parses_skills_install_subcommand() {
122+
let parsed = Cli::try_parse_from([
123+
"loopforge",
124+
"skills",
125+
"install",
126+
"https://example.com/hello-skill.zip",
127+
"--workspace",
128+
".",
129+
"--format",
130+
"zip",
131+
"--force",
132+
"--json",
133+
]);
134+
assert!(
135+
parsed.is_ok(),
136+
"expected `loopforge skills install` to parse, got: {parsed:?}"
137+
);
138+
}
139+
120140
#[test]
121141
fn cli_parses_onboard_subcommand() {
122142
let parsed = Cli::try_parse_from([

crates/loopforge-cli/src/dispatch/skills.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
mod doctor;
2+
mod install;
23
mod listing;
34
mod run;
45

@@ -17,6 +18,13 @@ pub(super) async fn run(command: SkillsCommand) -> anyhow::Result<()> {
1718
json,
1819
strict,
1920
} => doctor::run_doctor(workspace, json, strict),
21+
SkillsCommand::Install {
22+
url,
23+
workspace,
24+
format,
25+
force,
26+
json,
27+
} => install::run_install(url, workspace, format, force, json).await,
2028
SkillsCommand::Run {
2129
name,
2230
workspace,

0 commit comments

Comments
 (0)