@asobacloud/nehanda 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/LICENSE.md +57 -0
- package/README.md +404 -2
- package/bin/agent.mjs +1260 -0
- package/bin/nehanda-ui.mjs +824 -0
- package/lib/anthropicHeaders.mjs +8 -0
- package/lib/app.mjs +313 -0
- package/lib/auth.mjs +255 -0
- package/lib/bashguard.mjs +159 -0
- package/lib/compact.mjs +306 -0
- package/lib/energy-middleware.mjs +318 -0
- package/lib/hookShapeOrder.mjs +30 -0
- package/lib/hookplane.mjs +228 -0
- package/lib/instructions.mjs +23 -0
- package/lib/jev_read.mjs +216 -0
- package/lib/jev_write.mjs +253 -0
- package/lib/laya.mjs +296 -0
- package/lib/laya_sidecar.mjs +198 -0
- package/lib/mcp-config.mjs +189 -0
- package/lib/mcp.mjs +169 -0
- package/lib/modelConfig.mjs +152 -0
- package/lib/modelDiscovery.mjs +205 -0
- package/lib/openaiCompat.mjs +147 -0
- package/lib/orchestrate.mjs +1163 -0
- package/lib/paths.mjs +20 -0
- package/lib/permissions.mjs +44 -0
- package/lib/scripts/__pycache__/laya_worker.cpython-314.pyc +0 -0
- package/lib/scripts/audit-code-integrity.py +437 -0
- package/lib/scripts/ensure-laya-env.sh +55 -0
- package/lib/scripts/js-safety-checker.cjs +308 -0
- package/lib/scripts/laya_worker.py +171 -0
- package/lib/scripts/odse-transform.py +97 -0
- package/lib/scripts/python-safety-checker.py +197 -0
- package/lib/scripts/shell-safety-checker.sh +306 -0
- package/lib/settings.mjs +147 -0
- package/lib/store.mjs +278 -0
- package/lib/team.mjs +69 -0
- package/lib/testgen.mjs +146 -0
- package/lib/tools/AuditCodeIntegrity.json +30 -0
- package/lib/tools/JsSafetyChecker.json +29 -0
- package/lib/tools/PythonSafetyChecker.json +29 -0
- package/lib/tools/ShellSafetyChecker.json +29 -0
- package/lib/tools.mjs +907 -0
- package/lib/transcript.mjs +223 -0
- package/lib/ui.mjs +286 -0
- package/lib/workflow.mjs +91 -0
- package/lib/worktree.mjs +56 -0
- package/package.json +50 -4
- package/schema.sql +181 -0
- package/scripts/postinstall.mjs +62 -0
- package/scripts/sdlc-acceptance.sh +477 -0
- package/scripts/verify-sdlc-hook-order.mjs +26 -0
- package/templates/test_e2e.sh +32 -0
- package/templates/test_file.sh +14 -0
- package/templates/test_hook.sh +27 -0
- package/templates/test_http.sh +17 -0
- package/templates/test_phase.sh +23 -0
- package/templates/test_process.sh +14 -0
- package/templates/test_tool.sh +24 -0
- package/templates/validate_test.sh +55 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
class PythonSafetyChecker:
|
|
9
|
+
def __init__(self, root_path):
|
|
10
|
+
self.root_path = root_path
|
|
11
|
+
self.issues = []
|
|
12
|
+
|
|
13
|
+
def check(self):
|
|
14
|
+
self._check_dependencies()
|
|
15
|
+
self._run_bandit()
|
|
16
|
+
self._run_ruff()
|
|
17
|
+
self._run_ast_checks()
|
|
18
|
+
self._print_summary()
|
|
19
|
+
|
|
20
|
+
def _check_dependencies(self):
|
|
21
|
+
try:
|
|
22
|
+
import importlib.util
|
|
23
|
+
# Check if dependencies are available (imported but unused in code)
|
|
24
|
+
importlib.util.find_spec('bandit')
|
|
25
|
+
importlib.util.find_spec('ruff')
|
|
26
|
+
except ImportError:
|
|
27
|
+
print("ERROR: Missing dependencies. Please run 'pip install -r requirements-dev.txt' to install the required tools.")
|
|
28
|
+
sys.exit(1)
|
|
29
|
+
|
|
30
|
+
def _run_bandit(self):
|
|
31
|
+
print("Running bandit...")
|
|
32
|
+
try:
|
|
33
|
+
# Exclude safety checker itself, test files, and build artifacts from Bandit checks
|
|
34
|
+
result = subprocess.run(
|
|
35
|
+
['bandit', '-r', self.root_path,
|
|
36
|
+
'--exclude', 'scripts/python-safety-checker.py,tests/,*/test_*.py,.aws-sam/,build/,dist/,node_modules/,.venv/,venv/,./.venv-ci/,./.venv-configure-tests/,./_pt/,./.tvenv/,./.agent-venv/,.venv-ci/,.venv-configure-tests/,_pt/,.tvenv/,.agent-venv/'],
|
|
37
|
+
capture_output=True, text=True
|
|
38
|
+
)
|
|
39
|
+
# Only report High/Critical severity issues as errors (warnings are non-blocking)
|
|
40
|
+
if result.returncode == 1 and result.stdout:
|
|
41
|
+
# Parse output to find only High/Critical severity issues
|
|
42
|
+
lines = result.stdout.split('\n')
|
|
43
|
+
high_critical_issues = []
|
|
44
|
+
current_issue_lines = []
|
|
45
|
+
in_high_critical_issue = False
|
|
46
|
+
|
|
47
|
+
for i, line in enumerate(lines):
|
|
48
|
+
if '>> Issue:' in line:
|
|
49
|
+
# Start of new issue
|
|
50
|
+
if in_high_critical_issue and current_issue_lines:
|
|
51
|
+
high_critical_issues.extend(current_issue_lines)
|
|
52
|
+
current_issue_lines = [line]
|
|
53
|
+
in_high_critical_issue = False
|
|
54
|
+
elif 'Severity: High' in line or 'Severity: Critical' in line:
|
|
55
|
+
in_high_critical_issue = True
|
|
56
|
+
if current_issue_lines:
|
|
57
|
+
current_issue_lines.append(line)
|
|
58
|
+
elif in_high_critical_issue and current_issue_lines:
|
|
59
|
+
current_issue_lines.append(line)
|
|
60
|
+
# Stop collecting when we hit separator or next issue
|
|
61
|
+
if line.startswith('--') or line.strip() == '':
|
|
62
|
+
high_critical_issues.extend(current_issue_lines)
|
|
63
|
+
current_issue_lines = []
|
|
64
|
+
in_high_critical_issue = False
|
|
65
|
+
|
|
66
|
+
# Add last issue if applicable
|
|
67
|
+
if in_high_critical_issue and current_issue_lines:
|
|
68
|
+
high_critical_issues.extend(current_issue_lines)
|
|
69
|
+
|
|
70
|
+
if high_critical_issues:
|
|
71
|
+
filtered_output = '\n'.join(high_critical_issues)
|
|
72
|
+
self.issues.append(("Bandit", filtered_output))
|
|
73
|
+
except FileNotFoundError:
|
|
74
|
+
self.issues.append(("Bandit", "ERROR: bandit is not installed or not in PATH."))
|
|
75
|
+
|
|
76
|
+
def _run_ruff(self):
|
|
77
|
+
print("Running ruff...")
|
|
78
|
+
try:
|
|
79
|
+
# Exclude Jupyter notebooks from Ruff checks (they have different structure)
|
|
80
|
+
result = subprocess.run(
|
|
81
|
+
['ruff', 'check', self.root_path, '--exclude', '*.ipynb,node_modules,.venv-ci,.tvenv,.venv-configure-tests,.agent-venv,_pt'],
|
|
82
|
+
capture_output=True, text=True
|
|
83
|
+
)
|
|
84
|
+
# Only add Ruff issues if exit code is non-zero (actual errors found)
|
|
85
|
+
if result.returncode != 0 and result.stdout:
|
|
86
|
+
self.issues.append(("Ruff", result.stdout))
|
|
87
|
+
elif result.stdout and 'All checks passed!' not in result.stdout:
|
|
88
|
+
# Also handle cases where ruff outputs warnings but exit code is 0
|
|
89
|
+
self.issues.append(("Ruff", result.stdout))
|
|
90
|
+
except FileNotFoundError:
|
|
91
|
+
self.issues.append(("Ruff", "ERROR: ruff is not installed or not in PATH."))
|
|
92
|
+
|
|
93
|
+
def _run_ast_checks(self):
|
|
94
|
+
print("Running AST checks...")
|
|
95
|
+
|
|
96
|
+
# Support scanning a single file directly
|
|
97
|
+
if os.path.isfile(self.root_path):
|
|
98
|
+
if self.root_path.endswith('.py'):
|
|
99
|
+
self._check_file_path(self.root_path)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
for root, _, files in os.walk(self.root_path):
|
|
103
|
+
for file in files:
|
|
104
|
+
if file.endswith('.py'):
|
|
105
|
+
path = os.path.join(root, file)
|
|
106
|
+
# Skip safety checker itself, test files, and build artifacts
|
|
107
|
+
if ('python-safety-checker.py' in path or
|
|
108
|
+
'/test_' in path or
|
|
109
|
+
'/tests/' in path or
|
|
110
|
+
'/.aws-sam/' in path or
|
|
111
|
+
'/build/' in path or
|
|
112
|
+
'/dist/' in path or
|
|
113
|
+
'/node_modules/' in path or
|
|
114
|
+
'/.venv/' in path or
|
|
115
|
+
'/.venv-ci/' in path or
|
|
116
|
+
'/.tvenv/' in path or
|
|
117
|
+
'/.venv-configure-tests/' in path or
|
|
118
|
+
'/.agent-venv/' in path or
|
|
119
|
+
'/_pt/' in path or
|
|
120
|
+
'/venv/' in path):
|
|
121
|
+
continue
|
|
122
|
+
self._check_file_path(path)
|
|
123
|
+
|
|
124
|
+
def _check_file_path(self, path):
|
|
125
|
+
with open(path, 'r') as f:
|
|
126
|
+
try:
|
|
127
|
+
tree = ast.parse(f.read(), filename=path)
|
|
128
|
+
self._check_file(tree, path)
|
|
129
|
+
except SyntaxError as e:
|
|
130
|
+
self.issues.append(("AST", f"ERROR: Could not parse {path}: {e}"))
|
|
131
|
+
|
|
132
|
+
def _check_file(self, tree, path):
|
|
133
|
+
for node in ast.walk(tree):
|
|
134
|
+
if isinstance(node, ast.Assign):
|
|
135
|
+
for target in node.targets:
|
|
136
|
+
if isinstance(target, ast.Name) and target.id.upper() in ['PASSWORD', 'SECRET', 'API_KEY']:
|
|
137
|
+
# Check if value is from environment variable (os.getenv, os.environ, etc.)
|
|
138
|
+
value_is_from_env = False
|
|
139
|
+
if isinstance(node.value, ast.Call):
|
|
140
|
+
# Check for os.getenv(), os.environ.get(), etc.
|
|
141
|
+
if isinstance(node.value.func, ast.Attribute):
|
|
142
|
+
if node.value.func.attr in ['getenv', 'get'] and isinstance(node.value.func.value, ast.Name) and node.value.func.value.id == 'os':
|
|
143
|
+
value_is_from_env = True
|
|
144
|
+
# Check for os.environ['KEY'] or os.environ.get('KEY')
|
|
145
|
+
if isinstance(node.value.func, ast.Subscript) and isinstance(node.value.func.value, ast.Attribute):
|
|
146
|
+
if node.value.func.value.attr == 'environ' and isinstance(node.value.func.value.value, ast.Name) and node.value.func.value.value.id == 'os':
|
|
147
|
+
value_is_from_env = True
|
|
148
|
+
elif isinstance(node.value, ast.Attribute):
|
|
149
|
+
# Check for os.environ['KEY'] assignments
|
|
150
|
+
if isinstance(node.value.value, ast.Attribute) and node.value.value.attr == 'environ':
|
|
151
|
+
value_is_from_env = True
|
|
152
|
+
|
|
153
|
+
# Only flag hardcoded string literals, not environment variable lookups
|
|
154
|
+
# Handle both Python 3.7 (ast.Str) and Python 3.8+ (ast.Constant)
|
|
155
|
+
is_string_literal = False
|
|
156
|
+
if hasattr(ast, 'Constant'):
|
|
157
|
+
# Python 3.8+
|
|
158
|
+
if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
|
|
159
|
+
is_string_literal = True
|
|
160
|
+
else:
|
|
161
|
+
# Python 3.7 and earlier
|
|
162
|
+
if isinstance(node.value, getattr(ast, 'Str', type(None))):
|
|
163
|
+
is_string_literal = True
|
|
164
|
+
|
|
165
|
+
if is_string_literal and not value_is_from_env:
|
|
166
|
+
self.issues.append(("AST", f"Hardcoded credential found in {path} on line {node.lineno}"))
|
|
167
|
+
elif isinstance(node, ast.Call):
|
|
168
|
+
if isinstance(node.func, ast.Name) and node.func.id in ['eval', 'exec']:
|
|
169
|
+
self.issues.append(("AST", f"Insecure function '{node.func.id}' used in {path} on line {node.lineno}"))
|
|
170
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr in ['load', 'loads'] and isinstance(node.func.value, ast.Name) and node.func.value.id == 'pickle':
|
|
171
|
+
self.issues.append(("AST", f"Insecure function 'pickle.{node.func.attr}' used in {path} on line {node.lineno}"))
|
|
172
|
+
elif isinstance(node, ast.FunctionDef):
|
|
173
|
+
# Missing docstrings are warnings (non-blocking), not errors
|
|
174
|
+
# Only flag mutable default arguments as errors
|
|
175
|
+
for arg in node.args.defaults:
|
|
176
|
+
if isinstance(arg, (ast.List, ast.Dict)):
|
|
177
|
+
self.issues.append(("AST", f"Mutable default argument used in function '{node.name}' in {path} on line {node.lineno}"))
|
|
178
|
+
elif isinstance(node, ast.ClassDef):
|
|
179
|
+
# Missing docstrings are warnings (non-blocking), not errors
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
def _print_summary(self):
|
|
183
|
+
print("\nPython Safety Checker Summary")
|
|
184
|
+
print("=============================")
|
|
185
|
+
if not self.issues:
|
|
186
|
+
print("✓ All checks passed!")
|
|
187
|
+
else:
|
|
188
|
+
for tool, issue in self.issues:
|
|
189
|
+
print(f"\n--- {tool} Issues ---")
|
|
190
|
+
print(issue)
|
|
191
|
+
print(f"\n✗ Found {len(self.issues)} issues.")
|
|
192
|
+
sys.exit(1)
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
target = sys.argv[1] if len(sys.argv) > 1 else '.'
|
|
196
|
+
checker = PythonSafetyChecker(target)
|
|
197
|
+
checker.check()
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# shell-safety-checker.sh - Safety checker for shell scripts
|
|
3
|
+
# Based on ui/js-safety-checker.js pattern
|
|
4
|
+
# Integrates shellcheck for comprehensive static analysis
|
|
5
|
+
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
# Check if shellcheck is available
|
|
9
|
+
SHELLCHECK_AVAILABLE=0
|
|
10
|
+
if command -v shellcheck >/dev/null 2>&1; then
|
|
11
|
+
SHELLCHECK_AVAILABLE=1
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
# Checks for loops that could be parallelized
|
|
15
|
+
# Returns warnings (non-blocking optimization suggestions) via stdout
|
|
16
|
+
function check_parallelization_opportunities() {
|
|
17
|
+
local filePath="$1"
|
|
18
|
+
local line_num=0
|
|
19
|
+
local in_loop=0
|
|
20
|
+
local loop_start_line=0
|
|
21
|
+
local loop_body=""
|
|
22
|
+
local loop_indent=""
|
|
23
|
+
local loop_type="" # To store 'for', 'while', or 'until'
|
|
24
|
+
|
|
25
|
+
# Read file line by line to handle nested structures
|
|
26
|
+
while IFS= read -r line; do
|
|
27
|
+
((line_num++))
|
|
28
|
+
|
|
29
|
+
# Detect the start of a loop (for, while, until)
|
|
30
|
+
if [[ $in_loop -eq 0 && $line =~ ^([[:space:]]*)(for|while|until)[[:space:]]+.*do$ ]]; then
|
|
31
|
+
in_loop=1
|
|
32
|
+
loop_start_line=$line_num
|
|
33
|
+
loop_indent="${BASH_REMATCH[1]}"
|
|
34
|
+
loop_type="${BASH_REMATCH[2]}"
|
|
35
|
+
loop_body=""
|
|
36
|
+
continue
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# Detect the end of the loop
|
|
40
|
+
if [[ $in_loop -eq 1 && $line =~ ^${loop_indent}done$ ]]; then
|
|
41
|
+
# Heuristic: If the loop body is not already backgrounded, it's a candidate.
|
|
42
|
+
# Check for '&' at the end of a command or 'wait' for PIDs
|
|
43
|
+
# Note: Parallelization opportunities are optimization suggestions, not errors
|
|
44
|
+
if ! echo "$loop_body" | grep -qE '&[[:space:]]*$|wait[[:space:]]+\$!|pids[[:space:]]*\+='; then
|
|
45
|
+
echo "Potential parallelization opportunity: A '${loop_type}' loop was found at lines $loop_start_line-$line_num. If iterations are independent, consider parallelizing tasks (e.g., using '&' with 'wait', 'xargs -P', or 'parallel')."
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# Reset state for the next loop
|
|
49
|
+
in_loop=0
|
|
50
|
+
loop_body=""
|
|
51
|
+
loop_start_line=0
|
|
52
|
+
loop_indent=""
|
|
53
|
+
loop_type=""
|
|
54
|
+
continue
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
# If inside a loop, append the current line to the loop_body
|
|
58
|
+
if [[ $in_loop -eq 1 ]]; then
|
|
59
|
+
loop_body+="$line\n"
|
|
60
|
+
fi
|
|
61
|
+
done < "$filePath"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Add a new check for consecutive similar operations
|
|
65
|
+
# Returns warnings (non-blocking optimization suggestions) via stdout
|
|
66
|
+
function check_consecutive_similar_operations() {
|
|
67
|
+
local filePath="$1"
|
|
68
|
+
# Use awk to find consecutive lines with similar operations, ignoring common non-parallelizable keywords.
|
|
69
|
+
awk -v blocklist_str="export local readonly unset echo log_info log_warn log_error if for while case select function time mkdir cp git source" '
|
|
70
|
+
BEGIN {
|
|
71
|
+
# Keywords/built-ins that are typically not candidates for parallelization
|
|
72
|
+
split(blocklist_str, blocklist_arr, " ");
|
|
73
|
+
for (i in blocklist_arr) {
|
|
74
|
+
is_blocklisted[blocklist_arr[i]] = 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function check_and_report() {
|
|
79
|
+
if (count >= 3 && !is_blocklisted[opName]) { # Check against blocklist
|
|
80
|
+
print "Potential parallelization opportunity: A block of " count " similar sequential operations (\"" opName "\") was found starting at line " startLine ". Consider refactoring into a parallelized loop or using '\''xargs -P'\'' / '\''parallel'\''.";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Match lines that look like operations, ignore empty or commented lines
|
|
85
|
+
/^[[:space:]]*[a-zA-Z0-9_]+[[:space:]]+.*$/ {
|
|
86
|
+
# Use the first word as the operation name
|
|
87
|
+
op = $1;
|
|
88
|
+
# Further refine op to ignore variable assignments or simple declarations
|
|
89
|
+
if (op ~ /=/) { op = ""; } # Ignore assignments
|
|
90
|
+
if (op == "local" || op == "export" || op == "readonly") { op = ""; } # Ignore declarations
|
|
91
|
+
|
|
92
|
+
if (op != "") {
|
|
93
|
+
if (op == opName && ! /&[[:space:]]*$/) {
|
|
94
|
+
count++;
|
|
95
|
+
} else {
|
|
96
|
+
check_and_report();
|
|
97
|
+
opName = op;
|
|
98
|
+
startLine = NR;
|
|
99
|
+
count = 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
END { check_and_report(); }
|
|
104
|
+
' "$filePath"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
function checkShellFile() {
|
|
109
|
+
local filePath="$1"
|
|
110
|
+
local issues=()
|
|
111
|
+
local warnings=()
|
|
112
|
+
|
|
113
|
+
# Check parallelization opportunities (warnings, not errors)
|
|
114
|
+
# Capture warnings from functions that output to stdout
|
|
115
|
+
while IFS= read -r warning; do
|
|
116
|
+
warnings+=("$warning")
|
|
117
|
+
done < <(check_parallelization_opportunities "$filePath")
|
|
118
|
+
|
|
119
|
+
while IFS= read -r warning; do
|
|
120
|
+
warnings+=("$warning")
|
|
121
|
+
done < <(check_consecutive_similar_operations "$filePath")
|
|
122
|
+
|
|
123
|
+
# Check for silent failures (|| true) - CRITICAL
|
|
124
|
+
# Exception: Allow || true for non-blocking optional operations (e.g., CloudWatch logging, existence checks)
|
|
125
|
+
if grep -q "|| true" "$filePath"; then
|
|
126
|
+
# Check if it's part of a non-blocking optional operation pattern
|
|
127
|
+
# For multiline AWS commands, check if file has both AWS list/get/describe AND || true
|
|
128
|
+
local has_aws_list=0
|
|
129
|
+
local has_aws_get=0
|
|
130
|
+
local has_aws_describe=0
|
|
131
|
+
if grep -qE "(aws.*list|aws acm)" "$filePath"; then
|
|
132
|
+
has_aws_list=1
|
|
133
|
+
fi
|
|
134
|
+
if grep -qE "aws.*get" "$filePath"; then
|
|
135
|
+
has_aws_get=1
|
|
136
|
+
fi
|
|
137
|
+
if grep -qE "aws.*describe" "$filePath"; then
|
|
138
|
+
has_aws_describe=1
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
# Check if it matches allowed patterns (same line or multiline for AWS commands)
|
|
142
|
+
# Allow || true for AWS list/get/describe commands (handles multiline patterns)
|
|
143
|
+
local is_allowed=0
|
|
144
|
+
if grep -qE "(2>/dev/null \|\| true|>/dev/null \|\| true|\|\| true.*2>/dev/null|send_to_cloudwatch.*\|\| true|\|\| true.*cloudwatch)" "$filePath"; then
|
|
145
|
+
is_allowed=1
|
|
146
|
+
elif [[ ($has_aws_list -eq 1 || $has_aws_get -eq 1 || $has_aws_describe -eq 1) ]]; then
|
|
147
|
+
is_allowed=1
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
if [[ $is_allowed -eq 0 ]]; then
|
|
151
|
+
issues+=("Silent failure detected: || true")
|
|
152
|
+
fi
|
|
153
|
+
fi
|
|
154
|
+
|
|
155
|
+
# Check for missing set -euo pipefail - CRITICAL
|
|
156
|
+
# Check first 20 lines to allow for comments/headers
|
|
157
|
+
if ! head -20 "$filePath" | grep -q "set -euo pipefail"; then
|
|
158
|
+
issues+=("Missing 'set -euo pipefail'")
|
|
159
|
+
fi
|
|
160
|
+
|
|
161
|
+
# Check for dangerous combination: set -e with background jobs - CRITICAL SILENT FAILURE RISK
|
|
162
|
+
# Behavior-based detection rather than syntax-based
|
|
163
|
+
local has_background_job=0
|
|
164
|
+
local has_pid_capture=0
|
|
165
|
+
local has_wait_for_pid=0
|
|
166
|
+
local has_exit_code_check=0
|
|
167
|
+
|
|
168
|
+
# Check for background jobs (any command ending with &)
|
|
169
|
+
if grep -qE '\&[[:space:]]*$' "$filePath"; then
|
|
170
|
+
has_background_job=1
|
|
171
|
+
fi
|
|
172
|
+
|
|
173
|
+
# Check for PID capture behavior (any mechanism that stores $!)
|
|
174
|
+
# This includes: var=$!, array[key]=$!, var="${var}$!", etc.
|
|
175
|
+
if grep -qE '\$![[:space:]]*\)?[[:space:]]*$|\$![[:space:]]*\)?[[:space:]]*;|\$![[:space:]]*\)?[[:space:]]*&' "$filePath" || \
|
|
176
|
+
grep -qE '[a-zA-Z0-9_]+(\[[^]]+\])?[[:space:]]*=[[:space:]]*.*\$!' "$filePath"; then
|
|
177
|
+
has_pid_capture=1
|
|
178
|
+
fi
|
|
179
|
+
|
|
180
|
+
# Check for wait behavior (any wait command that could wait for specific processes)
|
|
181
|
+
# This includes: wait, wait $var, wait ${var}, wait $!, wait "${var}", etc.
|
|
182
|
+
if grep -qE 'wait[[:space:]]*$|wait[[:space:]]+[^[:space:]]+' "$filePath"; then
|
|
183
|
+
has_wait_for_pid=1
|
|
184
|
+
fi
|
|
185
|
+
|
|
186
|
+
# Check for exit code check behavior (any mechanism that checks $?)
|
|
187
|
+
# This includes: var=$?, if [[ $? -eq 0 ]], if [ $? = 0 ], if ! wait, wait || log, etc.
|
|
188
|
+
if grep -qE '\$\?[[:space:]]*\)?[[:space:]]*$|\$\?[[:space:]]*\)?[[:space:]]*;|[a-zA-Z0-9_]+[[:space:]]*=[[:space:]]*.*\$\?|if[[:space:]]+.*\$\?|\[\[[[:space:]]*.*\$\?|if[[:space:]]+!.*wait|if.*wait.*pids|wait.*pids.*then|wait[[:space:]]+.*\|\||wait[[:space:]]+\$.*\|\|' "$filePath"; then
|
|
189
|
+
has_exit_code_check=1
|
|
190
|
+
fi
|
|
191
|
+
|
|
192
|
+
if [[ $has_background_job -eq 1 ]]; then
|
|
193
|
+
if [[ $has_pid_capture -eq 0 || $has_wait_for_pid -eq 0 || $has_exit_code_check -eq 0 ]]; then
|
|
194
|
+
issues+=("CRITICAL SILENT FAILURE RISK: Background jobs ('&') with 'set -e' can fail silently. Ensure PIDs are captured, waited on, and their exit codes are explicitly checked. (Detected: BG_JOB=$has_background_job, PID_CAP=$has_pid_capture, WAIT=$has_wait_for_pid, EXIT_CHECK=$has_exit_code_check)")
|
|
195
|
+
fi
|
|
196
|
+
fi
|
|
197
|
+
|
|
198
|
+
# Check for proper shebang - CRITICAL
|
|
199
|
+
if ! head -1 "$filePath" | grep -q "^#!/.*bash"; then
|
|
200
|
+
issues+=("Missing or incorrect shebang")
|
|
201
|
+
fi
|
|
202
|
+
|
|
203
|
+
# Check for hardcoded credentials - SECURITY (more specific patterns)
|
|
204
|
+
# Exception: Variable assignments from environment variables are OK
|
|
205
|
+
if grep -qiE "(password|secret|api_key|api-key)\s*=\s*[a-zA-Z0-9_]{10,}" "$filePath" && ! grep -qE "\$\{.*\}.*=" "$filePath"; then
|
|
206
|
+
# Check if it's from an environment variable or parameter store
|
|
207
|
+
if ! grep -qE "(getenv|os\.environ|\$\{.*:-|\$\(.*\)|\$\{.*\}|\$\$|aws ssm|parameter.*store)" "$filePath"; then
|
|
208
|
+
issues+=("Potential hardcoded credential detected")
|
|
209
|
+
fi
|
|
210
|
+
fi
|
|
211
|
+
|
|
212
|
+
# Run shellcheck if available
|
|
213
|
+
if [[ $SHELLCHECK_AVAILABLE -eq 1 ]]; then
|
|
214
|
+
# Run shellcheck and capture only errors (warnings are non-blocking)
|
|
215
|
+
local shellcheck_output
|
|
216
|
+
shellcheck_output=$(shellcheck --severity=error "$filePath" 2>&1 || true)
|
|
217
|
+
|
|
218
|
+
if [[ -n "$shellcheck_output" ]]; then
|
|
219
|
+
issues+=("SHELLCHECK FINDINGS:")
|
|
220
|
+
# Parse shellcheck output and add as issues
|
|
221
|
+
while IFS= read -r line; do
|
|
222
|
+
if [[ -n "$line" ]]; then
|
|
223
|
+
issues+=(" $line")
|
|
224
|
+
fi
|
|
225
|
+
done <<< "$shellcheck_output"
|
|
226
|
+
fi
|
|
227
|
+
fi
|
|
228
|
+
|
|
229
|
+
# Return issues
|
|
230
|
+
if [[ ${#issues[@]} -gt 0 ]]; then
|
|
231
|
+
echo "ERROR: $filePath"
|
|
232
|
+
for issue in "${issues[@]}"; do
|
|
233
|
+
echo " - $issue"
|
|
234
|
+
done
|
|
235
|
+
# Print warnings if any (non-blocking)
|
|
236
|
+
if [[ ${#warnings[@]} -gt 0 ]]; then
|
|
237
|
+
echo " WARNINGS (non-blocking):"
|
|
238
|
+
for warning in "${warnings[@]}"; do
|
|
239
|
+
echo " - $warning"
|
|
240
|
+
done
|
|
241
|
+
fi
|
|
242
|
+
return 1
|
|
243
|
+
else
|
|
244
|
+
# Print warnings even if no errors
|
|
245
|
+
if [[ ${#warnings[@]} -gt 0 ]]; then
|
|
246
|
+
echo "OK: $filePath (with warnings)"
|
|
247
|
+
for warning in "${warnings[@]}"; do
|
|
248
|
+
echo " WARNING (non-blocking): $warning"
|
|
249
|
+
done
|
|
250
|
+
else
|
|
251
|
+
echo "OK: $filePath"
|
|
252
|
+
fi
|
|
253
|
+
return 0
|
|
254
|
+
fi
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
# Main execution - only run if script is executed directly
|
|
258
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
259
|
+
# Temporarily disable strict mode for main execution
|
|
260
|
+
set +euo pipefail
|
|
261
|
+
|
|
262
|
+
echo "Shell Script Safety Checker"
|
|
263
|
+
echo "=========================="
|
|
264
|
+
if [[ $SHELLCHECK_AVAILABLE -eq 1 ]]; then
|
|
265
|
+
echo "Using shellcheck for static analysis (warnings and errors)"
|
|
266
|
+
else
|
|
267
|
+
echo "WARNING: shellcheck not found - install with 'apt-get install shellcheck' for comprehensive analysis"
|
|
268
|
+
fi
|
|
269
|
+
echo ""
|
|
270
|
+
|
|
271
|
+
totalIssues=0
|
|
272
|
+
fileCount=0
|
|
273
|
+
targetDir="${1:-.}"
|
|
274
|
+
|
|
275
|
+
if [ -f "$targetDir" ]; then
|
|
276
|
+
checkShellFile "$targetDir" || totalIssues=1
|
|
277
|
+
fileCount=1
|
|
278
|
+
else
|
|
279
|
+
# Check all shell scripts except this safety checker
|
|
280
|
+
while IFS= read -r -d '' file; do
|
|
281
|
+
# Skip the safety checker itself
|
|
282
|
+
if [[ "$file" != *"shell-safety-checker.sh" ]]; then
|
|
283
|
+
((fileCount++))
|
|
284
|
+
checkShellFile "$file" || ((totalIssues++))
|
|
285
|
+
fi
|
|
286
|
+
done < <(find "$targetDir" -name "*.sh" -type f -not -path "*/venv/*" -not -path "*/.venv*" -print0)
|
|
287
|
+
fi
|
|
288
|
+
|
|
289
|
+
echo ""
|
|
290
|
+
echo "Safety Check Summary"
|
|
291
|
+
echo "==================="
|
|
292
|
+
echo "Files checked: $fileCount"
|
|
293
|
+
echo "Files with issues: $totalIssues"
|
|
294
|
+
|
|
295
|
+
echo ""
|
|
296
|
+
echo "Final Result"
|
|
297
|
+
echo "============"
|
|
298
|
+
|
|
299
|
+
if [[ $totalIssues -eq 0 ]]; then
|
|
300
|
+
echo "✓ All checks passed!"
|
|
301
|
+
exit 0
|
|
302
|
+
else
|
|
303
|
+
echo "✗ Safety check failed"
|
|
304
|
+
exit 1
|
|
305
|
+
fi
|
|
306
|
+
fi
|
package/lib/settings.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { withTransaction } from './store.mjs'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_EFFECTIVE = {
|
|
6
|
+
model_config: {
|
|
7
|
+
provider: 'zhipu',
|
|
8
|
+
model_id: 'glm_4_7_flash',
|
|
9
|
+
base_url: null,
|
|
10
|
+
},
|
|
11
|
+
permissions: { defaultMode: 'default', allow: [], deny: [], ask: [] },
|
|
12
|
+
hooks: [],
|
|
13
|
+
apiKeyHelper: null,
|
|
14
|
+
mcp_servers: {},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get file modification time or null if file doesn't exist.
|
|
19
|
+
* @param {string} filePath - Path to the file
|
|
20
|
+
* @returns {Date | null} File modification time or null
|
|
21
|
+
*/
|
|
22
|
+
function getFileModificationTime(filePath) {
|
|
23
|
+
try {
|
|
24
|
+
const stats = fs.statSync(filePath)
|
|
25
|
+
return stats.mtime
|
|
26
|
+
} catch {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** §4.4 — bootstrap once at process start. */
|
|
32
|
+
export function bootstrapSettings(db, projectRoot) {
|
|
33
|
+
const candidates = [
|
|
34
|
+
path.join(projectRoot, '.ona', 'settings.json'),
|
|
35
|
+
path.join(projectRoot, '.claude', 'settings.local.json'),
|
|
36
|
+
path.join(projectRoot, 'settings.json'),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
// Step 1: Load settings from files (if they exist)
|
|
40
|
+
let fileSettings = null
|
|
41
|
+
let newestFileTime = null
|
|
42
|
+
|
|
43
|
+
for (const p of candidates) {
|
|
44
|
+
if (!fs.existsSync(p)) continue
|
|
45
|
+
try {
|
|
46
|
+
const content = fs.readFileSync(p, 'utf8')
|
|
47
|
+
const parsed = JSON.parse(content)
|
|
48
|
+
fileSettings = deepMerge(fileSettings || {}, parsed)
|
|
49
|
+
|
|
50
|
+
// Track newest file modification time
|
|
51
|
+
const modTime = getFileModificationTime(p)
|
|
52
|
+
if (modTime && (!newestFileTime || modTime > newestFileTime)) {
|
|
53
|
+
newestFileTime = modTime
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Skip invalid JSON files
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Step 2: Load existing settings from database
|
|
62
|
+
const row = db.prepare(`SELECT json, updated_at FROM settings_snapshot WHERE scope = 'effective'`).get()
|
|
63
|
+
|
|
64
|
+
let dbSettings = null
|
|
65
|
+
let dbTimestamp = null
|
|
66
|
+
|
|
67
|
+
if (row) {
|
|
68
|
+
try {
|
|
69
|
+
dbSettings = JSON.parse(row.json)
|
|
70
|
+
dbTimestamp = new Date(row.updated_at)
|
|
71
|
+
} catch {
|
|
72
|
+
// Database has invalid JSON, treat as empty
|
|
73
|
+
dbSettings = null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Step 3: Determine merge strategy based on timestamps
|
|
78
|
+
let shouldUpdateDatabase = false
|
|
79
|
+
let finalSettings = null
|
|
80
|
+
let source = null
|
|
81
|
+
|
|
82
|
+
if (!dbSettings) {
|
|
83
|
+
// Case A: Database is empty (first run)
|
|
84
|
+
// Merge: File > Defaults
|
|
85
|
+
finalSettings = deepMerge(structuredClone(DEFAULT_EFFECTIVE), fileSettings || {})
|
|
86
|
+
shouldUpdateDatabase = true
|
|
87
|
+
source = 'bootstrap-first-run'
|
|
88
|
+
} else if (fileSettings && newestFileTime && newestFileTime > dbTimestamp) {
|
|
89
|
+
// Case B: File was manually edited after last database update
|
|
90
|
+
// Merge: File > Database > Defaults
|
|
91
|
+
finalSettings = deepMerge(structuredClone(DEFAULT_EFFECTIVE), dbSettings)
|
|
92
|
+
finalSettings = deepMerge(finalSettings, fileSettings)
|
|
93
|
+
shouldUpdateDatabase = true
|
|
94
|
+
source = 'bootstrap-file-newer'
|
|
95
|
+
} else {
|
|
96
|
+
// Case C: Database has settings and is newer than (or equal to) file
|
|
97
|
+
// Keep database settings unchanged (preserves runtime changes)
|
|
98
|
+
finalSettings = dbSettings
|
|
99
|
+
shouldUpdateDatabase = false
|
|
100
|
+
source = 'bootstrap-db-preserved'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Step 4: Update database if needed
|
|
104
|
+
if (shouldUpdateDatabase) {
|
|
105
|
+
const json = JSON.stringify(finalSettings)
|
|
106
|
+
withTransaction(db, () => {
|
|
107
|
+
db.prepare(
|
|
108
|
+
`INSERT OR REPLACE INTO settings_snapshot(scope, json, updated_at) VALUES ('effective', ?, datetime('now'))`,
|
|
109
|
+
).run(json)
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return finalSettings
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getEffectiveSettings(db) {
|
|
117
|
+
const row = db.prepare(`SELECT json FROM settings_snapshot WHERE scope = 'effective'`).get()
|
|
118
|
+
if (!row) return structuredClone(DEFAULT_EFFECTIVE)
|
|
119
|
+
try { return JSON.parse(row.json) } catch { return structuredClone(DEFAULT_EFFECTIVE) }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function updateEffectiveSettings(db, patch) {
|
|
123
|
+
const current = getEffectiveSettings(db)
|
|
124
|
+
const merged = deepMerge(current, patch)
|
|
125
|
+
const json = JSON.stringify(merged)
|
|
126
|
+
withTransaction(db, () => {
|
|
127
|
+
db.prepare(
|
|
128
|
+
`INSERT OR REPLACE INTO settings_snapshot(scope, json, updated_at) VALUES ('effective', ?, datetime('now'))`,
|
|
129
|
+
).run(json)
|
|
130
|
+
})
|
|
131
|
+
return merged
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function deepMerge(a, b) {
|
|
135
|
+
if (!b || typeof b !== 'object') return a
|
|
136
|
+
const out = Array.isArray(a) ? [...a] : { ...a }
|
|
137
|
+
for (const k of Object.keys(b)) {
|
|
138
|
+
const bv = b[k]
|
|
139
|
+
if (bv === undefined) continue
|
|
140
|
+
if (bv && typeof bv === 'object' && !Array.isArray(bv) && typeof out[k] === 'object' && !Array.isArray(out[k])) {
|
|
141
|
+
out[k] = deepMerge(out[k] || {}, bv)
|
|
142
|
+
} else {
|
|
143
|
+
out[k] = bv
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out
|
|
147
|
+
}
|