vibeqm-check 0.4.2 → 0.4.3

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/README.md CHANGED
@@ -130,7 +130,7 @@ that caused it. The report appears on the run's summary page.
130
130
  ```yaml
131
131
  - uses: actions/checkout@v4
132
132
  with: { fetch-depth: 0 } # the checks sample history
133
- - uses: vibeqm/vibeqm-check@v0.4.2
133
+ - uses: vibeqm/vibeqm-check@v0.4.3
134
134
  with: { min-level: 2 } # optional: fail the run below this level
135
135
  ```
136
136
 
@@ -342,9 +342,30 @@ function environmentsShown(root, files, unfilled = []) {
342
342
  }
343
343
  return shown;
344
344
  }
345
+ /** Claude Code settings files that count as the repository's: committed in HEAD, read as
346
+ * committed (uncommitted edits are not in any clone). The user's ~/.claude/settings.json
347
+ * and an uncommitted project file limit this machine only. A file that can't be read
348
+ * has text null. */
349
+ export function committedSettings(root) {
350
+ const project = [".claude/settings.json", ".claude/settings.local.json"];
351
+ const inHead = new Set((git(root, ["ls-tree", "-r", "--name-only", "HEAD", "--", ...project]) ?? "").split("\n").filter(Boolean));
352
+ const disk = (file) => {
353
+ try {
354
+ return readFileSync(file, "utf8");
355
+ }
356
+ catch {
357
+ return null;
358
+ }
359
+ };
360
+ return [
361
+ ...project.map((f) => (inHead.has(f) ? { file: join(root, f), committed: true, text: git(root, ["show", `HEAD:${f}`]) } : { file: join(root, f), committed: false, text: disk(join(root, f)) })),
362
+ { file: join(homedir(), ".claude", "settings.json"), committed: false, text: disk(join(homedir(), ".claude", "settings.json")) },
363
+ ].filter((s) => s.committed || existsSync(s.file));
364
+ }
345
365
  /** Where the agent is told, or made, to ask before publishing live. */
