signoff-cli 0.0.0-stage → 4.0.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 (57) hide show
  1. package/.pre-commit-hooks.yaml +27 -0
  2. package/CHANGELOG.md +336 -0
  3. package/LICENSE +21 -0
  4. package/README.md +540 -3
  5. package/action.yml +24 -0
  6. package/bin/signoff.js +218 -0
  7. package/docs/diagrams/README.md +41 -0
  8. package/docs/diagrams/signoff-1-capability-en.png +0 -0
  9. package/docs/diagrams/signoff-1-capability.png +0 -0
  10. package/docs/diagrams/signoff-2-market-en.png +0 -0
  11. package/docs/diagrams/signoff-2-market.png +0 -0
  12. package/docs/diagrams/signoff-3-user-en.png +0 -0
  13. package/docs/diagrams/signoff-3-user.png +0 -0
  14. package/docs/diagrams/signoff-4-flow-en.png +0 -0
  15. package/docs/diagrams/signoff-4-flow.png +0 -0
  16. package/docs/diagrams/signoff-5-competitors-en.png +0 -0
  17. package/docs/diagrams/signoff-5-competitors.png +0 -0
  18. package/docs/diagrams/src/signoff-1-capability-en.mmd +81 -0
  19. package/docs/diagrams/src/signoff-1-capability.mmd +84 -0
  20. package/docs/diagrams/src/signoff-2-market-en.mmd +72 -0
  21. package/docs/diagrams/src/signoff-2-market.mmd +72 -0
  22. package/docs/diagrams/src/signoff-3-user-en.mmd +94 -0
  23. package/docs/diagrams/src/signoff-3-user.mmd +92 -0
  24. package/docs/gates.en.md +105 -0
  25. package/docs/gates.md +88 -0
  26. package/docs/integrations.md +58 -0
  27. package/docs/promotion.md +71 -0
  28. package/docs/signoff.schema.json +108 -0
  29. package/docs/trust.md +72 -0
  30. package/gates/guard.py +474 -0
  31. package/gates/p1_verify.py +111 -0
  32. package/gates/p2_coverage.py +127 -0
  33. package/gates/p3_queue.py +186 -0
  34. package/gates/p4_ledger.py +249 -0
  35. package/gates/p5_slop.py +109 -0
  36. package/lib/audit.js +213 -0
  37. package/lib/bootstrap.js +361 -0
  38. package/lib/checkpoint.js +184 -0
  39. package/lib/ci.js +58 -0
  40. package/lib/core.js +120 -0
  41. package/lib/cov.js +253 -0
  42. package/lib/detect.js +169 -0
  43. package/lib/doctor.js +304 -0
  44. package/lib/editor.js +97 -0
  45. package/lib/explain.js +73 -0
  46. package/lib/guard.js +1229 -0
  47. package/lib/p1.js +469 -0
  48. package/lib/p2.js +323 -0
  49. package/lib/p3.js +633 -0
  50. package/lib/p4.js +733 -0
  51. package/lib/p5.js +438 -0
  52. package/lib/preset.js +66 -0
  53. package/lib/secrets.js +336 -0
  54. package/lib/sign.js +196 -0
  55. package/package.json +55 -6
  56. package/test/selftest.js +1226 -0
  57. package/test/selftest.py +190 -0
