@lowwattlabs/clawsec 2.2.0 → 2.3.0
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.
- package/cli/clawsec.py +60 -10
- package/package.json +1 -1
package/cli/clawsec.py
CHANGED
|
@@ -15,13 +15,14 @@ import argparse
|
|
|
15
15
|
import json
|
|
16
16
|
import os
|
|
17
17
|
import shutil
|
|
18
|
+
import stat
|
|
18
19
|
import subprocess
|
|
19
20
|
import sys
|
|
20
21
|
import tempfile
|
|
21
22
|
import time
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
|
|
24
|
-
VERSION = "2.
|
|
25
|
+
VERSION = "2.3.0"
|
|
25
26
|
CLAWSEC_DIR = os.environ.get("CLAWSEC_HOME", os.path.expanduser("~/.clawsec"))
|
|
26
27
|
INTEL_DIR = os.environ.get("CLAWSEC_INTEL_DIR", os.path.join(CLAWSEC_DIR, "intel"))
|
|
27
28
|
REPORTS_DIR = os.environ.get("CLAWSEC_REPORTS_DIR", os.path.join(CLAWSEC_DIR, "reports"))
|
|
@@ -40,6 +41,7 @@ BOLD = "\033[1m"
|
|
|
40
41
|
DIM = "\033[2m"
|
|
41
42
|
RESET = "\033[0m"
|
|
42
43
|
|
|
44
|
+
|
|
43
45
|
def banner():
|
|
44
46
|
print(f"""{BOLD}
|
|
45
47
|
╔═════════════════════════════════════════╗
|
|
@@ -48,6 +50,7 @@ def banner():
|
|
|
48
50
|
╚═════════════════════════════════════════╝{RESET}
|
|
49
51
|
""")
|
|
50
52
|
|
|
53
|
+
|
|
51
54
|
def is_slug(target):
|
|
52
55
|
"""Check if target looks like a ClawHub slug (no path separators, no dots, not a local path)."""
|
|
53
56
|
if os.path.exists(target):
|
|
@@ -59,13 +62,45 @@ def is_slug(target):
|
|
|
59
62
|
return False
|
|
60
63
|
return True
|
|
61
64
|
|
|
65
|
+
|
|
66
|
+
def harden_skill_dir(skill_dir):
|
|
67
|
+
"""
|
|
68
|
+
Remove execute permissions from all files in the skill directory.
|
|
69
|
+
This prevents any scripts from being executed accidentally before or during scanning.
|
|
70
|
+
We only need to READ these files, never execute them.
|
|
71
|
+
"""
|
|
72
|
+
for root, dirs, files in os.walk(skill_dir):
|
|
73
|
+
for f in files:
|
|
74
|
+
fpath = os.path.join(root, f)
|
|
75
|
+
try:
|
|
76
|
+
current_mode = os.stat(fpath).st_mode
|
|
77
|
+
# Remove all execute bits (user, group, other)
|
|
78
|
+
os.chmod(fpath, current_mode & ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH))
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
62
83
|
def download_slug(slug):
|
|
63
|
-
"""
|
|
84
|
+
"""
|
|
85
|
+
Download a skill from ClawHub by slug.
|
|
86
|
+
|
|
87
|
+
Security measures:
|
|
88
|
+
- Downloads to a restricted temp directory (0700 permissions)
|
|
89
|
+
- Strips execute permissions from all downloaded files before returning
|
|
90
|
+
- Uses --no-input flag to prevent any interactive prompts
|
|
91
|
+
- Does NOT run any postinstall scripts from the downloaded skill
|
|
92
|
+
|
|
93
|
+
Returns (skill_path, cleanup_dir) or None on failure.
|
|
94
|
+
"""
|
|
95
|
+
# Create temp dir with restricted permissions (owner only)
|
|
64
96
|
tmpdir = tempfile.mkdtemp(prefix="clawsec-scan-")
|
|
97
|
+
os.chmod(tmpdir, stat.S_IRWXU) # 0700 — owner read/write/exec only
|
|
98
|
+
|
|
65
99
|
try:
|
|
66
100
|
result = subprocess.run(
|
|
67
|
-
["clawhub", "install", slug, "--dir", tmpdir],
|
|
68
|
-
capture_output=True, text=True, timeout=120
|
|
101
|
+
["clawhub", "install", slug, "--dir", tmpdir, "--no-input"],
|
|
102
|
+
capture_output=True, text=True, timeout=120,
|
|
103
|
+
env={**os.environ, "npm_config_ignore_scripts": "true"} # Never run skill's postinstall
|
|
69
104
|
)
|
|
70
105
|
if result.returncode != 0:
|
|
71
106
|
print(f"{R}Error:{RESET} Failed to install '{slug}' from ClawHub", file=sys.stderr)
|
|
@@ -73,17 +108,26 @@ def download_slug(slug):
|
|
|
73
108
|
print(f" {DIM}{result.stderr.strip()}{RESET}", file=sys.stderr)
|
|
74
109
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
75
110
|
return None
|
|
111
|
+
|
|
76
112
|
# Find the installed skill directory
|
|
77
113
|
entries = list(Path(tmpdir).iterdir())
|
|
78
114
|
if not entries:
|
|
79
115
|
print(f"{R}Error:{RESET} ClawHub install produced no output for '{slug}'", file=sys.stderr)
|
|
80
116
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
81
117
|
return None
|
|
82
|
-
|
|
118
|
+
|
|
119
|
+
# Determine skill path
|
|
83
120
|
if len(entries) == 1 and entries[0].is_dir():
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
121
|
+
skill_path = str(entries[0])
|
|
122
|
+
else:
|
|
123
|
+
skill_path = tmpdir
|
|
124
|
+
|
|
125
|
+
# SECURITY: Strip execute permissions from ALL downloaded files.
|
|
126
|
+
# We only need to read them for scanning — never execute them.
|
|
127
|
+
harden_skill_dir(skill_path)
|
|
128
|
+
|
|
129
|
+
return skill_path, tmpdir
|
|
130
|
+
|
|
87
131
|
except FileNotFoundError:
|
|
88
132
|
print(f"{R}Error:{RESET} 'clawhub' CLI not found. Install it with: npm install -g @anthropic/clawhub", file=sys.stderr)
|
|
89
133
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
@@ -93,6 +137,7 @@ def download_slug(slug):
|
|
|
93
137
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
94
138
|
return None
|
|
95
139
|
|
|
140
|
+
|
|
96
141
|
def cmd_scan(args):
|
|
97
142
|
"""Run verification against a skill path or ClawHub slug."""
|
|
98
143
|
target = args.target
|
|
@@ -102,13 +147,13 @@ def cmd_scan(args):
|
|
|
102
147
|
# If it looks like a slug, try to download from ClawHub
|
|
103
148
|
if is_slug(target):
|
|
104
149
|
if not json_mode:
|
|
105
|
-
print(f" {C}⚡ Downloading '{target}' from ClawHub...{RESET}")
|
|
150
|
+
print(f" {C}⚡ Downloading '{target}' from ClawHub...{RESET}", flush=True)
|
|
106
151
|
result = download_slug(target)
|
|
107
152
|
if result is None:
|
|
108
153
|
sys.exit(2)
|
|
109
154
|
target, cleanup_dir = result
|
|
110
155
|
if not json_mode:
|
|
111
|
-
print(f" {G}✓{RESET} Downloaded to {target}")
|
|
156
|
+
print(f" {G}✓{RESET} Downloaded to {target}", flush=True)
|
|
112
157
|
|
|
113
158
|
if not os.path.exists(target):
|
|
114
159
|
print(f"{R}Error:{RESET} '{args.target}' not found (not a local path and not a valid ClawHub slug)", file=sys.stderr)
|
|
@@ -137,6 +182,7 @@ def cmd_scan(args):
|
|
|
137
182
|
|
|
138
183
|
sys.exit(result.returncode)
|
|
139
184
|
|
|
185
|
+
|
|
140
186
|
def cmd_sync(args):
|
|
141
187
|
"""Run intel sync."""
|
|
142
188
|
# Ensure intel directories exist
|
|
@@ -156,6 +202,7 @@ def cmd_sync(args):
|
|
|
156
202
|
result = subprocess.run(cmd, text=True)
|
|
157
203
|
sys.exit(result.returncode)
|
|
158
204
|
|
|
205
|
+
|
|
159
206
|
def cmd_status(args):
|
|
160
207
|
"""Show cache status."""
|
|
161
208
|
manifest_py = os.path.join(PKG_ROOT, "lib", "intel-sync", "manifest.py")
|
|
@@ -202,6 +249,7 @@ def cmd_status(args):
|
|
|
202
249
|
pass
|
|
203
250
|
print(f" Last full sync: {updated}")
|
|
204
251
|
|
|
252
|
+
|
|
205
253
|
def cmd_report(args):
|
|
206
254
|
"""View a saved report."""
|
|
207
255
|
report_id = args.report_id
|
|
@@ -257,6 +305,7 @@ def cmd_report(args):
|
|
|
257
305
|
|
|
258
306
|
print()
|
|
259
307
|
|
|
308
|
+
|
|
260
309
|
def main():
|
|
261
310
|
parser = argparse.ArgumentParser(
|
|
262
311
|
prog="clawsec",
|
|
@@ -314,5 +363,6 @@ def main():
|
|
|
314
363
|
parser.print_help()
|
|
315
364
|
sys.exit(1)
|
|
316
365
|
|
|
366
|
+
|
|
317
367
|
if __name__ == "__main__":
|
|
318
368
|
main()
|