346
366
  function confirmationRule(root) {
347
367
  const found = [];
368
+ const machineOnly = [];
348
369
  const rules = RULES_FILES.filter((f) => existsSync(join(root, f)));
349
370
  for (const f of rules) {
350
371
  const line = read(root, f)
@@ -353,22 +374,24 @@ function confirmationRule(root) {
353
374
  if (line)
354
375
  found.push(`${f}: "${line.replace(/^[-*\s]+/, "").trim().slice(0, 100)}"`);
355
376
  }
356
- const settings = [join(root, ".claude", "settings.json"), join(root, ".claude", "settings.local.json"), join(homedir(), ".claude", "settings.json")];
357
- for (const file of settings) {
358
- if (!existsSync(file))
359
- continue;
377
+ // Only settings committed to the repository count; this machine's settings are named, not counted.
378
+ for (const { file, committed, text } of committedSettings(root)) {
360
379
  try {
361
- const p = JSON.parse(readFileSync(file, "utf8"))?.permissions ?? {};
380
+ const p = JSON.parse(text ?? "")?.permissions ?? {};
362
381
  const hits = [...(p.ask ?? []), ...(p.deny ?? [])].filter((x) => typeof x === "string" && /^Bash\(/.test(x) && DEPLOY_COMMAND.test(x));
363
- const where = file.startsWith(homedir()) && !file.startsWith(root) ? `${file} (this machine's settings, not the repository's)` : file.slice(root.length + 1);
364
- if (hits.length)
365
- found.push(`publish commands that stop for the person or are refused in ${where}: ${hits.slice(0, 3).join(", ")}${hits.length > 3 ? "…" : ""}`);
382
+ if (!hits.length)
383
+ continue;
384
+ const list = `${hits.slice(0, 3).join(", ")}${hits.length > 3 ? "…" : ""}`;
385
+ if (committed)
386
+ found.push(`publish commands that stop for the person or are refused in ${file.slice(root.length + 1)}: ${list}`);
387
+ else
388
+ machineOnly.push(`${file} (this machine's settings, not committed): ${list}`);
366
389
  }
367
390
  catch {
368
391
  /* unreadable settings: agentPermissions reports it */
369
392
  }
370
393
  }
371
- return found;
394
+ return { found, machineOnly };
372
395
  }
373
396
  export function devApartFromProd(root) {
374
397
  const gate = "Development apart from production";
@@ -377,13 +400,13 @@ export function devApartFromProd(root) {
377
400
  const envs = environmentsShown(root, files, unfilled);
378
401
  // "<the live address, or "nothing is live yet">" is a template's choice, not a statement.
379
402
  const notLive = [...RULES_FILES, "README.md"].map((f) => read(root, f).split("\n").find((l) => NOT_LIVE.test(l) && !placeholders(l).length)).find(Boolean);
380
- const confirm = confirmationRule(root);
403
+ const { found: confirm, machineOnly } = confirmationRule(root);
381
404
  const shownEnvs = envs.length ? envs : notLive ? [`not live yet: "${notLive.trim().slice(0, 80)}"`] : [];
382
405
  const ev = [
383
406
  shownEnvs.length
384
407
  ? `environments: ${shownEnvs.slice(0, 2).join("; ")}`
385
408
  : `no development and production environments named in the repository (in the rules file, the README or a note) or shown by config files${unfilled.length ? `: ${unfilled[0]}` : ""}`,
386
- confirm.length ? `confirmation before publishing live: ${confirm.slice(0, 2).join("; ")}` : "no rule that the agent publishes live only after the person confirms (a line in the rules file, or an ask or deny rule on publish commands in the agent's settings)",
409
+ confirm.length ? `confirmation before publishing live: ${confirm.slice(0, 2).join("; ")}` : `no rule that the agent publishes live only after the person confirms (a line in the rules file, or an ask or deny rule on publish commands in the agent's settings committed to the repository)${machineOnly.length ? `; found only in ${machineOnly[0]}, which other clones and CI don't have` : ""}`,
387
410
  ].join("; ");
388
411
  return r(2, gate, shownEnvs.length && confirm.length ? "met" : "not-met", ev);
389
412
  }
@@ -1,11 +1,10 @@
1
1
  // Level 2 (Guarded) fact checks, from ASSESSOR.md v0.3 §2. Judgment questions are
2
2
  // left to the AI assessor; this file only reports what a script can prove.
3
3
  import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
4
- import { homedir } from "node:os";
5
4
  import { join, relative } from "node:path";
6
5
  import { commitSizes, git, median } from "../git.js";
7
6
  import { defaultBranchRuns, mergedPullRequests } from "../host.js";
8
- import { blanksNote, CHECK_COMMANDS, code, delegated, placeholders, RULES_FILES } from "./basics.js";
7
+ import { blanksNote, CHECK_COMMANDS, code, committedSettings, delegated, placeholders, RULES_FILES } from "./basics.js";
9
8
  const SMALL_BATCH_LIMIT = 400; // ASSESSOR §2: median changed lines per merged change
10
9
  const SAMPLE = 100;
11
10
  export function agentRulesFile(root) {
@@ -144,42 +143,38 @@ export function ownerPerChange(root) {
144
143
  : `all ${authors.length} sampled commits have a human author: ${[...people].slice(0, 3).join(", ")}${agentWritten ? `; ${agentWritten} of them written with an AI agent (per their trailers), owned by that author` : ""}`,
145
144
  };
146
145
  }
147
- /** Claude Code deny rules or a sandbox, in the project or the user's settings. */
146
+ /** Claude Code deny rules or a sandbox, in settings committed to the repository. */
148
147
  export function agentPermissions(root) {
149
148
  const gate = "Agent permissions";
150
- const sources = [
151
- join(root, ".claude", "settings.json"),
152
- join(root, ".claude", "settings.local.json"),
153
- join(homedir(), ".claude", "settings.json"),
154
- ];
155
149
  const found = [];
150
+ const machine = [];
156
151
  const unreadable = [];
157
- for (const file of sources) {
158
- if (!existsSync(file))
159
- continue;
152
+ for (const { file, committed, text } of committedSettings(root)) {
160
153
  try {
161
- const s = JSON.parse(readFileSync(file, "utf8"));
154
+ const s = JSON.parse(text ?? "");
162
155
  const deny = s?.permissions?.deny ?? [];
163
- const where = file.startsWith(homedir()) && !file.startsWith(root) ? `${file} (this machine's settings, not the repository's: other clones and CI don't have them)` : file;
156
+ const into = committed ? found : machine;
164
157
  if (deny.length)
165
- found.push(`${deny.length} deny rules in ${where}`);
158
+ into.push(`${deny.length} deny rules in ${file}`);
166
159
  if (s?.sandbox?.enabled)
167
- found.push(`sandbox enabled in ${where}`);
160
+ into.push(`sandbox enabled in ${file}`);
168
161
  }
169
162
  catch {
170
163
  unreadable.push(`unreadable settings: ${file}`);
171
164
  }
172
165
  }
173
166
  if (!found.length) {
167
+ // Limits on this machine are real here, but clones and CI don't have them: a person can attest them.
168
+ const local = machine.length ? [`${machine.join("; ")}: this machine's settings, not committed, so other clones and CI don't have them; commit .claude/settings.json, or attach proof that every machine working on this repository has them`] : [];
174
169
  return {
175
170
  level: 2,
176
171
  gate,
177
172
  status: "not-visible",
178
173
  // An unreadable file is not a limit: it says so, and never meets the gate.
179
- evidence: [...unreadable, "no Claude Code deny rules or sandbox found; other agents' settings are not read yet. Attach proof of limits"].join("; "),
174
+ evidence: [...unreadable, ...local, ...(local.length ? [] : ["no Claude Code deny rules or sandbox committed; other agents' settings are not read yet. Attach proof of limits"])].join("; "),
180
175
  };
181
176
  }
182
- return { level: 2, gate, status: "met", evidence: [...found, ...unreadable].join("; ") };
177
+ return { level: 2, gate, status: "met", evidence: [...found, ...machine.map((m) => `also ${m} (this machine only)`), ...unreadable].join("; ") };
183
178
  }
184
179
  /** A CI run on the default branch that failed, then a later run of the same workflow
185
180
  * that passed: the checks refused a change and it was fixed. */
package/dist/src/fixes.js CHANGED
@@ -205,7 +205,7 @@ const FIXES = {
205
205
  "Agent permissions": () => ({
206
206
  why: "An agent that can run anything can delete, leak or force-push anything.",
207
207
  steps: [
208
- 'Add deny rules to `.claude/settings.json` (or the user\'s `~/.claude/settings.json`): `"permissions": {"deny": ["Bash(git push --force*)", "Bash(git push -f*)", "Bash(git commit --no-verify*)", "Bash(rm -rf /*)", "Bash(rm -rf ~*)", "Read(./.env*)", "Read(**/.env*)"]}`, plus the paths of any credentials this project uses.',
208
+ 'Add deny rules to `.claude/settings.json` and commit it (the user\'s `~/.claude/settings.json` limits one machine only; clones and CI don\'t have it): `"permissions": {"deny": ["Bash(git push --force*)", "Bash(git push -f*)", "Bash(git commit --no-verify*)", "Bash(rm -rf /*)", "Bash(rm -rf ~*)", "Read(./.env*)", "Read(**/.env*)"]}`, plus the paths of any credentials this project uses.',
209
209
  "Keep production credentials and secrets out of the agent's environment.",
210
210
  ],
211
211
  verify: "Try one denied command (e.g. `git commit --no-verify --dry-run`) and confirm it is refused. " + RERUN,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeqm-check",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Self-assessment for the Vibe Quality Model (VibeQM): fact checks on a repository, with evidence for every gate",
5
5
  "keywords": [
6
6
  "vibe-quality-model",
@@ -15,7 +15,7 @@
15
15
  "Bash(git push * --delete*)",
16
16
  "Bash(git push -d *)",
17
17
  "Bash(git push * -d *)",
18
- "Bash(git push * :*)",
18
+ "Bash(git push * :**)",
19
19
  "Bash(rm -rf /*)",
20
20
  "Bash(rm -rf ~*)",
21
21
  "Bash(rm -rf ..*)",