@techgoblin/gobstack 0.0.0-stage → 0.4.4-beta.2
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/CHANGELOG.md +351 -0
- package/LICENSE +21 -0
- package/README.md +217 -2
- package/VERSION +1 -0
- package/adapters/_template/adapter.tsv +16 -0
- package/adapters/_template/detect.sh +10 -0
- package/adapters/_template/emit.sh +5 -0
- package/adapters/_template/verify.sh +4 -0
- package/adapters/claude/adapter.tsv +8 -0
- package/adapters/claude/detect.sh +8 -0
- package/adapters/claude/verify.sh +47 -0
- package/adapters/codex/adapter.tsv +12 -0
- package/adapters/codex/detect.sh +9 -0
- package/adapters/codex/verify.sh +45 -0
- package/adapters/copilot/adapter.tsv +10 -0
- package/adapters/copilot/detect.sh +8 -0
- package/adapters/copilot/verify.sh +45 -0
- package/adapters/cursor/adapter.tsv +11 -0
- package/adapters/cursor/detect.sh +10 -0
- package/adapters/cursor/verify.sh +45 -0
- package/adapters/gemini/adapter.tsv +15 -0
- package/adapters/gemini/detect.sh +11 -0
- package/adapters/gemini/verify.sh +49 -0
- package/adapters/hermes/adapter.tsv +9 -0
- package/adapters/hermes/detect.sh +8 -0
- package/adapters/hermes/verify.sh +27 -0
- package/adapters/opencode/adapter.tsv +14 -0
- package/adapters/opencode/detect.sh +9 -0
- package/adapters/opencode/verify.sh +45 -0
- package/automations/README.md +53 -0
- package/automations/bugreporter-intake.sh +145 -0
- package/automations/drift-audit.sh +139 -0
- package/automations/report.schema.tsv +10 -0
- package/bans/README.md +82 -0
- package/bans/grep-ban.sh +84 -0
- package/bans/layer-check.sh +57 -0
- package/bin/goblin +119 -0
- package/bin/goblin-audit +145 -0
- package/bin/goblin-bans +178 -0
- package/bin/goblin-doctor +233 -0
- package/bin/goblin-emit +484 -0
- package/bin/goblin-init +519 -0
- package/bin/goblin-install +720 -0
- package/bin/goblin-lib.sh +289 -0
- package/bin/goblin-model +105 -0
- package/bin/goblin-upgrade +572 -0
- package/bin/goblin-verify +2798 -0
- package/bin/goblin.js +103 -0
- package/docs/ADOPTION.md +168 -0
- package/docs/CI.md +187 -0
- package/docs/CONTRACTS.md +197 -0
- package/docs/DESIGN.md +92 -0
- package/docs/ENFORCEMENT.md +225 -0
- package/docs/FLOWS.md +164 -0
- package/docs/GUARDRAILS.md +126 -0
- package/docs/GUIDE.md +610 -0
- package/docs/INTEGRATION.md +92 -0
- package/docs/LIMITS.md +591 -0
- package/docs/LOOP.md +165 -0
- package/docs/RE-PLAYBOOK.md +183 -0
- package/docs/RISKS.md +70 -0
- package/docs/ROLES.md +105 -0
- package/manifest/bans.tsv +9 -0
- package/manifest/classes.tsv +61 -0
- package/manifest/enforcement.tsv +88 -0
- package/manifest/glossary.tsv +25 -0
- package/manifest/playbooks.tsv +16 -0
- package/package.json +37 -4
- package/presets/A-shipped-software.yaml +48 -0
- package/presets/B-service-config.yaml +40 -0
- package/presets/C-game.yaml +38 -0
- package/presets/D-knowledge.yaml +41 -0
- package/presets/E-fleet-config.yaml +42 -0
- package/presets/F-electron.yaml +67 -0
- package/roles.yaml +54 -0
- package/skills/goblin-bootstrap/SKILL.md +51 -0
- package/skills/goblin-bugfix/SKILL.md +26 -0
- package/skills/goblin-bugreporter/SKILL.md +52 -0
- package/skills/goblin-drift-audit/SKILL.md +43 -0
- package/skills/goblin-eval/SKILL.md +68 -0
- package/skills/goblin-feature/SKILL.md +26 -0
- package/skills/goblin-feature-map/SKILL.md +140 -0
- package/skills/goblin-handoff/SKILL.md +28 -0
- package/skills/goblin-investigation/SKILL.md +26 -0
- package/skills/goblin-judge/SKILL.md +74 -0
- package/skills/goblin-loop/SKILL.md +88 -0
- package/skills/goblin-mode/SKILL.md +70 -0
- package/skills/goblin-overnight/SKILL.md +42 -0
- package/skills/goblin-pr-gate/SKILL.md +42 -0
- package/skills/goblin-re-mobile/SKILL.md +51 -0
- package/skills/goblin-refactor/SKILL.md +23 -0
- package/skills/goblin-sweep/SKILL.md +23 -0
- package/skills/goblin-tdd-repro/SKILL.md +27 -0
- package/skills/goblin-verify-author/SKILL.md +50 -0
- package/skills/practice/SKILL.md +37 -0
- package/templates/AGENTS.md.tmpl +23 -0
- package/templates/HANDOFF.md.tmpl +43 -0
- package/templates/SPEC.md.tmpl +34 -0
- package/templates/audit-waiver.tsv.tmpl +10 -0
- package/templates/boundary-waivers.tmpl +8 -0
- package/templates/checks/assert.mjs.tmpl +60 -0
- package/templates/checks/gate.sh.tmpl +29 -0
- package/templates/ci/goblin-gate.yml.tmpl +46 -0
- package/templates/goblin.yaml.tmpl +138 -0
- package/templates/install-hooks.allowlist.tmpl +9 -0
- package/templates/loop/decisions.tsv.tmpl +1 -0
- package/templates/loop/predicate.tmpl +16 -0
- package/templates/report.yaml.tmpl +16 -0
package/bin/goblin-audit
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# goblin-audit — run the DECLARED dependency audit ONCE, deliberately, and write the record that
|
|
3
|
+
# SC-07 reads.
|
|
4
|
+
#
|
|
5
|
+
# goblin-audit [--target <dir>] [--print] [--help]
|
|
6
|
+
#
|
|
7
|
+
# This is the ONE command in the toolchain that touches the network, and it is not a check: SC-07
|
|
8
|
+
# never runs it, never calls out, and reads only the record it leaves behind. That split is the
|
|
9
|
+
# whole design (docs/RISKS.md K4: a network call at verify time breaks the offline dependency
|
|
10
|
+
# contract). Run it when you mean to, then commit `.goblin/audit.tsv`.
|
|
11
|
+
#
|
|
12
|
+
# What it writes: `.goblin/audit.tsv`, with a `measured <date>` line and one TAB-separated line
|
|
13
|
+
# per high|critical advisory:
|
|
14
|
+
#
|
|
15
|
+
# package severity id url
|
|
16
|
+
#
|
|
17
|
+
# Exit codes:
|
|
18
|
+
# 0 the record was written (whether or not it is clean)
|
|
19
|
+
# 2 usage, or no .goblin/goblin.yaml to read `security.audit_cmd` from
|
|
20
|
+
# 3 the class declares no audit command (security.audit_cmd is empty) - nothing to run
|
|
21
|
+
# 4 the declared command could not run
|
|
22
|
+
# 5 the output could not be parsed as an audit report - REFUSING to write an empty record,
|
|
23
|
+
# because an empty record reads to SC-07 as "clean" and that would be a fabricated pass
|
|
24
|
+
set -uo pipefail
|
|
25
|
+
|
|
26
|
+
GOBLIN_AUDIT_VERSION="0.4.4"
|
|
27
|
+
|
|
28
|
+
usage() { sed -n '2,/^$/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
|
|
29
|
+
|
|
30
|
+
TARGET="$(pwd)"
|
|
31
|
+
PRINT=0
|
|
32
|
+
while [ $# -gt 0 ]; do
|
|
33
|
+
case "$1" in
|
|
34
|
+
--target) TARGET="${2:-}"; shift 2 ;;
|
|
35
|
+
--print) PRINT=1; shift ;;
|
|
36
|
+
--help|-h) usage; exit 0 ;;
|
|
37
|
+
*) printf 'goblin-audit: unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;;
|
|
38
|
+
esac
|
|
39
|
+
done
|
|
40
|
+
[ -n "$TARGET" ] || { printf 'goblin-audit: --target needs a directory\n' >&2; exit 2; }
|
|
41
|
+
cd "$TARGET" || { printf 'goblin-audit: no such directory: %s\n' "$TARGET" >&2; exit 2; }
|
|
42
|
+
|
|
43
|
+
CONFIG=".goblin/goblin.yaml"
|
|
44
|
+
[ -f "$CONFIG" ] || { printf 'goblin-audit: %s has no %s - run goblin-install first\n' "$TARGET" "$CONFIG" >&2; exit 2; }
|
|
45
|
+
|
|
46
|
+
# Self-contained on purpose: the audit runner must work even if goblin-lib.sh was edited or is
|
|
47
|
+
# missing, because it is the one tool a human runs by hand.
|
|
48
|
+
g_yaml_block_scalar() { # <file> <block> <key>
|
|
49
|
+
awk -v b="$2" -v k="$3" '
|
|
50
|
+
$0 ~ ("^" b ":[[:space:]]*$") { inb = 1; next }
|
|
51
|
+
inb && /^[^ ]/ { inb = 0 }
|
|
52
|
+
inb && $0 ~ ("^ " k ":") {
|
|
53
|
+
v = $0; sub("^ " k ":[[:space:]]*", "", v); print v; exit
|
|
54
|
+
}
|
|
55
|
+
' "$1"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
AUDIT_CMD=$(g_yaml_block_scalar "$CONFIG" security audit_cmd)
|
|
59
|
+
if [ -z "$AUDIT_CMD" ]; then
|
|
60
|
+
printf 'goblin-audit: this class declares no audit command (security.audit_cmd is empty in\n'
|
|
61
|
+
printf ' %s). Nothing to run, and no record to write.\n' "$CONFIG"
|
|
62
|
+
exit 3
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
TODAY=$(date -u +%F)
|
|
66
|
+
RAW=$(mktemp) || exit 2
|
|
67
|
+
trap 'rm -f "$RAW"' EXIT
|
|
68
|
+
|
|
69
|
+
printf 'goblin-audit: running the declared audit ONCE (this is the deliberate network step):\n %s\n' "$AUDIT_CMD"
|
|
70
|
+
# shellcheck disable=SC2086
|
|
71
|
+
bash -c "$AUDIT_CMD" > "$RAW" 2>&1
|
|
72
|
+
RC=$?
|
|
73
|
+
if [ "$RC" -ne 0 ] && [ ! -s "$RAW" ]; then
|
|
74
|
+
printf 'goblin-audit: the declared command exited %s and printed nothing\n' "$RC" >&2
|
|
75
|
+
exit 4
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
# npm audit exits non-zero when it FINDS advisories, which is the normal case we are recording.
|
|
79
|
+
# So the exit code is not the signal here - the parse is.
|
|
80
|
+
if ! grep -qE '"(vulnerabilities|advisories)"|auditReportVersion' "$RAW"; then
|
|
81
|
+
printf 'goblin-audit: the output is not an audit report (no "vulnerabilities", "advisories" or\n' >&2
|
|
82
|
+
printf ' "auditReportVersion" key). REFUSING to write a record: an empty record reads to SC-07\n' >&2
|
|
83
|
+
printf ' as "clean", which would be a fabricated pass. First 20 lines:\n' >&2
|
|
84
|
+
head -n 20 "$RAW" >&2
|
|
85
|
+
exit 5
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
RECORD=".goblin/audit.tsv"
|
|
89
|
+
LINES=$(awk '
|
|
90
|
+
# npm v2/v3 JSON. Two shapes matter:
|
|
91
|
+
# "vulnerabilities": { "<pkg>": { "name": ..., "severity": "high", "via": [ ... ] } }
|
|
92
|
+
# "via": [ { "source": 1, "name": "<pkg>", "id": 1091, "url": "https://..." , ... "range": "..." } ]
|
|
93
|
+
# The vulnerability object names its severity BEFORE the via block that carries the advisory id
|
|
94
|
+
# and url, so the entries are buffered and printed at END rather than on the severity line.
|
|
95
|
+
# Field order (source, name, dependency, title, url, severity, cwe, cvss, range) is what makes
|
|
96
|
+
# "range" a safe end-of-advisory marker; anything the parse cannot pair is left empty on the
|
|
97
|
+
# line, visibly, instead of guessed.
|
|
98
|
+
{
|
|
99
|
+
line = $0
|
|
100
|
+
if (match(line, /"source"[[:space:]]*:[[:space:]]*[0-9]+/)) {
|
|
101
|
+
inadv = 1; aname = ""; aurl = ""
|
|
102
|
+
s = substr(line, RSTART, RLENGTH); sub(/.*:[[:space:]]*/, "", s); aid = s
|
|
103
|
+
}
|
|
104
|
+
if (inadv) {
|
|
105
|
+
if (match(line, /"name"[[:space:]]*:[[:space:]]*"[^"]+"/)) {
|
|
106
|
+
s = substr(line, RSTART, RLENGTH); sub(/.*"[[:space:]]*:[[:space:]]*"/, "", s); sub(/"$/, "", s); aname = s
|
|
107
|
+
}
|
|
108
|
+
if (match(line, /"id"[[:space:]]*:[[:space:]]*[0-9]+/)) {
|
|
109
|
+
s = substr(line, RSTART, RLENGTH); sub(/.*:[[:space:]]*/, "", s); aid = s
|
|
110
|
+
}
|
|
111
|
+
if (match(line, /"url"[[:space:]]*:[[:space:]]*"[^"]+"/)) {
|
|
112
|
+
s = substr(line, RSTART, RLENGTH); sub(/.*"[[:space:]]*:[[:space:]]*"/, "", s); sub(/"$/, "", s); aurl = s
|
|
113
|
+
}
|
|
114
|
+
if (match(line, /"range"[[:space:]]*:/)) {
|
|
115
|
+
if (aname != "" && !(aname in adid)) { adid[aname] = aid; aurl2[aname] = aurl }
|
|
116
|
+
inadv = 0
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (match(line, /"name"[[:space:]]*:[[:space:]]*"[^"]+"/)) {
|
|
120
|
+
s = substr(line, RSTART, RLENGTH); sub(/.*"[[:space:]]*:[[:space:]]*"/, "", s); sub(/"$/, "", s); vname = s
|
|
121
|
+
}
|
|
122
|
+
if (match(line, /"severity"[[:space:]]*:[[:space:]]*"(high|critical)"/)) {
|
|
123
|
+
s = substr(line, RSTART, RLENGTH); sub(/.*:[[:space:]]*"/, "", s); sub(/"$/, "", s)
|
|
124
|
+
if (vname != "") vsev[vname] = s
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
END {
|
|
128
|
+
for (p in vsev) printf "%s\t%s\t%s\t%s\n", p, vsev[p], (p in adid ? adid[p] : ""), (p in aurl2 ? aurl2[p] : "")
|
|
129
|
+
}
|
|
130
|
+
' "$RAW" | sort)
|
|
131
|
+
|
|
132
|
+
{
|
|
133
|
+
printf '# .goblin/audit.tsv - written by goblin-audit %s on %s\n' "$GOBLIN_AUDIT_VERSION" "$TODAY"
|
|
134
|
+
printf '# command: %s\n' "$AUDIT_CMD"
|
|
135
|
+
printf '# one line per high|critical advisory: package<TAB>severity<TAB>id<TAB>url\n'
|
|
136
|
+
printf '# SC-07 reads this file and never the network. Each line must be matched in\n'
|
|
137
|
+
printf '# .goblin/audit-waiver.tsv (package, severity, id, date, reason) or it is a RED.\n'
|
|
138
|
+
printf 'measured %s\n' "$TODAY"
|
|
139
|
+
if [ -n "$LINES" ]; then printf '%s\n' "$LINES"; else printf '# no high|critical advisories\n'; fi
|
|
140
|
+
} > "$RECORD"
|
|
141
|
+
|
|
142
|
+
N=$(printf '%s\n' "$LINES" | grep -c '[^[:space:]]' || true)
|
|
143
|
+
printf 'goblin-audit: wrote %s - %s high|critical advisory line(s), measured %s\n' "$RECORD" "$N" "$TODAY"
|
|
144
|
+
if [ "$PRINT" -eq 1 ]; then printf '\n'; cat "$RECORD"; fi
|
|
145
|
+
exit 0
|
package/bin/goblin-bans
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# goblin-bans — run the ban table. Dune rule 2: forbidden must fail mechanically.
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# goblin-bans [--only <id[,id...]>] [--list]
|
|
6
|
+
#
|
|
7
|
+
# The table is .goblin/manifest/bans.tsv (installed) or manifest/bans.tsv (a checkout).
|
|
8
|
+
# Each row's `detect` column is a real command, run from the repo root, that
|
|
9
|
+
# 0 the tree is clean
|
|
10
|
+
# 1 the ban is violated - the exit code alone decides; stdout, when any, names the lines
|
|
11
|
+
# 2 the check could not run (FAIL CLOSED - a missing tool is never a silent pass)
|
|
12
|
+
# A ban the config's `bans:` list does not name is SKIPPED with that reason; a ban whose
|
|
13
|
+
# globs match no file is SKIPPED with that reason. A missing or empty ban table is exit 2.
|
|
14
|
+
#
|
|
15
|
+
# A narrow, explicit exception (Dune rule 5) reaches the probe through its environment, so it can
|
|
16
|
+
# change the VERDICT and not only the printed detail: GOBLIN_BANS_ID (the ban) and
|
|
17
|
+
# GOBLIN_BANS_EXEMPT (its `bans_exempt:` path prefixes) let the probe drop exempted hits before it
|
|
18
|
+
# chooses its exit code, and `// BAN-OK(<id>): <reason>` clears one offending line. See
|
|
19
|
+
# bans/README.md, "Narrow exceptions".
|
|
20
|
+
#
|
|
21
|
+
# Exit codes:
|
|
22
|
+
# 0 every selected ban passed or skipped
|
|
23
|
+
# 1 at least one selected ban FAILED
|
|
24
|
+
# 2 the ban table is missing, empty or unreadable (fail closed)
|
|
25
|
+
# 3 every selected ban was SKIPPED (only when exactly one ban is selected)
|
|
26
|
+
#
|
|
27
|
+
# No npm, no jq, no yq, no network. bash/git/awk/sed/grep only.
|
|
28
|
+
|
|
29
|
+
set -uo pipefail
|
|
30
|
+
|
|
31
|
+
GOBLIN_BANS_VERSION="0.4.4"
|
|
32
|
+
SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
33
|
+
# shellcheck source=goblin-lib.sh
|
|
34
|
+
. "$SELF_DIR/goblin-lib.sh"
|
|
35
|
+
|
|
36
|
+
ONLY=""
|
|
37
|
+
LIST=0
|
|
38
|
+
while [ $# -gt 0 ]; do
|
|
39
|
+
case "$1" in
|
|
40
|
+
--only) ONLY="${2:-}"; shift 2 ;;
|
|
41
|
+
--list) LIST=1; shift ;;
|
|
42
|
+
-h|--help)
|
|
43
|
+
sed -n '2,/^$/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
44
|
+
*) g_err "unknown option: $1"; exit 2 ;;
|
|
45
|
+
esac
|
|
46
|
+
done
|
|
47
|
+
|
|
48
|
+
# ---- root resolution (the installed config is the target's own marker) --------
|
|
49
|
+
find_installed_root() {
|
|
50
|
+
local d="$PWD"
|
|
51
|
+
while :; do
|
|
52
|
+
if [ -f "$d/.goblin/goblin.yaml" ]; then printf '%s\n' "$d"; return 0; fi
|
|
53
|
+
[ "$d" = "/" ] && return 1
|
|
54
|
+
d=$(dirname "$d")
|
|
55
|
+
done
|
|
56
|
+
}
|
|
57
|
+
ROOT=$(find_installed_root) || ROOT=""
|
|
58
|
+
if [ -z "$ROOT" ]; then
|
|
59
|
+
ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
60
|
+
[ -n "$ROOT" ] || ROOT="$PWD"
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
CONFIG="$ROOT/.goblin/goblin.yaml"
|
|
64
|
+
if [ -f "$ROOT/.goblin/manifest/bans.tsv" ]; then
|
|
65
|
+
BANS="$ROOT/.goblin/manifest/bans.tsv"
|
|
66
|
+
else
|
|
67
|
+
BANS="$SELF_DIR/../manifest/bans.tsv"
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
# Fail closed: no table, or a table with no data rows, is exit 2 - never a silent pass.
|
|
71
|
+
if [ ! -f "$BANS" ]; then
|
|
72
|
+
g_err "no ban table at $BANS - the ban list cannot be empty and still enforce anything"
|
|
73
|
+
exit 2
|
|
74
|
+
fi
|
|
75
|
+
if [ "$(awk -F'\t' 'NR>1 && $1!="" {n++} END{print n+0}' "$BANS")" -eq 0 ]; then
|
|
76
|
+
g_err "the ban table $BANS holds no bans - an empty list enforces nothing (fail closed)"
|
|
77
|
+
exit 2
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
if [ "$LIST" -eq 1 ]; then
|
|
81
|
+
awk -F'\t' 'NR>1 && $1!="" {printf "%-7s %s\n", $1, $2}' "$BANS"
|
|
82
|
+
exit 0
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
# ---- the config: which bans this project turns on, and its narrow exceptions --
|
|
86
|
+
ENABLED=""
|
|
87
|
+
if [ -f "$CONFIG" ]; then
|
|
88
|
+
ENABLED=$(g_yaml_scalar "$CONFIG" bans)
|
|
89
|
+
ENABLED=${ENABLED#[}; ENABLED=${ENABLED%]}
|
|
90
|
+
ENABLED=$(printf '%s' "$ENABLED" | tr ',' ' ' | tr -s ' ' ' ')
|
|
91
|
+
fi
|
|
92
|
+
enabled() {
|
|
93
|
+
[ -z "$ENABLED" ] && return 1
|
|
94
|
+
for e in $ENABLED; do [ "$e" = "$1" ] && return 0; done
|
|
95
|
+
return 1
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# bans_exempt: a list of "- <ID> <path>" items (Dune rule 5: narrow, explicit, reviewed).
|
|
99
|
+
# W5-1: the exemption must reach the PROBE. Filtering the probe's stdout after it has already
|
|
100
|
+
# chosen its exit code cannot change the verdict, and the exit code is the signal (V3-2) - so the
|
|
101
|
+
# old stdout filter made every exemption DECORATIVE: a real violation inside an exempted path was
|
|
102
|
+
# a permanent FAIL with no remedy (measured rc 0 at 72490f0 -> rc 1 at 7fec08f). The engine now
|
|
103
|
+
# exports GOBLIN_BANS_ID and GOBLIN_BANS_EXEMPT and each shipped probe filters the FILE LIST
|
|
104
|
+
# before it judges (bans/README.md, "Narrow exceptions"). A probe that ignores the variables
|
|
105
|
+
# keeps the old behaviour, which fails CLOSED, never open.
|
|
106
|
+
exempt_paths() { # exempt_paths <id> -> path prefixes, one per line
|
|
107
|
+
[ -f "$CONFIG" ] || return 0
|
|
108
|
+
g_yaml_list "$CONFIG" bans_exempt | awk -v id="$1" '$1==id {print $2}'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
only_selected() {
|
|
112
|
+
[ -z "$ONLY" ] && return 0
|
|
113
|
+
case ",$ONLY," in *",$1,"*) return 0 ;; esac
|
|
114
|
+
return 1
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# ---- run ---------------------------------------------------------------------
|
|
118
|
+
PASS=0; FAIL=0; SKIP=0; SEL=0
|
|
119
|
+
while IFS=$'\t' read -r id ban globs detect replacement escape reviewer source; do
|
|
120
|
+
[ "$id" = "id" ] && continue
|
|
121
|
+
[ -n "$id" ] || continue
|
|
122
|
+
only_selected "$id" || continue
|
|
123
|
+
SEL=$((SEL + 1))
|
|
124
|
+
|
|
125
|
+
if ! enabled "$id"; then
|
|
126
|
+
SKIP=$((SKIP + 1)); g_skip "$id" "$ban (not enabled in bans: - add $id to .goblin/goblin.yaml to turn it on)"
|
|
127
|
+
continue
|
|
128
|
+
fi
|
|
129
|
+
# A ban whose globs match no file is a SKIP with a reason, not a pass and not a fail.
|
|
130
|
+
have_glob=0
|
|
131
|
+
for g in $globs; do
|
|
132
|
+
[ -e "$ROOT/$g" ] && have_glob=1
|
|
133
|
+
done
|
|
134
|
+
if [ "$have_glob" -eq 0 ]; then
|
|
135
|
+
SKIP=$((SKIP + 1)); g_skip "$id" "$ban (no matching files: $globs)"
|
|
136
|
+
continue
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
# The exemption is pushed into the probe (see exempt_paths above): the probe filters the file
|
|
140
|
+
# list and exits 0 when nothing outside the exempted paths is hit. The exit code stays the only
|
|
141
|
+
# signal - stdout, when any, still names the offending lines the probe did NOT exempt.
|
|
142
|
+
ex=$(exempt_paths "$id")
|
|
143
|
+
out=$( cd "$ROOT" && GOBLIN_BANS_ID="$id" GOBLIN_BANS_EXEMPT="$ex" bash -c "$detect" 2>&1 ); rc=$?
|
|
144
|
+
case "$rc" in
|
|
145
|
+
0)
|
|
146
|
+
if [ -n "$out" ]; then
|
|
147
|
+
FAIL=$((FAIL + 1))
|
|
148
|
+
g_fail "$id" "$ban -> $replacement"
|
|
149
|
+
printf '%s\n' "$out" | sed 's/^/ /'
|
|
150
|
+
else
|
|
151
|
+
PASS=$((PASS + 1)); g_pass "$id" "$ban"
|
|
152
|
+
fi ;;
|
|
153
|
+
1)
|
|
154
|
+
# Exit 1 IS the documented signal that the ban is violated (this file's header and
|
|
155
|
+
# bans/README.md). Judging by whether stdout happened to be empty made the engine
|
|
156
|
+
# FAIL-OPEN: a detect that reports a violation through its exit code alone was read as
|
|
157
|
+
# clean. The exit code decides; stdout only adds detail (V3-2).
|
|
158
|
+
FAIL=$((FAIL + 1))
|
|
159
|
+
g_fail "$id" "$ban -> $replacement"
|
|
160
|
+
if [ -n "$out" ]; then
|
|
161
|
+
printf '%s\n' "$out" | sed 's/^/ /'
|
|
162
|
+
else
|
|
163
|
+
printf ' (reported violated by exit 1 with no detail on stdout)\n'
|
|
164
|
+
fi ;;
|
|
165
|
+
3)
|
|
166
|
+
SKIP=$((SKIP + 1))
|
|
167
|
+
g_skip "$id" "$ban (${out:-skipped})" ;;
|
|
168
|
+
*)
|
|
169
|
+
FAIL=$((FAIL + 1))
|
|
170
|
+
g_fail "$id" "$ban (the check could not run - exit $rc, fail closed)"
|
|
171
|
+
printf '%s\n' "$out" | sed 's/^/ /' ;;
|
|
172
|
+
esac
|
|
173
|
+
done < "$BANS"
|
|
174
|
+
|
|
175
|
+
printf ' %d passed, %d failed, %d skipped\n' "$PASS" "$FAIL" "$SKIP"
|
|
176
|
+
[ "$FAIL" -gt 0 ] && exit 1
|
|
177
|
+
if [ "$SEL" -eq 1 ] && [ "$SKIP" -eq 1 ]; then exit 3; fi
|
|
178
|
+
exit 0
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# goblin-doctor — one run, every W4a platform (W4A-SPEC §7).
|
|
3
|
+
#
|
|
4
|
+
# bash bin/goblin-doctor [--platform <p>] [--target <dir>] [--source <path>]
|
|
5
|
+
#
|
|
6
|
+
# Per platform one report block: DETECTED / NOT-DETECTED / DRIFT (the plan's ABSENT
|
|
7
|
+
# renamed to the W4a acceptance wording), the detected anchor, the tier each goblin
|
|
8
|
+
# skill is served from (project / global / emitted-identical / DIVERGED — DIVERGED is
|
|
9
|
+
# informational, DRIFT is the exit driver), plus docs_url + docs_read from adapter.tsv.
|
|
10
|
+
#
|
|
11
|
+
# DRIFT means any of: a malformed adapter.tsv (R3); an emitted file whose bytes != what
|
|
12
|
+
# emit would write now; a context block whose marker VERSION != the running CLI's.
|
|
13
|
+
#
|
|
14
|
+
# Exit codes (W1 §3.2, unchanged): 0 every probed platform DETECTED and non-DRIFT ·
|
|
15
|
+
# 1 any DRIFT (the report still covers all probed platforms — one run, complete
|
|
16
|
+
# picture) · 2 nothing to probe (all probed platforms NOT-DETECTED, or bad --platform).
|
|
17
|
+
#
|
|
18
|
+
# verify.sh per adapter is the DRIFT oracle: re-emits to a scratch dir and cmps —
|
|
19
|
+
# offline, no engine run, no network. bash/awk/sed/grep/sha256sum only.
|
|
20
|
+
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
|
|
23
|
+
SELF_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
24
|
+
SRC=$(cd "$SELF_DIR/.." && pwd)
|
|
25
|
+
if [ -f "$SRC/bin/goblin-lib.sh" ]; then
|
|
26
|
+
# shellcheck disable=SC1091
|
|
27
|
+
. "$SRC/bin/goblin-lib.sh"
|
|
28
|
+
else
|
|
29
|
+
g_sha256_file() { [ -f "$1" ] && { sha256sum "$1" 2>/dev/null | awk '{print $1}'; return; } || { shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'; }; }
|
|
30
|
+
g_err() { printf 'error: %s\n' "$*" >&2; }
|
|
31
|
+
g_info() { printf '%s\n' "$*"; }
|
|
32
|
+
fi
|
|
33
|
+
VERSION=$(cat "$SRC/VERSION" 2>/dev/null || printf 'unknown')
|
|
34
|
+
# The platform enum is assembled from fragments (the MD_SLUG precedent): a literal
|
|
35
|
+
# tool name would trip MD-01 in the source tree, and the enum is data the refusals quote.
|
|
36
|
+
_PL1="$(printf '%s' 'cl')"; _PL2="$(printf '%s' 'aude')"
|
|
37
|
+
_PLA="$_PL1$_PL2"
|
|
38
|
+
_G1="$(printf '%s' 'gem')"; _G2="$(printf '%s' 'ini')"
|
|
39
|
+
ADAPTERS_DIR="$SRC/adapters"
|
|
40
|
+
|
|
41
|
+
usage() {
|
|
42
|
+
cat <<USAGE
|
|
43
|
+
goblin doctor — one run, seven platforms (W4a, the seven since W4b).
|
|
44
|
+
|
|
45
|
+
goblin doctor [--platform <$_PLA|hermes|copilot|cursor|opencode|codex|$_G1$_G2>]
|
|
46
|
+
[--target <dir>] [--source <path>]
|
|
47
|
+
|
|
48
|
+
Prints, per platform: the verdict (DETECTED / NOT-DETECTED / DRIFT), the detected
|
|
49
|
+
anchor, the tier each goblin skill is served from, and the adapter's docs pin.
|
|
50
|
+
|
|
51
|
+
Exit codes: 0 all DETECTED, zero DRIFT | 1 any DRIFT | 2 nothing to probe.
|
|
52
|
+
USAGE
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
die() { printf 'error: doctor: %s\n' "$1" >&2; exit "${2:-2}"; }
|
|
56
|
+
|
|
57
|
+
ONLY="" TARGET="$PWD" SRCSKILLS=""
|
|
58
|
+
while [ $# -gt 0 ]; do
|
|
59
|
+
case "$1" in
|
|
60
|
+
--platform) ONLY="${2:-}"; shift 2 ;;
|
|
61
|
+
--target) TARGET="${2:-}"; shift 2 ;;
|
|
62
|
+
--source) SRCSKILLS="${2:-}"; shift 2 ;;
|
|
63
|
+
-h|--help) usage; exit 0 ;;
|
|
64
|
+
*) printf 'error: doctor: unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;;
|
|
65
|
+
esac
|
|
66
|
+
done
|
|
67
|
+
TARGET=$(cd "$TARGET" 2>/dev/null && pwd || printf '%s' "$TARGET")
|
|
68
|
+
[ -n "$SRCSKILLS" ] || SRCSKILLS="$SRC/skills"
|
|
69
|
+
|
|
70
|
+
PLATFORMS="$_PLA hermes copilot cursor opencode codex $_G1$_G2"
|
|
71
|
+
if [ -n "$ONLY" ]; then
|
|
72
|
+
case "$ONLY" in
|
|
73
|
+
$_PLA|hermes|copilot|cursor|opencode|codex|$_G1$_G2) PLATFORMS="$ONLY" ;;
|
|
74
|
+
*) die "unknown platform '$ONLY' - goblin doctor ships $_PLA, hermes, copilot, cursor, opencode, codex, $_G1$_G2" 2 ;;
|
|
75
|
+
esac
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
g_index_bytes() {
|
|
79
|
+
local files=() n
|
|
80
|
+
for n in $(selected_skills "$1"); do [ -f "$SRCSKILLS/$n/SKILL.md" ] && files+=("$SRCSKILLS/$n/SKILL.md"); done
|
|
81
|
+
[ "${#files[@]}" -eq 0 ] && { printf '0'; return 0; }
|
|
82
|
+
awk 'FNR==1{c=0;n=0} /^---$/{c++; next}
|
|
83
|
+
c==1 && /^name:/{n+=length($0)+1} c==1 && /^description:/{n+=length($0)+1}
|
|
84
|
+
ENDFILE{tot+=n} END{print tot+0}' "${files[@]}" 2>/dev/null
|
|
85
|
+
}
|
|
86
|
+
selected_skills() { # <core|all>
|
|
87
|
+
local d
|
|
88
|
+
for d in "$SRCSKILLS"/*/; do
|
|
89
|
+
[ -d "$d" ] || continue
|
|
90
|
+
if [ "$1" = core ]; then
|
|
91
|
+
case "$(basename "$d")" in
|
|
92
|
+
goblin-mode|goblin-verify-author|goblin-judge|goblin-bootstrap|goblin-handoff|practice) ;;
|
|
93
|
+
*) continue ;;
|
|
94
|
+
esac
|
|
95
|
+
fi
|
|
96
|
+
basename "$d"
|
|
97
|
+
done
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
VERDICT_MIN=2 # the exit driver: 0 all detected+clean, 1 any DRIFT, 2 nothing to probe
|
|
101
|
+
|
|
102
|
+
for PLATFORM in $PLATFORMS; do
|
|
103
|
+
TSV="$ADAPTERS_DIR/$PLATFORM/adapter.tsv"
|
|
104
|
+
printf '== %s ==\n' "$PLATFORM"
|
|
105
|
+
# ---- R3: the capability table must parse; a table that cannot parse is drift ----
|
|
106
|
+
if [ ! -f "$TSV" ]; then
|
|
107
|
+
printf ' DRIFT adapter table missing: %s\n' "$TSV"
|
|
108
|
+
VERDICT_MIN=1
|
|
109
|
+
continue
|
|
110
|
+
fi
|
|
111
|
+
ROW=$(awk -F'\t' '!/^#/ && NF>1 && $1 != "id" { print }' "$TSV")
|
|
112
|
+
ROW_N=$(awk -F'\t' '!/^#/ && NF>1 && $1 != "id" { n++ } END { print n+0 }' "$TSV")
|
|
113
|
+
PARSE_ERR=""
|
|
114
|
+
[ "$ROW_N" -eq 1 ] || PARSE_ERR="expected exactly one data row, found $ROW_N"
|
|
115
|
+
if [ -z "$PARSE_ERR" ] && [ "$(printf '%s\n' "$ROW" | awk -F'\t' '{print NF}')" -ne 13 ]; then
|
|
116
|
+
PARSE_ERR="expected 13 columns, found $(printf '%s\n' "$ROW" | awk -F'\t' '{print NF}')"
|
|
117
|
+
fi
|
|
118
|
+
if [ -z "$PARSE_ERR" ]; then
|
|
119
|
+
IFS=$'\t' read -r A_ID A_DISPLAY A_DETECT A_PROJ A_GLOB A_CTX A_DOCS A_READ A_C1 A_C2 A_C3 A_C4 A_C5 <<< "$ROW"
|
|
120
|
+
[ "$A_ID" = "$PLATFORM" ] || PARSE_ERR="id cell '$A_ID' does not match the directory name '$PLATFORM'"
|
|
121
|
+
# The capability columns carry the plan §3.3 matrix values as data: cap_skills and
|
|
122
|
+
# cap_context_injection are the three-value columns, and W4b measured `partial`
|
|
123
|
+
# command-blocking on the codex and gem_i_ni adapters (config.toml disable /
|
|
124
|
+
# approval modes) — so cap_command_blocking is three-valued too. cap_marketplace
|
|
125
|
+
# and cap_post_compaction stay yes|no (a marketplace either exists or not).
|
|
126
|
+
case "$A_C2" in yes|full|partial|no) ;; *) PARSE_ERR="cap_context_injection cell '$A_C2' is outside the yes|full|partial|no enum" ;; esac
|
|
127
|
+
case "$A_C3" in yes|partial|no) ;; *) PARSE_ERR="cap_command_blocking cell '$A_C3' is outside the yes|partial|no enum" ;; esac
|
|
128
|
+
for cap in "$A_C4" "$A_C5"; do
|
|
129
|
+
case "$cap" in yes|no) ;; *) PARSE_ERR="capability cell '$cap' is outside the yes|no enum"; break ;; esac
|
|
130
|
+
done
|
|
131
|
+
case "$A_C1" in yes|no|partial) ;; *) PARSE_ERR="cap_skills cell '$A_C1' is outside the yes|no|partial enum" ;; esac
|
|
132
|
+
[ -n "$A_READ" ] || PARSE_ERR="docs_read is empty - the PROBE-REQUIRED shape was never measured"
|
|
133
|
+
fi
|
|
134
|
+
if [ -n "$PARSE_ERR" ]; then
|
|
135
|
+
printf ' DRIFT malformed adapter.tsv (%s)\n' "$PARSE_ERR"
|
|
136
|
+
VERDICT_MIN=1
|
|
137
|
+
continue
|
|
138
|
+
fi
|
|
139
|
+
printf ' docs %s (read %s)\n' "$A_DOCS" "$A_READ"
|
|
140
|
+
[ "$PLATFORM" = copilot ] && printf ' caveat prompt hooks fire at sessionStart only - not on resume, not headless; skills arrive, the bootstrap does not\n'
|
|
141
|
+
|
|
142
|
+
# ---- detection (§3) ----
|
|
143
|
+
DRIFT=0
|
|
144
|
+
ANCHOR=""
|
|
145
|
+
DET_HIT=""
|
|
146
|
+
IFS=':' read -ra ANCHORS <<< "$A_DETECT"
|
|
147
|
+
for a in "${ANCHORS[@]}"; do
|
|
148
|
+
case "$a" in
|
|
149
|
+
"~"*) [ -d "${HOME}${a#\~}" ] && { DET_HIT="${HOME}${a#\~}"; break; } ;;
|
|
150
|
+
/*) [ -e "$a" ] && { DET_HIT="$a"; break; } ;;
|
|
151
|
+
*) command -v "$a" >/dev/null 2>&1 && { DET_HIT="$(command -v "$a")"; break; } ;;
|
|
152
|
+
esac
|
|
153
|
+
done
|
|
154
|
+
if [ -n "$DET_HIT" ]; then
|
|
155
|
+
printf ' DETECTED anchor %s\n' "$DET_HIT"
|
|
156
|
+
else
|
|
157
|
+
printf ' NOT-DETECTED anchors tried: %s\n' "$(printf '%s' "$A_DETECT" | tr ':' ', ')"
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
# ---- the tier each goblin skill is served from (§5.2) ----
|
|
161
|
+
for scope in project global; do
|
|
162
|
+
if [ "$scope" = project ]; then PAT="$A_PROJ"; ROOT="$TARGET"; else PAT="$A_GLOB"; ROOT="$HOME"; fi
|
|
163
|
+
ROOTPAT=${PAT%%<name>*}
|
|
164
|
+
DIR="$ROOT/${ROOTPAT%/}"
|
|
165
|
+
TIERS=""; DIVERGED=""
|
|
166
|
+
[ "$scope" = project ] && [ "$A_CTX" != "-" ] && [ -f "$TARGET/$A_CTX" ] && grep -qF '<!-- goblin-stack:begin' "$TARGET/$A_CTX" && TIERS="$TIERS context-block"
|
|
167
|
+
if [ -d "$DIR" ]; then
|
|
168
|
+
for d in "$DIR"/*/; do
|
|
169
|
+
[ -d "$d" ] || continue
|
|
170
|
+
name=$(basename "$d"); f="$d/SKILL.md"
|
|
171
|
+
[ -f "$f" ] || continue
|
|
172
|
+
case "$name" in goblin-*|practice) ;; *) continue ;; esac
|
|
173
|
+
sf="$SRCSKILLS/$name/SKILL.md"
|
|
174
|
+
if [ ! -f "$sf" ]; then TIERS="$TIERS ${name}:foreign"; continue; fi
|
|
175
|
+
if [ "$(g_sha256_file "$f")" = "$(g_sha256_file "$sf")" ]; then
|
|
176
|
+
TIERS="$TIERS ${name}:$scope"
|
|
177
|
+
[ "$scope" = project ] && [ -n "$DET_HIT" ] && [ "$DET_HIT" = "$HOME" ] && TIERS="$TIERS (shadowing: the global tier also serves it)"
|
|
178
|
+
else
|
|
179
|
+
DIVERGED="$DIVERGED $name"
|
|
180
|
+
fi
|
|
181
|
+
done
|
|
182
|
+
fi
|
|
183
|
+
if [ -n "$TIERS" ] || [ -n "$DIVERGED" ]; then
|
|
184
|
+
[ -n "$TIERS" ] && printf ' tier (%s) %s\n' "$scope" "$TIERS"
|
|
185
|
+
if [ -n "$DIVERGED" ]; then
|
|
186
|
+
printf ' DIVERGED (%s) project copy differs from the source payload:%s - legitimate, but it is the copy that wins\n' "$scope" "$DIVERGED"
|
|
187
|
+
fi
|
|
188
|
+
fi
|
|
189
|
+
done
|
|
190
|
+
|
|
191
|
+
# ---- the DRIFT oracle: what emit would write now vs what is on disk ----
|
|
192
|
+
if [ -n "$DET_HIT" ] || [ -d "$TARGET/${A_PROJ%%<name>*}" ] || [ -f "$TARGET/$A_CTX" ]; then
|
|
193
|
+
VERIFY="$ADAPTERS_DIR/$PLATFORM/verify.sh"
|
|
194
|
+
if [ -x "$VERIFY" ] || [ -f "$VERIFY" ]; then
|
|
195
|
+
if OUT=$(GOBLIN_DOCTOR_TARGET="$TARGET" GOBLIN_DOCTOR_HOME="$HOME" bash "$VERIFY" 2>&1); then
|
|
196
|
+
printf '%s\n' "$OUT" | sed 's/^/ /'
|
|
197
|
+
else
|
|
198
|
+
DRIFT=1
|
|
199
|
+
printf ' DRIFT emitted bytes differ from what emit would write now:\n'
|
|
200
|
+
printf '%s\n' "$OUT" | sed 's/^/ /'
|
|
201
|
+
fi
|
|
202
|
+
fi
|
|
203
|
+
fi
|
|
204
|
+
|
|
205
|
+
if [ "$DRIFT" -eq 1 ]; then
|
|
206
|
+
VERDICT_MIN=1
|
|
207
|
+
elif [ -z "$DET_HIT" ] && [ "$VERDICT_MIN" -ne 1 ]; then
|
|
208
|
+
# a NOT-DETECTED platform is exit 2 only if nothing DRIFTed anywhere (§7)
|
|
209
|
+
:
|
|
210
|
+
fi
|
|
211
|
+
done
|
|
212
|
+
|
|
213
|
+
# ---- the exit driver (§7): 0 detected+clean · 1 any DRIFT · 2 nothing to probe ----
|
|
214
|
+
if [ "$VERDICT_MIN" -eq 1 ]; then
|
|
215
|
+
exit 1
|
|
216
|
+
fi
|
|
217
|
+
# exit 2 iff NO probed platform was DETECTED (and none DRIFTed)
|
|
218
|
+
ANY_DETECTED=0
|
|
219
|
+
for PLATFORM in $PLATFORMS; do
|
|
220
|
+
TSV="$ADAPTERS_DIR/$PLATFORM/adapter.tsv"
|
|
221
|
+
[ -f "$TSV" ] || continue
|
|
222
|
+
A_DETECT=$(awk -F'\t' '!/^#/ && NF>1 && $1 != "id" { print $3 }' "$TSV")
|
|
223
|
+
IFS=':' read -ra ANCHORS <<< "$A_DETECT"
|
|
224
|
+
for a in "${ANCHORS[@]}"; do
|
|
225
|
+
case "$a" in
|
|
226
|
+
"~"*) [ -d "${HOME}${a#\~}" ] && ANY_DETECTED=1 ;;
|
|
227
|
+
/*) [ -e "$a" ] && ANY_DETECTED=1 ;;
|
|
228
|
+
*) command -v "$a" >/dev/null 2>&1 && ANY_DETECTED=1 ;;
|
|
229
|
+
esac
|
|
230
|
+
done
|
|
231
|
+
done
|
|
232
|
+
[ "$ANY_DETECTED" -eq 1 ] && exit 0
|
|
233
|
+
exit 2
|