@ikon85/agent-workflow-kit 0.23.0 → 0.25.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.
Files changed (32) hide show
  1. package/.agents/skills/setup-workflow/SKILL.md +26 -6
  2. package/.agents/skills/setup-workflow/workflow-advisories.md +97 -0
  3. package/.claude/hooks/_hook_utils.py +35 -0
  4. package/.claude/hooks/_safety_guard.py +24 -0
  5. package/.claude/hooks/baseline-capture-hint.py +25 -0
  6. package/.claude/hooks/block-bg-double-background.py +30 -0
  7. package/.claude/hooks/block-npm-install-in-pnpm.py +30 -0
  8. package/.claude/hooks/block-secrets.py +30 -0
  9. package/.claude/hooks/convention-drift-hint.py +27 -0
  10. package/.claude/hooks/grep-shim-guard.py +30 -0
  11. package/.claude/hooks/loc-offender-forewarn.py +56 -0
  12. package/.claude/hooks/migration-snapshot-reminder.py +24 -0
  13. package/.claude/hooks/pre-refactor-sweep.py +27 -0
  14. package/.claude/hooks/recon-size-hint.py +24 -0
  15. package/.claude/hooks/skill-drift-hint.py +9 -45
  16. package/.claude/hooks/typecheck-on-stop.py +27 -0
  17. package/.claude/hooks/typecheck-on-stop.sh +2 -0
  18. package/.claude/skills/setup-workflow/SKILL.md +26 -6
  19. package/.claude/skills/setup-workflow/workflow-advisories.md +97 -0
  20. package/README.md +27 -0
  21. package/agent-workflow-kit.package.json +94 -6
  22. package/package.json +1 -1
  23. package/scripts/loc_offender_gate.py +24 -0
  24. package/scripts/safety-guardrails/core.py +221 -0
  25. package/scripts/safety-guardrails/search.py +92 -0
  26. package/scripts/security/audit-gate.mjs +122 -0
  27. package/scripts/security/ensure-gitleaks.mjs +101 -0
  28. package/scripts/security/gitleaks-profile.json +14 -0
  29. package/scripts/security/install-git-hooks.mjs +52 -0
  30. package/scripts/workflow-advisories/capabilities.json +33 -0
  31. package/scripts/workflow-advisories/core.py +285 -0
  32. package/src/lib/bundle.mjs +12 -0
@@ -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
+ }
@@ -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
+ }