package/lib/secrets.js ADDED
@@ -0,0 +1,336 @@
1
+ "use strict";
2
+ // Secret scanning for the commit gate.
3
+ //
4
+ // Why this exists: commit hooks that only run tests leave the most expensive
5
+ // accident untouched - a key pushed to a public remote. The pattern set below is
6
+ // deliberately small and high-signal (a hit is a hit), and the engine is plain
7
+ // Node so `npm i -g dsh-signoff` needs no extra binary. When gitleaks is on PATH
8
+ // it can be used instead (--engine gitleaks|auto) for the full rule set.
9
+
10
+ const fs = require("fs");
11
+ const os = require("os");
12
+ const path = require("path");
13
+ const { spawnSync } = require("child_process");
14
+ const core = require("./core");
15
+
16
+ const USAGE =
17
+ "usage: signoff secrets [-h] [--staged|--all] [--engine auto|builtin|gitleaks] [--allow RULE] [--json] [--quiet]";
18
+ const HELP =
19
+ USAGE +
20
+ "\n\n" +
21
+ [
22
+ "options:",
23
+ " -h, --help show this help message and exit",
24
+ " --staged scan the git index (default)",
25
+ " --all scan every tracked text file",
26
+ " --engine E auto (default), builtin, or gitleaks when installed",
27
+ " --allow RULE rule id to ignore (repeatable)",
28
+ " --json print one JSON line",
29
+ " --quiet no per-finding lines",
30
+ " --no-state do not write .signoff/secrets.json (the machine-wide guard",
31
+ " runs in repos that never opted in and must leave no state)",
32
+ ].join("\n") +
33
+ "\n";
34
+
35
+ const MAX_BYTES = 1024 * 1024;
36
+ const SKIP_DIRS = /(^|\/)(node_modules|\.git|dist|build|vendor|\.venv|venv|__pycache__|target)(\/|$)/;
37
+ const SKIP_FILES = /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb|go\.sum|Cargo\.lock|composer\.lock|poetry\.lock|\.DS_Store)$/;
38
+ const BINARY_EXT = /\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|tgz|bz2|xz|7z|rar|jar|war|exe|dll|so|dylib|bin|wasm|mp3|mp4|mov|avi|wav|woff2?|ttf|otf|eot|class|pyc|o|a)$/i;
39
+ const IGNORE_LINE = /signoff-ignore-secret|pragma:\s*allowlist\s*secret/i;
40
+
41
+ // Placeholder values that look like credentials but are documentation.
42
+ const PLACEHOLDER = /^(?:x{3,}|\*{3,}|\.{3,}|<[^>]*>|\$\{[^}]*\}|(?:your|example|sample|dummy|fake|test|change[_-]?me|placeholder|redacted|todo|none|null|undefined|password|passwd|secret|token|apikey|api[_-]?key)[\w.-]*)$/i;
43
+
44
+ // shannon entropy over the candidate value; random keys score high, words low.
45
+ function entropy(s) {
46
+ const freq = new Map();
47
+ for (const ch of s) freq.set(ch, (freq.get(ch) || 0) + 1);
48
+ let h = 0;
49
+ for (const n of freq.values()) {
50
+ const p = n / s.length;
51
+ h -= p * Math.log2(p);
52
+ }
53
+ return h;
54
+ }
55
+
56
+ const RULES = [
57
+ { id: "aws-access-key", re: /\b(?:AKIA|ASIA|AGPA|AIDA)[0-9A-Z]{16}\b/g },
58
+ { id: "aws-secret-key", re: /aws_?secret_?access_?key["']?\s*[:=]\s*["']?[A-Za-z0-9/+=]{40}\b/gi },
59
+ { id: "github-pat", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
60
+ { id: "github-fine-grained", re: /\bgithub_pat_[A-Za-z0-9_]{50,}\b/g },
61
+ { id: "gitlab-pat", re: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
62
+ { id: "openai-key", re: /\bsk-(?:proj-|ant-)?[A-Za-z0-9_-]{32,}\b/g },
63
+ { id: "google-api-key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
64
+ { id: "slack-token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g },
65
+ { id: "stripe-live-key", re: /\bsk_live_[A-Za-z0-9]{20,}\b/g },
66
+ { id: "npm-token", re: /\bnpm_[A-Za-z0-9]{36}\b/g },
67
+ { id: "sendgrid-key", re: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g },
68
+ { id: "private-key", re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g },
69
+ { id: "azure-storage-key", re: /AccountKey=[A-Za-z0-9+/=]{80,}/g },
70
+ { id: "discord-webhook", re: /https:\/\/discord(?:app)?\.com\/api\/webhooks\/\d{5,}\/[A-Za-z0-9_-]{20,}/g },
71
+ { id: "db-url-with-password", re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s/@]+:[^@\s/]{6,}@/gi },
72
+ { id: "generic-credential", re: /(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key)\s*[:=]\s*["']([^"'\s]{12,})["']/gi, value: 1, entropy: 3.2 },
73
+ ];
74
+
75
+ function rule(id) {
76
+ return RULES.find((r) => r.id === id) || null;
77
+ }
78
+
79
+ // One line of text -> findings. Kept pure so tests can call it directly.
80
+ function scanText(text, file) {
81
+ const out = [];
82
+ const lines = String(text).split(/\r\n|\n|\r/);
83
+ for (let i = 0; i < lines.length; i++) {
84
+ const line = lines[i];
85
+ if (!line || line.length > 4000) continue;
86
+ if (IGNORE_LINE.test(line)) continue;
87
+ for (const r of RULES) {
88
+ r.re.lastIndex = 0;
89
+ let m;
90
+ while ((m = r.re.exec(line)) !== null) {
91
+ if (m[0].length === 0) {
92
+ r.re.lastIndex++;
93
+ continue;
94
+ }
95
+ const value = r.value ? m[r.value] : m[0];
96
+ if (PLACEHOLDER.test(String(value).trim())) continue;
97
+ if (r.entropy && entropy(String(value)) < r.entropy) continue;
98
+ out.push({ file, line: i + 1, rule: r.id, match: mask(String(value)) });
99
+ break; // one hit per rule per line is enough to fail the commit
100
+ }
101
+ }
102
+ }
103
+ return out;
104
+ }
105
+
106
+ // Never print the secret itself: the log is committed-adjacent and often pasted.
107
+ function mask(s) {
108
+ const keep = s.length <= 8 ? 2 : 4;
109
+ return s.slice(0, keep) + "…" + s.slice(-2);
110
+ }
111
+
112
+ function readConfig(root) {
113
+ const cfg = core.readJSON(path.join(root, ".signoff", "config.json"), {}) || {};
114
+ return cfg.secrets && typeof cfg.secrets === "object" ? cfg.secrets : {};
115
+ }
116
+
117
+ // Allow entries: "rule", "rule:path/suffix", or {rule, file} objects.
118
+ function allowEntries(root) {
119
+ const cfg = readConfig(root);
120
+ const raw = [].concat(cfg.allow || []);
121
+ const fileCfg = core.readJSON(path.join(root, ".signoff", "secrets-allow.json"), []);
122
+ for (const e of [].concat(fileCfg || [])) raw.push(e);
123
+ return raw
124
+ .map((e) => {
125
+ if (typeof e === "string") {
126
+ const i = e.indexOf(":");
127
+ return i === -1 ? { rule: e, file: null } : { rule: e.slice(0, i), file: e.slice(i + 1) };
128
+ }
129
+ if (e && typeof e === "object") return { rule: String(e.rule || ""), file: e.file ? String(e.file) : null };
130
+ return null;
131
+ })
132
+ .filter(Boolean);
133
+ }
134
+
135
+ function allowed(finding, allow) {
136
+ return allow.some((a) => {
137
+ if (a.rule !== finding.rule && a.rule !== "*") return false;
138
+ if (!a.file) return true;
139
+ return finding.file === a.file || finding.file.endsWith(a.file);
140
+ });
141
+ }
142
+
143
+ function skipped(file) {
144
+ return SKIP_DIRS.test(file) || SKIP_FILES.test(file) || BINARY_EXT.test(file);
145
+ }
146
+
147
+ function readMaybe(root, file) {
148
+ const p = path.join(root, file);
149
+ try {
150
+ const st = fs.statSync(p);
151
+ if (!st.isFile() || st.size > MAX_BYTES) return null;
152
+ return fs.readFileSync(p, "utf8");
153
+ } catch (e) {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ function stagedFiles(root) {
159
+ const r = core.git(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], root);
160
+ return r.out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
161
+ }
162
+
163
+ function trackedFiles(root) {
164
+ const r = core.git(["ls-files"], root);
165
+ return r.out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
166
+ }
167
+
168
+ function gitleaksAvailable() {
169
+ const r = spawnSync("gitleaks", ["version"], { encoding: "utf8", windowsHide: true, timeout: 5000 });
170
+ return r.status === 0;
171
+ }
172
+
173
+ // gitleaks writes a JSON report; map it onto our finding shape so the caller does
174
+ // not care which engine produced the verdict.
175
+ function runGitleaks(root, files) {
176
+ const out = path.join(os.tmpdir(), `signoff-gitleaks-${process.pid}.json`);
177
+ const r = spawnSync(
178
+ "gitleaks",
179
+ ["protect", "--staged", "--redact", "--no-banner", "--report-format", "json", "--report-path", out],
180
+ { cwd: root, encoding: "utf8", windowsHide: true }
181
+ );
182
+ let raw = [];
183
+ try {
184
+ raw = JSON.parse(fs.readFileSync(out, "utf8"));
185
+ } catch (e) {
186
+ raw = [];
187
+ }
188
+ try {
189
+ fs.unlinkSync(out);
190
+ } catch (e) {
191
+ /* temp file already gone */
192
+ }
193
+ if (r.status === null) return { engine: "gitleaks", error: "gitleaks could not start", findings: [] };
194
+ return {
195
+ engine: "gitleaks",
196
+ findings: (Array.isArray(raw) ? raw : []).map((h) => ({
197
+ file: String(h.File || ""),
198
+ line: Number(h.StartLine || 0),
199
+ rule: String(h.RuleID || "gitleaks"),
200
+ match: String(h.Match ? mask(String(h.Match)) : ""),
201
+ })),
202
+ staged: files,
203
+ };
204
+ }
205
+
206
+ // opts: {mode: "staged"|"all", engine, allow: [rule], quiet, files}
207
+ function scan(root, opts) {
208
+ const o = opts || {};
209
+ // An empty --file list means "derive from git", not "scan nothing".
210
+ const given = Array.isArray(o.files) && o.files.length ? o.files : null;
211
+ const files = (given || (o.mode === "all" ? trackedFiles(root) : stagedFiles(root))).filter((f) => !skipped(f));
212
+ const engine = o.engine || "auto";
213
+ if (engine !== "builtin" && (engine === "gitleaks" || gitleaksAvailable())) {
214
+ if (engine === "gitleaks" && !gitleaksAvailable()) {
215
+ return { engine: "gitleaks", available: false, files, findings: [], error: "gitleaks not found on PATH" };
216
+ }
217
+ const res = runGitleaks(root, files);
218
+ const allow = allowEntries(root).concat((o.allow || []).map((r) => ({ rule: r, file: null })));
219
+ res.findings = res.findings.filter((f) => !allowed(f, allow));
220
+ return Object.assign({ available: true }, res);
221
+ }
222
+ const allow = allowEntries(root).concat((o.allow || []).map((r) => ({ rule: r, file: null })));
223
+ const findings = [];
224
+ for (const f of files) {
225
+ const text = readMaybe(root, f);
226
+ if (text === null) continue;
227
+ for (const hit of scanText(text, f)) if (!allowed(hit, allow)) findings.push(hit);
228
+ }
229
+ return { engine: "builtin", available: true, files, findings };
230
+ }
231
+
232
+ function parse(argv) {
233
+ const a = { mode: "staged", engine: "auto", allow: [], json: false, quiet: false, noState: false, help: false, files: [] };
234
+ const take = (i, tok) => {
235
+ const eq = tok.indexOf("=");
236
+ if (eq !== -1) return [tok.slice(eq + 1), i];
237
+ if (i + 1 >= argv.length || String(argv[i + 1]).startsWith("--")) return [null, i];
238
+ return [String(argv[i + 1]), i + 1];
239
+ };
240
+ for (let i = 0; i < argv.length; i++) {
241
+ const tok = String(argv[i]);
242
+ if (tok === "-h" || tok === "--help") {
243
+ a.help = true;
244
+ continue;
245
+ }
246
+ if (tok === "--staged") {
247
+ a.mode = "staged";
248
+ continue;
249
+ }
250
+ if (tok === "--all") {
251
+ a.mode = "all";
252
+ continue;
253
+ }
254
+ if (tok === "--json") {
255
+ a.json = true;
256
+ continue;
257
+ }
258
+ if (tok === "--quiet") {
259
+ a.quiet = true;
260
+ continue;
261
+ }
262
+ if (tok === "--no-state") {
263
+ a.noState = true;
264
+ continue;
265
+ }
266
+ const [v, ni] = take(i, tok);
267
+ if (tok === "--engine" || tok.startsWith("--engine=")) {
268
+ if (v === null) return { error: "argument --engine: expected one argument" };
269
+ i = ni;
270
+ if (!["auto", "builtin", "gitleaks"].includes(v)) return { error: `argument --engine: invalid choice: '${v}'` };
271
+ a.engine = v;
272
+ continue;
273
+ }
274
+ if (tok === "--allow" || tok.startsWith("--allow=")) {
275
+ if (v === null) return { error: "argument --allow: expected one argument" };
276
+ i = ni;
277
+ a.allow.push(v);
278
+ continue;
279
+ }
280
+ if (tok.startsWith("-") && tok.length > 1) return { error: `unrecognized arguments: ${tok}` };
281
+ a.files.push(tok);
282
+ }
283
+ return a;
284
+ }
285
+
286
+ function run(argv) {
287
+ const a = parse(argv || []);
288
+ if (a.error) {
289
+ process.stderr.write(USAGE + "\n" + `signoff secrets: error: ${a.error}\n`);
290
+ return 2;
291
+ }
292
+ if (a.help) {
293
+ process.stdout.write(HELP);
294
+ return 0;
295
+ }
296
+ const root = core.git(["rev-parse", "--show-toplevel"]).out || process.cwd();
297
+ const res = scan(root, { mode: a.mode, engine: a.engine, allow: a.allow, files: a.files });
298
+ if (res.error) {
299
+ process.stderr.write(`signoff secrets: ${res.error}\n`);
300
+ return 2;
301
+ }
302
+ const ok = res.findings.length === 0;
303
+ const payload = {
304
+ gate: "secrets",
305
+ ok,
306
+ engine: res.engine,
307
+ mode: a.mode,
308
+ files: res.files.length,
309
+ findings: res.findings,
310
+ };
311
+ if (!a.noState) core.writeJSON(path.join(root, ".signoff", "secrets.json"), payload);
312
+ if (a.json) {
313
+ console.log(JSON.stringify({ gate: "secrets", ok, engine: res.engine, files: res.files.length, hits: res.findings.length }));
314
+ } else {
315
+ if (!a.quiet) {
316
+ for (const f of res.findings) console.log(`[secrets] ${f.file}:${f.line} ${f.rule} ${f.match}`);
317
+ }
318
+ // In CI the finding belongs on the diff line, not in a log tail.
319
+ const ci = require("./ci");
320
+ if (!ok && ci.isCI()) {
321
+ ci.annotate(
322
+ res.findings.map((f) => ({
323
+ file: f.file,
324
+ line: f.line,
325
+ rule: f.rule,
326
+ message: `${f.rule} credential pattern (${f.match}); remove it or allow the rule in .signoff/config.json`,
327
+ }))
328
+ );
329
+ ci.summary(`### SignOff: ${res.findings.length} secret(s) in the change\n\n` + ci.table(res.findings.map((f) => [f.file, String(f.line), f.rule]), ["file", "line", "rule"]));
330
+ }
331
+ console.log(`[secrets] ${ok ? "PASS" : "FAIL"} engine=${res.engine} files=${res.files.length} hits=${res.findings.length}`);
332
+ }
333
+ return ok ? 0 : 1;
334
+ }
335
+
336
+ module.exports = { run, scan, scanText, RULES, USAGE, HELP, mask };
package/lib/sign.js ADDED
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ // Signed verdicts.
3
+ //
4
+ // The unsigned marker proves "this tree was seen by the gate on this machine" -
5
+ // it is a JSON file anyone can write. Signing the trailer with the developer's
6
+ // SSH key makes a different claim: this specific tree, at commit time, was
7
+ // approved by the holder of that key, and the signature travels with the commit
8
+ // so a clone can check it without this machine or this tool's state directory.
9
+ //
10
+ // Trust model, stated plainly: a self-contained signature proves *a* key signed
11
+ // that tree. Identity needs an anchor - `.signoff/allowed_signers` in the repo
12
+ // (committed) or `signoff.json` `allowed_signers`. Without one, audit reports the
13
+ // commit as "self-signed", never as "verified".
14
+
15
+ const fs = require("fs");
16
+ const os = require("os");
17
+ const path = require("path");
18
+ const { spawnSync } = require("child_process");
19
+ const core = require("./core");
20
+
21
+ const NAMESPACE = "signoff";
22
+
23
+ function run(args, opts) {
24
+ const r = spawnSync(args[0], args.slice(1), {
25
+ encoding: "utf8",
26
+ windowsHide: true,
27
+ input: opts && opts.input,
28
+ cwd: opts && opts.cwd,
29
+ env: { ...process.env, ...((opts && opts.env) || {}) },
30
+ });
31
+ return { code: r.status === null ? 127 : r.status, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() };
32
+ }
33
+
34
+ function haveSshKeygen() {
35
+ const r = run(["ssh-keygen", "-Y", "sign", "-h"]);
36
+ // ssh-keygen prints usage and exits 1 when the flag set is valid but args are missing.
37
+ return !/not found|is not recognized|ENOENT/i.test(r.err) && r.code !== 127;
38
+ }
39
+
40
+ // Candidate private keys, in priority order. SIGNOFF_SIGN_KEY wins so tests and
41
+ // CI can pin one without touching git config; `signoff init --sign` pins the repo's
42
+ // own key in .signoff/config.json so the choice survives a fresh shell.
43
+ function candidates() {
44
+ const out = [];
45
+ if (process.env.SIGNOFF_SIGN_KEY) out.push(process.env.SIGNOFF_SIGN_KEY);
46
+ const repo = repoConfigKey();
47
+ if (repo) out.push(repo);
48
+ const cfg = run(["git", "config", "--get", "user.signingkey"]);
49
+ if (cfg.code === 0 && cfg.out) out.push(cfg.out);
50
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
51
+ for (const n of ["id_ed25519", "id_rsa", "id_ecdsa"]) out.push(path.join(home, ".ssh", n));
52
+ return out;
53
+ }
54
+
55
+ function repoConfigKey() {
56
+ const top = run(["git", "rev-parse", "--show-toplevel"]);
57
+ if (top.code !== 0) return null;
58
+ const cfg = core.readJSON(path.join(top.out, ".signoff", "config.json"), {}) || {};
59
+ return typeof cfg.sign_key === "string" && cfg.sign_key ? cfg.sign_key : null;
60
+ }
61
+
62
+ // A repo-local key: not an identity claim, but it makes every commit tamper-evident
63
+ // against accidental trailer edits, and it costs one flag.
64
+ function generateKey(dir) {
65
+ if (!haveSshKeygen()) return null;
66
+ const keyPath = path.join(dir, "signing_key");
67
+ if (fs.existsSync(keyPath)) return keyPath;
68
+ fs.mkdirSync(dir, { recursive: true });
69
+ const r = run(["ssh-keygen", "-t", "ed25519", "-N", "", "-C", "signoff-local", "-f", keyPath]);
70
+ if (r.code !== 0) return null;
71
+ try {
72
+ fs.chmodSync(keyPath, 0o600);
73
+ } catch (e) {
74
+ /* windows ignores the mode */
75
+ }
76
+ return keyPath;
77
+ }
78
+
79
+ function findKey() {
80
+ for (const raw of candidates()) {
81
+ const p = String(raw).replace(/^~/ , (process.env.HOME || process.env.USERPROFILE || "").replace(/\\/g, "/"));
82
+ if (!fs.existsSync(p)) continue;
83
+ if (fs.existsSync(p + ".pub") || /PRIVATE KEY/.test(safeRead(p))) return p;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ function safeRead(p) {
89
+ try {
90
+ return fs.readFileSync(p, "utf8");
91
+ } catch (e) {
92
+ return "";
93
+ }
94
+ }
95
+
96
+ function pubkeyOf(keyPath) {
97
+ const pub = safeRead(keyPath + ".pub").trim();
98
+ if (pub) return pub;
99
+ const r = run(["ssh-keygen", "-y", "-f", keyPath]);
100
+ return r.code === 0 ? r.out : "";
101
+ }
102
+
103
+ // Sign the tree string. Returns {key, sig} where sig is base64 of the armored
104
+ // signature so it survives as a one-line git trailer.
105
+ function sign(tree) {
106
+ if (!tree || !haveSshKeygen()) return null;
107
+ const keyPath = findKey();
108
+ if (!keyPath) return null;
109
+ const key = pubkeyOf(keyPath);
110
+ if (!key) return null;
111
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "signoff-sign-"));
112
+ const data = path.join(dir, "tree.txt");
113
+ try {
114
+ fs.writeFileSync(data, tree, "utf8");
115
+ const r = run(["ssh-keygen", "-Y", "sign", "-f", keyPath, "-n", NAMESPACE, data]);
116
+ if (r.code !== 0) return null;
117
+ const armored = safeRead(data + ".sig");
118
+ if (!armored) return null;
119
+ return { key, sig: Buffer.from(armored, "utf8").toString("base64") };
120
+ } finally {
121
+ fs.rmSync(dir, { recursive: true, force: true });
122
+ }
123
+ }
124
+
125
+ // Verify a trailer pair. `allowed` is a list of pubkey lines (or fingerprints);
126
+ // empty means self-signed only.
127
+ function verify(tree, key, sigB64, allowed) {
128
+ if (!tree || !key || !sigB64) return { ok: false, state: "unsigned", reason: "no signature" };
129
+ if (!haveSshKeygen()) return { ok: false, state: "unverifiable", reason: "ssh-keygen not available" };
130
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "signoff-verify-"));
131
+ try {
132
+ const sigFile = path.join(dir, "tree.sig");
133
+ const allowedFile = path.join(dir, "allowed_signers");
134
+ fs.writeFileSync(sigFile, Buffer.from(sigB64, "base64").toString("utf8"), "utf8");
135
+ fs.writeFileSync(allowedFile, `signoff ${key}\n`, "utf8");
136
+ // ssh-keygen -Y verify reads the signed message on stdin, not as an argument.
137
+ const r = run(
138
+ ["ssh-keygen", "-Y", "verify", "-f", allowedFile, "-I", "signoff", "-n", NAMESPACE, "-s", sigFile],
139
+ { input: tree }
140
+ );
141
+ if (r.code !== 0) return { ok: false, state: "bad-signature", reason: (r.err || r.out || "verify failed").split("\n")[0] };
142
+ const trusted = matchAllowed(key, allowed);
143
+ return { ok: true, state: trusted ? "verified" : "self-signed", reason: trusted ? "key is in allowed_signers" : "key not in allowed_signers" };
144
+ } finally {
145
+ fs.rmSync(dir, { recursive: true, force: true });
146
+ }
147
+ }
148
+
149
+ // allowed entries may be full pubkey lines, or just the base64 blob, or a
150
+ // fingerprint like SHA256:abc... . Compare what we have against all three.
151
+ function matchAllowed(key, allowed) {
152
+ if (!allowed || !allowed.length) return false;
153
+ const blob = key.split(/\s+/)[1] || "";
154
+ const fp = fingerprint(key);
155
+ for (const a of allowed) {
156
+ const s = String(a).trim();
157
+ if (!s || s.startsWith("#")) continue;
158
+ if (s === key || s === blob) return true;
159
+ if (s.split(/\s+/)[1] === blob) return true;
160
+ if (fp && s === fp) return true;
161
+ }
162
+ return false;
163
+ }
164
+
165
+ let fpCache = null;
166
+ function fingerprint(key) {
167
+ if (fpCache !== null) return fpCache;
168
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "signoff-fp-"));
169
+ try {
170
+ const f = path.join(dir, "key.pub");
171
+ fs.writeFileSync(f, key + "\n", "utf8");
172
+ const r = run(["ssh-keygen", "-lf", f]);
173
+ fpCache = r.code === 0 ? r.out.split(/\s+/)[1] || "" : "";
174
+ return fpCache;
175
+ } finally {
176
+ fs.rmSync(dir, { recursive: true, force: true });
177
+ }
178
+ }
179
+
180
+ // The repo's trust anchor: priority to the committed allowlist, then the declaration.
181
+ function allowedKeys(root) {
182
+ const out = [];
183
+ for (const rel of [".signoff/allowed_signers", "allowed_signers", "signoff.allowed_signers"]) {
184
+ const p = path.join(root, rel);
185
+ if (!fs.existsSync(p)) continue;
186
+ for (const ln of safeRead(p).split(/\r?\n/)) {
187
+ const s = ln.trim();
188
+ if (s && !s.startsWith("#")) out.push(s);
189
+ }
190
+ }
191
+ const decl = core.readJSON(path.join(root, "signoff.json"), {}) || {};
192
+ for (const k of [].concat(decl.allowed_signers || [])) if (typeof k === "string") out.push(k);
193
+ return out;
194
+ }
195
+
196
+ module.exports = { sign, verify, allowedKeys, findKey, pubkeyOf, haveSshKeygen, matchAllowed, generateKey, NAMESPACE };
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
- {
2
- "name": "signoff-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "signoff-cli",
3
+ "version": "4.0.0",
4
+ "description": "SignOff (落印) — five gates that make an agent's \"done\" provable: verify, coverage, queue, ledger, anti-slop.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "license": "MIT",
9
+ "bin": {
10
+ "signoff": "bin/signoff.js"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "lib/",
15
+ "gates/",
16
+ "docs/",
17
+ "test/",
18
+ "README.md",
19
+ "CHANGELOG.md",
20
+ "LICENSE",
21
+ ".pre-commit-hooks.yaml",
22
+ "action.yml"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "keywords": [
28
+ "agent",
29
+ "ai-agents",
30
+ "anti-slop",
31
+ "audit",
32
+ "code-review",
33
+ "coverage",
34
+ "dsh",
35
+ "fanout",
36
+ "gates",
37
+ "git-hooks",
38
+ "mcp",
39
+ "pre-commit",
40
+ "precommit",
41
+ "signoff",
42
+ "gitleaks",
43
+ "vscode-tasks"
44
+ ],
45
+ "scripts": {
46
+ "test": "node test/selftest.js",
47
+ "parity": "python test/selftest.py",
48
+ "postinstall": "node bin/signoff.js ensure --from-install --quiet"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "https://github.com/ayuanxx-max/dsh-signoff.git"
53
+ },
54
+ "mcpName": "io.github.ayuanxx-max/signoff"
55
+ }