@ikon85/agent-workflow-kit 0.24.0 → 0.26.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/.agents/skills/setup-workflow/SKILL.md +31 -5
- package/.agents/skills/setup-workflow/safety-guardrails.md +155 -0
- package/.agents/skills/setup-workflow/workflow-advisories.md +28 -6
- package/.claude/hooks/_hook_utils.py +19 -0
- package/.claude/hooks/_safety_guard.py +24 -0
- package/.claude/hooks/block-bg-double-background.py +30 -0
- package/.claude/hooks/block-npm-install-in-pnpm.py +30 -0
- package/.claude/hooks/block-secrets.py +30 -0
- package/.claude/hooks/convention-drift-hint.py +27 -0
- package/.claude/hooks/grep-shim-guard.py +30 -0
- package/.claude/hooks/loc-offender-forewarn.py +56 -0
- package/.claude/hooks/migration-snapshot-reminder.py +24 -0
- package/.claude/hooks/skill-drift-hint.py +9 -45
- package/.claude/skills/setup-workflow/SKILL.md +31 -5
- package/.claude/skills/setup-workflow/safety-guardrails.md +155 -0
- package/.claude/skills/setup-workflow/workflow-advisories.md +28 -6
- package/README.md +31 -0
- package/agent-workflow-kit.package.json +132 -9
- package/package.json +1 -1
- package/scripts/loc_offender_gate.py +24 -0
- package/scripts/safety-guardrails/core.py +221 -0
- package/scripts/safety-guardrails/search.py +92 -0
- package/scripts/security/audit-gate.mjs +122 -0
- package/scripts/security/ensure-gitleaks.mjs +101 -0
- package/scripts/security/gitleaks-profile.json +14 -0
- package/scripts/security/install-git-hooks.mjs +52 -0
- package/scripts/test_skill_publish_audit.py +15 -0
- package/scripts/workflow-advisories/capabilities.json +33 -0
- package/scripts/workflow-advisories/core.py +100 -0
- package/src/lib/bundle.mjs +18 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Profile-driven decisions for independently activatable Safety Guardrails."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from search import scan as scan_search
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class GuardDecision:
|
|
17
|
+
action: str
|
|
18
|
+
message: str = ""
|
|
19
|
+
log_message: str = ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_profile(path: Path) -> dict[str, Any]:
|
|
23
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
24
|
+
section = document.get("safetyGuardrails")
|
|
25
|
+
if not isinstance(section, dict):
|
|
26
|
+
return {}
|
|
27
|
+
return section
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def inactive() -> GuardDecision:
|
|
31
|
+
return GuardDecision("allow")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _secret_name_is_sensitive(name: str, policy: dict[str, Any]) -> bool:
|
|
35
|
+
suffixes = tuple(policy.get("safeTemplateSuffixes") or ())
|
|
36
|
+
if name.startswith(".env"):
|
|
37
|
+
return not any(name.endswith(suffix) for suffix in suffixes)
|
|
38
|
+
return name in set(policy.get("sensitiveNames") or ())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _secret_path_is_sensitive(path: str, policy: dict[str, Any]) -> bool:
|
|
42
|
+
candidate = Path(path)
|
|
43
|
+
if _secret_name_is_sensitive(candidate.name, policy):
|
|
44
|
+
return True
|
|
45
|
+
normalized = str(candidate).replace("\\", "/")
|
|
46
|
+
return any(fragment in normalized for fragment in policy.get("sensitivePathFragments") or ())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def secret_decision(policy: dict[str, Any], payload: dict[str, Any]) -> GuardDecision:
|
|
50
|
+
if policy.get("enabled") is not True:
|
|
51
|
+
return inactive()
|
|
52
|
+
tool = payload.get("tool_name")
|
|
53
|
+
tool_input = payload.get("tool_input") or {}
|
|
54
|
+
if tool == "Bash":
|
|
55
|
+
return _secret_bash_decision(policy, str(tool_input.get("command") or ""))
|
|
56
|
+
path = str(tool_input.get("file_path") or "")
|
|
57
|
+
if not path or tool not in {"Read", "Edit", "Write", "MultiEdit"}:
|
|
58
|
+
return inactive()
|
|
59
|
+
if tool == "Write" and Path(path).name.startswith(".env"):
|
|
60
|
+
return inactive()
|
|
61
|
+
if not _secret_path_is_sensitive(path, policy):
|
|
62
|
+
return inactive()
|
|
63
|
+
return GuardDecision(
|
|
64
|
+
"block",
|
|
65
|
+
f"BLOCKED: access to sensitive path '{path}' is not allowed.",
|
|
66
|
+
f"blocked tool={tool} path={path!r}",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_DUMP_COMMANDS = {
|
|
71
|
+
"cat", "head", "tail", "less", "more", "strings", "nl", "xxd", "od",
|
|
72
|
+
"bat", "tac", "rev", "grep", "egrep", "fgrep", "rg", "ag", "awk", "sed", "cut",
|
|
73
|
+
}
|
|
74
|
+
_QUIET_GREP = {"grep", "egrep", "fgrep", "rg", "ag"}
|
|
75
|
+
_INSTALL_RE = re.compile(
|
|
76
|
+
r"\b(?P<manager>npm|pnpm|yarn|bun)\s+(?:install|i|ci)\b"
|
|
77
|
+
)
|
|
78
|
+
_BACKGROUND_RE = re.compile(r"(?<![<>&])&[ \t]*(?:;|$)", re.MULTILINE)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _secret_bash_decision(policy: dict[str, Any], command: str) -> GuardDecision:
|
|
82
|
+
if not command:
|
|
83
|
+
return inactive()
|
|
84
|
+
try:
|
|
85
|
+
segments = re.split(r"\n|;|&&|\|\||\|", command)
|
|
86
|
+
for segment in segments:
|
|
87
|
+
tokens = shlex.split(segment)
|
|
88
|
+
dumps = set(tokens) & _DUMP_COMMANDS
|
|
89
|
+
paths = [token for token in tokens if _secret_path_is_sensitive(token, policy)]
|
|
90
|
+
if not dumps or not paths:
|
|
91
|
+
continue
|
|
92
|
+
if dumps <= _QUIET_GREP and any(flag == "--quiet" or flag.startswith("-q") for flag in tokens):
|
|
93
|
+
continue
|
|
94
|
+
return GuardDecision(
|
|
95
|
+
"block",
|
|
96
|
+
f"BLOCKED: command would print sensitive path '{paths[0]}' to the transcript.",
|
|
97
|
+
f"blocked bash secret dump path={paths[0]!r}",
|
|
98
|
+
)
|
|
99
|
+
except ValueError:
|
|
100
|
+
return inactive()
|
|
101
|
+
return inactive()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _effective_cwd(command: str, match_pos: int, fallback: Path) -> Path:
|
|
105
|
+
target = None
|
|
106
|
+
for match in re.finditer(r"(?:^|[;&|]\s*)cd\s+([^\s;&|]+)", command):
|
|
107
|
+
if match.end() <= match_pos:
|
|
108
|
+
target = match.group(1).strip("'\"")
|
|
109
|
+
if target is None:
|
|
110
|
+
return fallback
|
|
111
|
+
path = Path(target).expanduser()
|
|
112
|
+
return path if path.is_absolute() else fallback / path
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _detected_manager(policy: dict[str, Any], start: Path) -> tuple[str, Path] | None:
|
|
116
|
+
lockfiles = policy.get("lockfiles") or {}
|
|
117
|
+
try:
|
|
118
|
+
current = start.resolve()
|
|
119
|
+
except OSError:
|
|
120
|
+
return None
|
|
121
|
+
while True:
|
|
122
|
+
for filename, manager in lockfiles.items():
|
|
123
|
+
candidate = current / filename
|
|
124
|
+
if candidate.is_file():
|
|
125
|
+
return str(manager), candidate
|
|
126
|
+
if current.parent == current:
|
|
127
|
+
return None
|
|
128
|
+
current = current.parent
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def package_manager_decision(
|
|
132
|
+
policy: dict[str, Any],
|
|
133
|
+
payload: dict[str, Any],
|
|
134
|
+
cwd: Path,
|
|
135
|
+
) -> GuardDecision:
|
|
136
|
+
if policy.get("enabled") is not True or payload.get("tool_name") != "Bash":
|
|
137
|
+
return inactive()
|
|
138
|
+
command = str((payload.get("tool_input") or {}).get("command") or "")
|
|
139
|
+
match = _INSTALL_RE.search(command)
|
|
140
|
+
if match is None:
|
|
141
|
+
return inactive()
|
|
142
|
+
requested = match.group("manager")
|
|
143
|
+
detected = _detected_manager(policy, _effective_cwd(command, match.start(), cwd))
|
|
144
|
+
if detected is None or detected[0] == requested:
|
|
145
|
+
return inactive()
|
|
146
|
+
manager, lockfile = detected
|
|
147
|
+
return GuardDecision(
|
|
148
|
+
"block",
|
|
149
|
+
f"BLOCKED: `{match.group(0)}` conflicts with {lockfile.name}. "
|
|
150
|
+
f"Use `{manager} install` in this project.",
|
|
151
|
+
f"blocked package-manager requested={requested} expected={manager} lock={lockfile}",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def double_background_decision(
|
|
156
|
+
policy: dict[str, Any],
|
|
157
|
+
payload: dict[str, Any],
|
|
158
|
+
) -> GuardDecision:
|
|
159
|
+
if policy.get("enabled") is not True or payload.get("tool_name") != "Bash":
|
|
160
|
+
return inactive()
|
|
161
|
+
surface = str(payload.get("surface") or "claude")
|
|
162
|
+
if surface not in set(policy.get("surfaces") or ()):
|
|
163
|
+
return inactive()
|
|
164
|
+
tool_input = payload.get("tool_input") or {}
|
|
165
|
+
is_background = tool_input.get("run_in_background") is True or (
|
|
166
|
+
str(tool_input.get("run_in_background") or "").lower() == "true"
|
|
167
|
+
)
|
|
168
|
+
command = str(tool_input.get("command") or "")
|
|
169
|
+
if not is_background or not _BACKGROUND_RE.search(command):
|
|
170
|
+
return inactive()
|
|
171
|
+
return GuardDecision(
|
|
172
|
+
"block",
|
|
173
|
+
"BLOCKED: run_in_background already detaches and captures this command; "
|
|
174
|
+
"remove the inner `&` background operator.",
|
|
175
|
+
"blocked double-background command",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def search_shim_decision(
|
|
180
|
+
policy: dict[str, Any],
|
|
181
|
+
payload: dict[str, Any],
|
|
182
|
+
) -> GuardDecision:
|
|
183
|
+
if (
|
|
184
|
+
policy.get("enabled") is not True
|
|
185
|
+
or policy.get("detected") is not True
|
|
186
|
+
or payload.get("tool_name") != "Bash"
|
|
187
|
+
):
|
|
188
|
+
return inactive()
|
|
189
|
+
command = str((payload.get("tool_input") or {}).get("command") or "")
|
|
190
|
+
finding = scan_search(command, set(policy.get("commandNames") or ()))
|
|
191
|
+
if finding is None:
|
|
192
|
+
return inactive()
|
|
193
|
+
reason, pattern = finding
|
|
194
|
+
return GuardDecision(
|
|
195
|
+
"block",
|
|
196
|
+
f"BLOCKED: search shim cannot safely evaluate pattern `{pattern}` ({reason}). "
|
|
197
|
+
"Use `--fixed-strings` or `command grep`.",
|
|
198
|
+
f"blocked search-shim breaker reason={reason}",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def evaluate(kind: str, profile: dict[str, Any], payload: dict[str, Any]) -> GuardDecision:
|
|
203
|
+
if kind == "secrets":
|
|
204
|
+
return secret_decision(profile.get("secrets") or {}, payload)
|
|
205
|
+
if kind == "package-manager":
|
|
206
|
+
return package_manager_decision(
|
|
207
|
+
profile.get("packageManager") or {},
|
|
208
|
+
payload,
|
|
209
|
+
Path.cwd(),
|
|
210
|
+
)
|
|
211
|
+
if kind == "double-background":
|
|
212
|
+
return double_background_decision(
|
|
213
|
+
profile.get("doubleBackground") or {},
|
|
214
|
+
payload,
|
|
215
|
+
)
|
|
216
|
+
if kind == "search-shim":
|
|
217
|
+
return search_shim_decision(
|
|
218
|
+
profile.get("searchShim") or {},
|
|
219
|
+
payload,
|
|
220
|
+
)
|
|
221
|
+
return inactive()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Verified search-shim breaker detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
|
|
7
|
+
OPERATORS = {"|", "||", "&&", ";", "&", "|&"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _count_unescaped(value: str, character: str) -> int:
|
|
11
|
+
count = index = 0
|
|
12
|
+
while index < len(value):
|
|
13
|
+
if value[index] == "\\":
|
|
14
|
+
index += 2
|
|
15
|
+
continue
|
|
16
|
+
if value[index] == character:
|
|
17
|
+
count += 1
|
|
18
|
+
index += 1
|
|
19
|
+
return count
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _pattern_and_flags(args: list[str]) -> tuple[str | None, set[str]]:
|
|
23
|
+
pattern = None
|
|
24
|
+
flags = set()
|
|
25
|
+
after_options = False
|
|
26
|
+
index = 0
|
|
27
|
+
while index < len(args):
|
|
28
|
+
value = args[index]
|
|
29
|
+
if value == "--":
|
|
30
|
+
after_options = True
|
|
31
|
+
elif not after_options and value in {"-e", "--regexp"}:
|
|
32
|
+
index += 1
|
|
33
|
+
if index < len(args) and pattern is None:
|
|
34
|
+
pattern = args[index]
|
|
35
|
+
elif not after_options and value.startswith("--regexp="):
|
|
36
|
+
if pattern is None:
|
|
37
|
+
pattern = value.split("=", 1)[1]
|
|
38
|
+
elif not after_options and value.startswith("-"):
|
|
39
|
+
flags.add(value)
|
|
40
|
+
elif pattern is None:
|
|
41
|
+
pattern = value
|
|
42
|
+
index += 1
|
|
43
|
+
return pattern, flags
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _has_flag(flags: set[str], letter: str, long: str) -> bool:
|
|
47
|
+
return long in flags or any(
|
|
48
|
+
flag.startswith("-") and not flag.startswith("--") and letter in flag[1:]
|
|
49
|
+
for flag in flags
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _breaker(pattern: str, flags: set[str]) -> str | None:
|
|
54
|
+
if _has_flag(flags, "F", "--fixed-strings"):
|
|
55
|
+
return None
|
|
56
|
+
if _count_unescaped(pattern, "(") != _count_unescaped(pattern, ")"):
|
|
57
|
+
return "unbalanced parentheses"
|
|
58
|
+
extended = _has_flag(flags, "E", "--extended-regexp") or _has_flag(
|
|
59
|
+
flags, "P", "--perl-regexp"
|
|
60
|
+
)
|
|
61
|
+
if not extended and _count_unescaped(pattern, "|"):
|
|
62
|
+
return "bare alternation without extended-regexp mode"
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def scan(command: str, command_names: set[str]) -> tuple[str, str] | None:
|
|
67
|
+
try:
|
|
68
|
+
tokens = shlex.split(command, posix=True)
|
|
69
|
+
except ValueError:
|
|
70
|
+
return None
|
|
71
|
+
index = 0
|
|
72
|
+
while index < len(tokens):
|
|
73
|
+
token = tokens[index]
|
|
74
|
+
shim = token in command_names
|
|
75
|
+
if token == "grep" and index and tokens[index - 1] in {"command", "git"}:
|
|
76
|
+
shim = False
|
|
77
|
+
if token == "grep" and index and tokens[index - 1] == "rtk":
|
|
78
|
+
shim = True
|
|
79
|
+
if not shim:
|
|
80
|
+
index += 1
|
|
81
|
+
continue
|
|
82
|
+
end = index + 1
|
|
83
|
+
args = []
|
|
84
|
+
while end < len(tokens) and tokens[end] not in OPERATORS:
|
|
85
|
+
args.append(tokens[end])
|
|
86
|
+
end += 1
|
|
87
|
+
pattern, flags = _pattern_and_flags(args)
|
|
88
|
+
reason = _breaker(pattern, flags) if pattern is not None else None
|
|
89
|
+
if reason:
|
|
90
|
+
return reason, pattern
|
|
91
|
+
index = end
|
|
92
|
+
return None
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
const SEVERITIES = ['info', 'low', 'moderate', 'high', 'critical'];
|
|
5
|
+
|
|
6
|
+
function emptyCounts() {
|
|
7
|
+
return Object.fromEntries(SEVERITIES.map((severity) => [severity, 0]));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizedCounts(input) {
|
|
11
|
+
const counts = emptyCounts();
|
|
12
|
+
for (const severity of SEVERITIES) {
|
|
13
|
+
const value = Number(input?.[severity] ?? 0);
|
|
14
|
+
if (!Number.isInteger(value) || value < 0) throw new Error('invalid vulnerability count');
|
|
15
|
+
counts[severity] = value;
|
|
16
|
+
}
|
|
17
|
+
return counts;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseJson(input) {
|
|
21
|
+
return JSON.parse(input);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function countsFromAdvisories(data) {
|
|
25
|
+
const counts = emptyCounts();
|
|
26
|
+
for (const advisories of Object.values(data)) {
|
|
27
|
+
if (!Array.isArray(advisories)) throw new Error('invalid advisory collection');
|
|
28
|
+
for (const advisory of advisories) {
|
|
29
|
+
const severity = advisory?.severity;
|
|
30
|
+
if (!SEVERITIES.includes(severity)) throw new Error('invalid advisory severity');
|
|
31
|
+
counts[severity] += 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return counts;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseAudit(manager, input) {
|
|
38
|
+
let data;
|
|
39
|
+
try {
|
|
40
|
+
data = parseJson(input);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (manager !== 'yarn') throw error;
|
|
43
|
+
const events = input.trim().split(/\r?\n/).map(parseJson);
|
|
44
|
+
const summary = events.find((event) => event?.type === 'auditSummary');
|
|
45
|
+
if (!summary?.data?.vulnerabilities) throw error;
|
|
46
|
+
return {
|
|
47
|
+
counts: normalizedCounts(summary.data.vulnerabilities),
|
|
48
|
+
data: summary,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (manager === 'npm' || manager === 'pnpm') {
|
|
52
|
+
if (data?.error && typeof data.error === 'object') {
|
|
53
|
+
return { counts: emptyCounts(), data };
|
|
54
|
+
}
|
|
55
|
+
if (!data?.metadata?.vulnerabilities) throw new Error('missing metadata.vulnerabilities');
|
|
56
|
+
return { counts: normalizedCounts(data.metadata.vulnerabilities), data };
|
|
57
|
+
}
|
|
58
|
+
if (manager === 'yarn' || manager === 'bun') {
|
|
59
|
+
if (data?.error && typeof data.error === 'object') {
|
|
60
|
+
return { counts: emptyCounts(), data };
|
|
61
|
+
}
|
|
62
|
+
return { counts: countsFromAdvisories(data), data };
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`unsupported package manager: ${manager}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function auditCommand(manager) {
|
|
68
|
+
const commands = {
|
|
69
|
+
npm: ['npm', 'audit', '--omit=dev', '--json'],
|
|
70
|
+
pnpm: ['pnpm', 'audit', '--prod', '--json'],
|
|
71
|
+
yarn: ['yarn', 'npm', 'audit', '--environment', 'production', '--recursive', '--json'],
|
|
72
|
+
bun: ['bun', 'audit', '--prod', '--json'],
|
|
73
|
+
};
|
|
74
|
+
if (!commands[manager]) throw new Error(`unsupported package manager: ${manager}`);
|
|
75
|
+
return commands[manager];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function evaluateAudit({
|
|
79
|
+
manager,
|
|
80
|
+
input,
|
|
81
|
+
serviceOutage = 'warn',
|
|
82
|
+
blockingSeverities = ['high', 'critical'],
|
|
83
|
+
}) {
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = parseAudit(manager, input);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return { status: 'malformed', exitCode: 2, message: error.message };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (parsed.data?.error && typeof parsed.data.error === 'object') {
|
|
92
|
+
return serviceOutage === 'block'
|
|
93
|
+
? { status: 'service-outage-blocked', exitCode: 3 }
|
|
94
|
+
: { status: 'service-outage-warning', exitCode: 0 };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const isBlocking = blockingSeverities.some((severity) => parsed.counts[severity] > 0);
|
|
98
|
+
return {
|
|
99
|
+
status: isBlocking ? 'blocking-findings' : 'passed',
|
|
100
|
+
exitCode: isBlocking ? 1 : 0,
|
|
101
|
+
counts: parsed.counts,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function main() {
|
|
106
|
+
const manager = process.argv[2];
|
|
107
|
+
const serviceOutageArg = process.argv.find((arg) => arg.startsWith('--service-outage='));
|
|
108
|
+
const serviceOutage = serviceOutageArg?.split('=', 2)[1] ?? 'warn';
|
|
109
|
+
let input = '';
|
|
110
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
111
|
+
const verdict = evaluateAudit({ manager, input, serviceOutage });
|
|
112
|
+
const output = JSON.stringify(verdict);
|
|
113
|
+
(verdict.exitCode === 0 ? console.log : console.error)(output);
|
|
114
|
+
process.exitCode = verdict.exitCode;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
118
|
+
main().catch((error) => {
|
|
119
|
+
console.error(JSON.stringify({ status: 'malformed', message: error.message }));
|
|
120
|
+
process.exitCode = 2;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import {
|
|
5
|
+
chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm, unlink, writeFile,
|
|
6
|
+
} from 'node:fs/promises';
|
|
7
|
+
import { homedir, tmpdir } from 'node:os';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const exec = promisify(execFile);
|
|
13
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
export const DEFAULT_GITLEAKS_PROFILE = JSON.parse(
|
|
15
|
+
await readFile(join(HERE, 'gitleaks-profile.json'), 'utf8'),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
async function defaultIsAvailable() {
|
|
19
|
+
try {
|
|
20
|
+
await exec('gitleaks', ['version']);
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function defaultFetchArchive(url) {
|
|
28
|
+
const response = await fetch(url);
|
|
29
|
+
if (!response.ok) throw new Error(`download returned HTTP ${response.status}`);
|
|
30
|
+
return Buffer.from(await response.arrayBuffer());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function defaultInstallArchive({ bytes, destination }) {
|
|
34
|
+
const scratch = await mkdtemp(join(tmpdir(), 'awkit-gitleaks-'));
|
|
35
|
+
const archive = join(scratch, 'gitleaks.tar.gz');
|
|
36
|
+
const extracted = join(scratch, 'gitleaks');
|
|
37
|
+
const staged = `${destination}.tmp-${process.pid}`;
|
|
38
|
+
try {
|
|
39
|
+
await writeFile(archive, bytes);
|
|
40
|
+
await exec('tar', ['-xzf', archive, '-C', scratch, 'gitleaks']);
|
|
41
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
42
|
+
await copyFile(extracted, staged);
|
|
43
|
+
await chmod(staged, 0o755);
|
|
44
|
+
await rename(staged, destination);
|
|
45
|
+
} finally {
|
|
46
|
+
await unlink(staged).catch(() => {});
|
|
47
|
+
await rm(scratch, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function ensureGitleaks({
|
|
52
|
+
profile = DEFAULT_GITLEAKS_PROFILE,
|
|
53
|
+
platform = process.platform,
|
|
54
|
+
arch = process.arch,
|
|
55
|
+
destination = join(homedir(), '.local', 'bin', 'gitleaks'),
|
|
56
|
+
isAvailable = defaultIsAvailable,
|
|
57
|
+
fetchArchive = defaultFetchArchive,
|
|
58
|
+
installArchive = defaultInstallArchive,
|
|
59
|
+
} = {}) {
|
|
60
|
+
if (await isAvailable()) return { status: 'already-available' };
|
|
61
|
+
|
|
62
|
+
const target = profile.platforms?.[`${platform}-${arch}`];
|
|
63
|
+
if (!target?.asset || !/^[a-f0-9]{64}$/i.test(target.sha256 ?? '')) {
|
|
64
|
+
return { status: 'unsupported-platform', platform, arch };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const url = `${profile.baseUrl ?? ''}/${target.asset}`;
|
|
68
|
+
let bytes;
|
|
69
|
+
try {
|
|
70
|
+
bytes = await fetchArchive(url);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return { status: 'offline', message: error.message };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const actual = createHash('sha256').update(bytes).digest('hex');
|
|
76
|
+
if (actual !== target.sha256.toLowerCase()) {
|
|
77
|
+
return { status: 'checksum-mismatch', expected: target.sha256, actual };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await installArchive({ bytes, destination, asset: target.asset });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return { status: 'unwritable', message: error.message };
|
|
84
|
+
}
|
|
85
|
+
return { status: 'installed', destination, version: profile.version };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function main() {
|
|
89
|
+
const result = await ensureGitleaks({ destination: process.argv[2] });
|
|
90
|
+
if (result.status === 'installed' || result.status === 'already-available') {
|
|
91
|
+
console.log(`Gitleaks provisioning: ${result.status}.`);
|
|
92
|
+
} else {
|
|
93
|
+
console.error(`Gitleaks provisioning skipped safely: ${result.status}.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
98
|
+
main().catch((error) => {
|
|
99
|
+
console.error(`Gitleaks provisioning failed safely: ${error.message}`);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "8.30.1",
|
|
3
|
+
"baseUrl": "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1",
|
|
4
|
+
"platforms": {
|
|
5
|
+
"linux-x64": {
|
|
6
|
+
"asset": "gitleaks_8.30.1_linux_x64.tar.gz",
|
|
7
|
+
"sha256": "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb"
|
|
8
|
+
},
|
|
9
|
+
"linux-arm64": {
|
|
10
|
+
"asset": "gitleaks_8.30.1_linux_arm64.tar.gz",
|
|
11
|
+
"sha256": "e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const exec = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
async function defaultRunGit(cwd, args) {
|
|
9
|
+
const result = await exec('git', args, { cwd });
|
|
10
|
+
return result.stdout.trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function installGitHooks({
|
|
14
|
+
cwd = process.cwd(),
|
|
15
|
+
hooksPath = '.githooks',
|
|
16
|
+
runGit = defaultRunGit,
|
|
17
|
+
} = {}) {
|
|
18
|
+
try {
|
|
19
|
+
await runGit(cwd, ['rev-parse', '--is-inside-work-tree']);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error?.code === 128) return { status: 'not-a-repository' };
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let current = '';
|
|
26
|
+
try {
|
|
27
|
+
current = await runGit(cwd, ['config', '--get', 'core.hooksPath']);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error?.code !== 1) throw error;
|
|
30
|
+
}
|
|
31
|
+
if (current === hooksPath) return { status: 'unchanged', hooksPath };
|
|
32
|
+
|
|
33
|
+
await runGit(cwd, ['config', 'core.hooksPath', hooksPath]);
|
|
34
|
+
return { status: 'wired', hooksPath };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
const hooksPath = process.argv[2] ?? '.githooks';
|
|
39
|
+
const result = await installGitHooks({ hooksPath });
|
|
40
|
+
if (result.status === 'not-a-repository') {
|
|
41
|
+
console.log('Git hooks not wired: current directory is not a Git work tree.');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
console.log(`Git hooks ${result.status}: core.hooksPath=${result.hooksPath}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
48
|
+
main().catch((error) => {
|
|
49
|
+
console.error(`Git hook wiring failed: ${error.message}`);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -124,6 +124,13 @@ def _known_hashes(root: Path) -> set:
|
|
|
124
124
|
for f in data.get("files", []):
|
|
125
125
|
if "sha256" in f:
|
|
126
126
|
hashes.add(f["sha256"])
|
|
127
|
+
gitleaks = root / "scripts/security/gitleaks-profile.json"
|
|
128
|
+
if gitleaks.exists():
|
|
129
|
+
data = json.loads(gitleaks.read_text(encoding="utf-8"))
|
|
130
|
+
for platform in data.get("platforms", {}).values():
|
|
131
|
+
checksum = platform.get("sha256")
|
|
132
|
+
if isinstance(checksum, str):
|
|
133
|
+
hashes.add(checksum)
|
|
127
134
|
return hashes
|
|
128
135
|
|
|
129
136
|
|
|
@@ -293,6 +300,14 @@ class Exemptions(unittest.TestCase):
|
|
|
293
300
|
{"path": "x", "sha256": "f" * 64}]}), encoding="utf-8")
|
|
294
301
|
self.assertEqual(audit_dir(self.dir), [])
|
|
295
302
|
|
|
303
|
+
def test_gitleaks_profile_checksums_are_verified_pin_metadata(self):
|
|
304
|
+
profile = self.dir / "scripts/security"
|
|
305
|
+
profile.mkdir(parents=True)
|
|
306
|
+
(profile / "gitleaks-profile.json").write_text(json.dumps({
|
|
307
|
+
"platforms": {"linux-x64": {"sha256": "e" * 64}},
|
|
308
|
+
}), encoding="utf-8")
|
|
309
|
+
self.assertEqual(audit_dir(self.dir), [])
|
|
310
|
+
|
|
296
311
|
def test_loc_offender_traversal_guard_allowed(self):
|
|
297
312
|
p = self.dir / "scripts"
|
|
298
313
|
p.mkdir()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"capabilities": [
|
|
4
|
+
{
|
|
5
|
+
"profileKey": "largeRead",
|
|
6
|
+
"artifact": ".claude/hooks/recon-size-hint.py"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"profileKey": "baseline",
|
|
10
|
+
"artifact": ".claude/hooks/baseline-capture-hint.py"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"profileKey": "preRefactor",
|
|
14
|
+
"artifact": ".claude/hooks/pre-refactor-sweep.py"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"profileKey": "stopChecks",
|
|
18
|
+
"artifact": ".claude/hooks/typecheck-on-stop.sh"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"profileKey": "freshness",
|
|
22
|
+
"artifact": ".claude/hooks/convention-drift-hint.py"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"profileKey": "migration",
|
|
26
|
+
"artifact": ".claude/hooks/migration-snapshot-reminder.py"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"profileKey": "locForewarn",
|
|
30
|
+
"artifact": ".claude/hooks/loc-offender-forewarn.py"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|