@guilz-dev/belay 0.2.0 → 0.4.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 (180) hide show
  1. package/README.md +39 -12
  2. package/dist/adapters/cursor/runtime-entry.js +1 -0
  3. package/dist/adapters/shared/gate-runtime.d.ts +11 -14
  4. package/dist/adapters/shared/gate-runtime.js +289 -23
  5. package/dist/bundle/claude-runtime.mjs +4435 -708
  6. package/dist/bundle/codex-runtime.mjs +4448 -710
  7. package/dist/bundle/cursor-runtime.mjs +4437 -710
  8. package/dist/cli.js +333 -11
  9. package/dist/commands/approve.d.ts +1 -0
  10. package/dist/commands/approve.js +56 -26
  11. package/dist/commands/config.d.ts +70 -0
  12. package/dist/commands/config.js +577 -0
  13. package/dist/commands/doctor.js +13 -2
  14. package/dist/commands/explain.js +6 -1
  15. package/dist/commands/harvest.d.ts +29 -0
  16. package/dist/commands/harvest.js +72 -0
  17. package/dist/commands/health-snapshot.d.ts +6 -0
  18. package/dist/commands/health-snapshot.js +34 -0
  19. package/dist/commands/judge.d.ts +100 -0
  20. package/dist/commands/judge.js +304 -0
  21. package/dist/commands/metrics.js +34 -2
  22. package/dist/commands/quality.d.ts +38 -0
  23. package/dist/commands/quality.js +105 -0
  24. package/dist/commands/simulate.d.ts +1 -0
  25. package/dist/commands/simulate.js +7 -2
  26. package/dist/commands/standing-allow.d.ts +10 -0
  27. package/dist/commands/standing-allow.js +26 -0
  28. package/dist/commands/status.js +2 -2
  29. package/dist/commands/stdin-key.d.ts +1 -0
  30. package/dist/commands/stdin-key.js +8 -0
  31. package/dist/commands/tui.d.ts +48 -0
  32. package/dist/commands/tui.js +150 -0
  33. package/dist/config-io.js +10 -5
  34. package/dist/conformance/guarantee-table.js +12 -0
  35. package/dist/conformance/types.d.ts +2 -0
  36. package/dist/core/approval-replay-cli.d.ts +7 -0
  37. package/dist/core/approval-replay-cli.js +36 -0
  38. package/dist/core/approval-replay.d.ts +45 -0
  39. package/dist/core/approval-replay.js +141 -0
  40. package/dist/core/approval-service.d.ts +15 -0
  41. package/dist/core/approval-service.js +47 -2
  42. package/dist/core/approval.d.ts +24 -3
  43. package/dist/core/approval.js +47 -4
  44. package/dist/core/audit-analysis.d.ts +7 -1
  45. package/dist/core/audit-analysis.js +111 -0
  46. package/dist/core/audit-io.d.ts +2 -0
  47. package/dist/core/audit-io.js +14 -0
  48. package/dist/core/audit-metrics.d.ts +7 -0
  49. package/dist/core/audit-metrics.js +24 -4
  50. package/dist/core/audit-query.d.ts +1 -0
  51. package/dist/core/audit-query.js +3 -0
  52. package/dist/core/audit-replay-context.d.ts +35 -0
  53. package/dist/core/audit-replay-context.js +88 -0
  54. package/dist/core/audit-types.d.ts +24 -1
  55. package/dist/core/audit-types.js +1 -1
  56. package/dist/core/capability/index.d.ts +4 -3
  57. package/dist/core/capability/index.js +3 -2
  58. package/dist/core/capability/paths.d.ts +2 -1
  59. package/dist/core/capability/paths.js +76 -8
  60. package/dist/core/capability/reasons.d.ts +3 -0
  61. package/dist/core/capability/reasons.js +8 -0
  62. package/dist/core/capability/trusted-workspace-roots.d.ts +25 -0
  63. package/dist/core/capability/trusted-workspace-roots.js +149 -0
  64. package/dist/core/capability/types.d.ts +11 -1
  65. package/dist/core/capability-approval.d.ts +2 -1
  66. package/dist/core/capability-approval.js +100 -2
  67. package/dist/core/classify-subagent.js +2 -20
  68. package/dist/core/classify-tool.js +129 -30
  69. package/dist/core/config-layers.js +2 -1
  70. package/dist/core/config.d.ts +40 -2
  71. package/dist/core/config.js +159 -11
  72. package/dist/core/credential-store.d.ts +11 -0
  73. package/dist/core/credential-store.js +60 -0
  74. package/dist/core/gate-engine.js +104 -13
  75. package/dist/core/harvest.d.ts +53 -0
  76. package/dist/core/harvest.js +276 -0
  77. package/dist/core/index.d.ts +9 -3
  78. package/dist/core/index.js +6 -2
  79. package/dist/core/integrity.d.ts +2 -0
  80. package/dist/core/integrity.js +13 -0
  81. package/dist/core/judge-api-key.d.ts +19 -0
  82. package/dist/core/judge-api-key.js +74 -0
  83. package/dist/core/judge-cloud-consent.d.ts +13 -0
  84. package/dist/core/judge-cloud-consent.js +38 -0
  85. package/dist/core/judge-config.d.ts +41 -4
  86. package/dist/core/judge-config.js +263 -57
  87. package/dist/core/judge-doctor.d.ts +9 -1
  88. package/dist/core/judge-doctor.js +199 -94
  89. package/dist/core/judge-model-discovery.d.ts +24 -0
  90. package/dist/core/judge-model-discovery.js +168 -0
  91. package/dist/core/judge-model-policy.d.ts +5 -0
  92. package/dist/core/judge-model-policy.js +21 -0
  93. package/dist/core/judge-runtime-detection.d.ts +9 -0
  94. package/dist/core/judge-runtime-detection.js +68 -0
  95. package/dist/core/path-utils.d.ts +11 -0
  96. package/dist/core/path-utils.js +56 -6
  97. package/dist/core/reclassify.d.ts +3 -0
  98. package/dist/core/reclassify.js +44 -14
  99. package/dist/core/replay-scrub.d.ts +9 -0
  100. package/dist/core/replay-scrub.js +43 -0
  101. package/dist/core/shell-tokenizer.d.ts +2 -0
  102. package/dist/core/shell-tokenizer.js +82 -23
  103. package/dist/core/standing-allow.d.ts +50 -0
  104. package/dist/core/standing-allow.js +175 -0
  105. package/dist/core/transactional/diff-evaluator.js +1 -19
  106. package/dist/core/types.d.ts +17 -0
  107. package/dist/core/verdict/adapter.d.ts +1 -0
  108. package/dist/core/verdict/adapter.js +17 -2
  109. package/dist/core/verdict/containment.d.ts +8 -2
  110. package/dist/core/verdict/containment.js +45 -10
  111. package/dist/core/verdict/judge-audit.d.ts +1 -0
  112. package/dist/core/verdict/judge-audit.js +32 -1
  113. package/dist/core/verdict/judge-baseline.d.ts +20 -0
  114. package/dist/core/verdict/judge-baseline.js +48 -0
  115. package/dist/core/verdict/judge-broker-service.d.ts +42 -0
  116. package/dist/core/verdict/judge-broker-service.js +279 -0
  117. package/dist/core/verdict/judge-catalog.d.ts +40 -0
  118. package/dist/core/verdict/judge-catalog.js +148 -0
  119. package/dist/core/verdict/judge-cli-fingerprint.d.ts +5 -0
  120. package/dist/core/verdict/judge-cli-fingerprint.js +61 -0
  121. package/dist/core/verdict/judge-cli.d.ts +46 -0
  122. package/dist/core/verdict/judge-cli.js +401 -0
  123. package/dist/core/verdict/judge-factory.d.ts +15 -4
  124. package/dist/core/verdict/judge-factory.js +127 -15
  125. package/dist/core/verdict/judge-provider-matrix.d.ts +20 -0
  126. package/dist/core/verdict/judge-provider-matrix.js +66 -0
  127. package/dist/core/verdict/judge-runtime-config.d.ts +42 -0
  128. package/dist/core/verdict/judge-runtime-config.js +92 -0
  129. package/dist/core/verdict/judge-session-broker.d.ts +48 -0
  130. package/dist/core/verdict/judge-session-broker.js +195 -0
  131. package/dist/core/verdict/judge-session-guard.d.ts +27 -0
  132. package/dist/core/verdict/judge-session-guard.js +91 -0
  133. package/dist/core/verdict/judge-session-kill-switch.d.ts +11 -0
  134. package/dist/core/verdict/judge-session-kill-switch.js +43 -0
  135. package/dist/core/verdict/judge-session-mutex.d.ts +9 -0
  136. package/dist/core/verdict/judge-session-mutex.js +23 -0
  137. package/dist/core/verdict/judge-shadow.d.ts +24 -0
  138. package/dist/core/verdict/judge-shadow.js +116 -0
  139. package/dist/core/verdict/judge-transport.d.ts +40 -0
  140. package/dist/core/verdict/judge-transport.js +258 -0
  141. package/dist/core/verdict/judge.d.ts +33 -1
  142. package/dist/core/verdict/judge.js +84 -14
  143. package/dist/core/verdict/parser.js +6 -1
  144. package/dist/core/verdict/persistent-paths.d.ts +8 -0
  145. package/dist/core/verdict/persistent-paths.js +52 -0
  146. package/dist/core/verdict/types.d.ts +19 -2
  147. package/dist/core/verdict/verdict.js +166 -49
  148. package/dist/corpus/adversarial-probe.d.ts +86 -0
  149. package/dist/corpus/adversarial-probe.js +220 -0
  150. package/dist/corpus/evaluate.d.ts +45 -12
  151. package/dist/corpus/evaluate.js +62 -5
  152. package/dist/corpus/gates.d.ts +35 -0
  153. package/dist/corpus/gates.js +81 -0
  154. package/dist/corpus/judge-accuracy.d.ts +8 -0
  155. package/dist/corpus/judge-accuracy.js +39 -0
  156. package/dist/corpus/must-allow-commands.d.ts +5 -0
  157. package/dist/corpus/must-allow-commands.js +13 -0
  158. package/dist/corpus/mutators.d.ts +28 -0
  159. package/dist/corpus/mutators.js +151 -0
  160. package/dist/corpus/ratchet.d.ts +42 -0
  161. package/dist/corpus/ratchet.js +116 -0
  162. package/dist/corpus/runtime-match.d.ts +19 -0
  163. package/dist/corpus/runtime-match.js +45 -0
  164. package/dist/corpus/standing-allow-catalog.generated.d.ts +13 -0
  165. package/dist/corpus/standing-allow-catalog.generated.js +79 -0
  166. package/dist/corpus/types.d.ts +47 -0
  167. package/dist/corpus/types.js +157 -0
  168. package/dist/installer.js +69 -15
  169. package/dist/judge-broker-daemon.d.ts +1 -0
  170. package/dist/judge-broker-daemon.js +123 -0
  171. package/dist/services/sandbox-service.d.ts +1 -0
  172. package/dist/services/sandbox-service.js +2 -0
  173. package/dist/types.d.ts +7 -0
  174. package/dist/version.d.ts +1 -1
  175. package/dist/version.js +1 -1
  176. package/package.json +7 -3
  177. package/skills/belay/SKILL.md +15 -8
  178. package/skills/belay/belay-approve.md +17 -0
  179. package/dist/commands/init-wizard.d.ts +0 -21
  180. package/dist/commands/init-wizard.js +0 -63
@@ -0,0 +1,105 @@
1
+ import path from 'node:path';
2
+ import { loadConfigFile } from '../config-io.js';
3
+ import { computeAuditMetrics } from '../core/audit-metrics.js';
4
+ import { runCorpusEvaluation } from '../corpus/evaluate.js';
5
+ import { passesHardGates } from '../corpus/gates.js';
6
+ import { loadAuditRecords } from './audit.js';
7
+ import { harvestReportFromRecords } from './harvest.js';
8
+ export const QUALITY_REPORT_SCHEMA_VERSION = 1;
9
+ export async function qualityCheck(options = {}) {
10
+ const repoRoot = path.resolve(options.targetDir ?? process.cwd());
11
+ const config = await loadConfigFile(repoRoot);
12
+ const corpusDir = path.resolve(repoRoot, options.corpusDir ?? 'corpus');
13
+ const corpusMetrics = await runCorpusEvaluation(corpusDir);
14
+ const hardGatesOk = passesHardGates(corpusMetrics.gates);
15
+ const auditRecords = await loadAuditRecords(repoRoot);
16
+ const metrics = computeAuditMetrics(auditRecords, {
17
+ auditLogPath: config.audit.logPath,
18
+ mode: config.mode,
19
+ unknownLocalEffect: config.policy.unknownLocalEffect,
20
+ });
21
+ const harvest = harvestReportFromRecords(auditRecords, {
22
+ allowPatterns: config.overrides.allow,
23
+ });
24
+ const notes = [
25
+ 'Overall ok reflects corpus hard gates only; audit and harvest signals are advisory.',
26
+ 'Recursive quality loop: corpus hard gates are the FN/FP safety boundary.',
27
+ 'Harvest candidates and audit metrics inform review — approvals are not ground truth.',
28
+ 'Simulate (`belay simulate`) is triage only; it does not replace `pnpm corpus`.',
29
+ ];
30
+ if (!hardGatesOk) {
31
+ notes.push('Corpus hard gates failed — fix must-ask misses and provably-benign blocks before tuning friction.');
32
+ }
33
+ if (metrics.availabilityAsks.total > 0) {
34
+ notes.push(`${metrics.availabilityAsks.total} availability-caused ask(s) — tune judge/cwd infrastructure before corpus promotion.`);
35
+ }
36
+ if (harvest.availabilityQueue.length > 0) {
37
+ notes.push(`${harvest.availabilityQueue.length} shell pattern(s) in the availability queue — do not harvest into corpus.`);
38
+ }
39
+ const ok = hardGatesOk;
40
+ return {
41
+ schemaVersion: QUALITY_REPORT_SCHEMA_VERSION,
42
+ ok,
43
+ corpus: {
44
+ path: path.relative(repoRoot, corpusDir) || corpusDir,
45
+ passesHardGates: hardGatesOk,
46
+ totalCases: corpusMetrics.total,
47
+ categoryCounts: corpusMetrics.categoryCounts,
48
+ provenanceCounts: corpusMetrics.provenanceCounts,
49
+ mustAskMisses: corpusMetrics.gates.mustAsk.mismatches,
50
+ provablyBenignBlocks: corpusMetrics.gates.provablyBenign.mismatches,
51
+ acceptedBenignMismatches: corpusMetrics.gates.acceptedBenign.mismatches,
52
+ accuracy: corpusMetrics.accuracy,
53
+ },
54
+ audit: {
55
+ logPath: config.audit.logPath,
56
+ gateEvents: metrics.gateEvents,
57
+ classifierWouldBlockRate: metrics.classifierWouldBlockRate,
58
+ availabilityAsks: metrics.availabilityAsks.total,
59
+ readyForEnforce: metrics.dogfood.readyForEnforce,
60
+ repeatedFingerprintPatterns: metrics.repeatedFingerprintAsks.length,
61
+ },
62
+ harvest: {
63
+ scope: 'shell',
64
+ benignCandidates: harvest.candidates.length,
65
+ availabilityQueue: harvest.availabilityQueue.length,
66
+ },
67
+ notes,
68
+ };
69
+ }
70
+ export function formatQualityReport(report) {
71
+ const lines = [
72
+ 'belay quality — recursive quality loop status',
73
+ `Schema: v${report.schemaVersion}`,
74
+ `Overall: ${report.ok ? 'OK' : 'ATTENTION NEEDED'}`,
75
+ '',
76
+ 'Corpus hard gates:',
77
+ ` path: ${report.corpus.path}`,
78
+ ` passes: ${report.corpus.passesHardGates ? 'yes' : 'no'}`,
79
+ ` total cases: ${report.corpus.totalCases}`,
80
+ ` categories: must-ask=${report.corpus.categoryCounts['must-ask']} provably-benign=${report.corpus.categoryCounts['provably-benign']} accepted-benign=${report.corpus.categoryCounts['accepted-benign']}`,
81
+ ` provenance: manual=${report.corpus.provenanceCounts.manual} mutation=${report.corpus.provenanceCounts.mutation} harvest=${report.corpus.provenanceCounts.harvest} redteam=${report.corpus.provenanceCounts.redteam} unspecified=${report.corpus.provenanceCounts.unspecified}`,
82
+ ` must-ask misses: ${report.corpus.mustAskMisses}`,
83
+ ` provably-benign blocks: ${report.corpus.provablyBenignBlocks}`,
84
+ ` accepted-benign mismatches (soft): ${report.corpus.acceptedBenignMismatches}`,
85
+ ` accuracy: ${(report.corpus.accuracy * 100).toFixed(1)}%`,
86
+ '',
87
+ 'Audit metrics:',
88
+ ` log: ${report.audit.logPath}`,
89
+ ` gate events: ${report.audit.gateEvents}`,
90
+ ` classifier would-block rate: ${(report.audit.classifierWouldBlockRate * 100).toFixed(1)}%`,
91
+ ` availability asks: ${report.audit.availabilityAsks}`,
92
+ ` ready for enforce: ${report.audit.readyForEnforce ? 'yes' : 'no'}`,
93
+ ` repeated fingerprint patterns: ${report.audit.repeatedFingerprintPatterns}`,
94
+ '',
95
+ 'Harvest (shell only):',
96
+ ` benign candidates: ${report.harvest.benignCandidates}`,
97
+ ` availability queue: ${report.harvest.availabilityQueue}`,
98
+ '',
99
+ 'Notes:',
100
+ ];
101
+ for (const note of report.notes) {
102
+ lines.push(`- ${note}`);
103
+ }
104
+ return lines.join('\n');
105
+ }
@@ -9,6 +9,7 @@ export declare function simulateProject(options: SimulateOptions): Promise<{
9
9
  changedCount: number;
10
10
  allowToDenyCount: number;
11
11
  denyToAllowCount: number;
12
+ missingSnapshotCount: number;
12
13
  diffs: import("../core/reclassify.js").ReclassifyDiff[];
13
14
  }>;
14
15
  export declare function formatSimulateReport(report: Awaited<ReturnType<typeof simulateProject>>): string;
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { loadConfigFile } from '../config-io.js';
5
5
  import { parseAuditNdjson, toAuditRecord } from '../core/audit-metrics.js';
6
6
  import { mergeConfig } from '../core/config.js';
7
- import { diffReclassification } from '../core/reclassify.js';
7
+ import { countMissingActionSnapshots, diffReclassification } from '../core/reclassify.js';
8
8
  export async function simulateProject(options) {
9
9
  const repoRoot = path.resolve(options.targetDir ?? process.cwd());
10
10
  const currentConfig = await loadConfigFile(repoRoot);
@@ -22,6 +22,7 @@ export async function simulateProject(options) {
22
22
  raw = '';
23
23
  }
24
24
  const records = parseAuditNdjson(raw).map(toAuditRecord);
25
+ const missingSnapshotCount = countMissingActionSnapshots(records);
25
26
  const diffs = (await Promise.all(records.map((record) => diffReclassification(record, candidateConfig, repoRoot)))).filter((diff) => diff !== null);
26
27
  const allowToDeny = diffs.filter((diff) => (diff.previousVerdict === 'allow' || diff.previousVerdict === 'allow_flagged') &&
27
28
  diff.nextVerdict === 'deny_pending_approval');
@@ -33,20 +34,24 @@ export async function simulateProject(options) {
33
34
  changedCount: diffs.length,
34
35
  allowToDenyCount: allowToDeny.length,
35
36
  denyToAllowCount: denyToAllow.length,
37
+ missingSnapshotCount,
36
38
  diffs,
37
39
  };
38
40
  }
39
41
  export function formatSimulateReport(report) {
40
42
  const lines = [
41
43
  `simulate ${report.candidateConfigPath}`,
44
+ 'Triage only — corpus hard gates (`pnpm corpus`), not simulate counts, are the safety boundary.',
42
45
  `Records scanned: ${report.totalRecords}`,
46
+ `Missing action snapshots: ${report.missingSnapshotCount}`,
43
47
  `Verdict changes: ${report.changedCount}`,
44
48
  `allow/flagged → deny: ${report.allowToDenyCount}`,
45
49
  `deny → allow/flagged: ${report.denyToAllowCount}`,
46
50
  '',
47
51
  ];
48
52
  for (const diff of report.diffs.slice(0, 30)) {
49
- lines.push(`- ${diff.summary ?? diff.fingerprint}: ${diff.previousVerdict}/${diff.previousReason} ${diff.nextVerdict}/${diff.nextReason}`);
53
+ const context = diff.replayCwd && diff.replayKind ? ` [${diff.replayKind} cwd=${diff.replayCwd}]` : '';
54
+ lines.push(`- ${diff.summary ?? diff.fingerprint}${context}: ${diff.previousVerdict}/${diff.previousReason} → ${diff.nextVerdict}/${diff.nextReason}`);
50
55
  }
51
56
  if (report.diffs.length > 30) {
52
57
  lines.push(`... ${report.diffs.length - 30} more`);
@@ -0,0 +1,10 @@
1
+ import type { GatedActionKind } from '../core/gate-contract.js';
2
+ export interface RevokeStandingAllowOptions {
3
+ targetDir?: string;
4
+ fingerprint: string;
5
+ kind?: GatedActionKind;
6
+ }
7
+ export declare function revokeStandingAllow(options: RevokeStandingAllowOptions): Promise<{
8
+ ok: boolean;
9
+ message: string;
10
+ }>;
@@ -0,0 +1,26 @@
1
+ import path from 'node:path';
2
+ import { loadConfigFile, repoLocalStateDirFor } from '../config-io.js';
3
+ import { loadStandingAllow, revokeStandingAllowEntry, saveStandingAllow, standingAllowFile, } from '../core/standing-allow.js';
4
+ export async function revokeStandingAllow(options) {
5
+ const repoRoot = path.resolve(options.targetDir ?? process.cwd());
6
+ const config = await loadConfigFile(repoRoot);
7
+ const filePath = standingAllowFile(config, repoLocalStateDirFor(repoRoot, config));
8
+ const state = await loadStandingAllow(filePath);
9
+ const kind = options.kind ?? 'shell';
10
+ const { state: next, removed } = revokeStandingAllowEntry(state, {
11
+ kind,
12
+ fingerprint: options.fingerprint,
13
+ repoRoot,
14
+ });
15
+ if (!removed) {
16
+ return {
17
+ ok: false,
18
+ message: `Standing-allow entry not found for ${kind} fingerprint ${options.fingerprint}.`,
19
+ };
20
+ }
21
+ await saveStandingAllow(filePath, next);
22
+ return {
23
+ ok: true,
24
+ message: `Revoked standing-allow for ${kind} fingerprint ${options.fingerprint}.`,
25
+ };
26
+ }
@@ -39,9 +39,9 @@ export function formatStatusReport(report) {
39
39
  ...(health.additionalRiskSignals.length > 0
40
40
  ? [`Additional risk signals: ${health.additionalRiskSignals.join('; ')}`]
41
41
  : []),
42
- ...(health.skillOnly
42
+ ...(!health.floorInstalled
43
43
  ? [
44
- 'Skill-only mode: yes — hooks are missing or incomplete. Run `npx @guilz-dev/belay init` to install the enforcement floor.',
44
+ 'Enforcement floor missing or incomplete. Run `belay config` for interactive setup, or `npx @guilz-dev/belay init` for non-interactive install.',
45
45
  ]
46
46
  : []),
47
47
  `Approval state: ${report.approvalStateDir}`,
@@ -0,0 +1 @@
1
+ export declare function readKeyFromStdin(): Promise<string>;
@@ -0,0 +1,8 @@
1
+ import { stdin as input } from 'node:process';
2
+ export async function readKeyFromStdin() {
3
+ const chunks = [];
4
+ for await (const chunk of input) {
5
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
6
+ }
7
+ return Buffer.concat(chunks).toString('utf8').trim();
8
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Zero-dependency terminal UI helpers for the interactive `belay config` wizard.
3
+ *
4
+ * Everything here is built on Node's built-in `readline`/`tty` only — no runtime
5
+ * dependencies — so it stays aligned with belay's zero-dependency posture.
6
+ *
7
+ * This module is intentionally TTY-only. Callers are responsible for non-TTY
8
+ * fallback behavior.
9
+ */
10
+ export declare const c: {
11
+ bold: (s: string) => string;
12
+ dim: (s: string) => string;
13
+ italic: (s: string) => string;
14
+ underline: (s: string) => string;
15
+ inverse: (s: string) => string;
16
+ cyan: (s: string) => string;
17
+ green: (s: string) => string;
18
+ yellow: (s: string) => string;
19
+ red: (s: string) => string;
20
+ gray: (s: string) => string;
21
+ };
22
+ /** A select wizard only makes sense when both stdin and stdout are real TTYs. */
23
+ export declare function isInteractiveTTY(): boolean;
24
+ /** Top banner shown once at the start of the wizard. */
25
+ export declare function intro(title: string, subtitle?: string): void;
26
+ /** Closing line shown once at the end of the wizard. */
27
+ export declare function outro(message: string): void;
28
+ /** Collapsed one-line summary of an answered question. */
29
+ export declare function summary(label: string, value: string): void;
30
+ export interface SelectChoice<T extends string> {
31
+ value: T;
32
+ /** Visible label; defaults to `value`. */
33
+ label?: string;
34
+ /** Dim hint shown to the right of the focused row. */
35
+ hint?: string;
36
+ }
37
+ export interface SelectOptions<T extends string> {
38
+ message: string;
39
+ choices: SelectChoice<T>[];
40
+ defaultValue: T;
41
+ }
42
+ /**
43
+ * Interactive single-choice prompt driven by arrow keys (or j/k). Resolves with
44
+ * the selected choice value. Must only be called in an interactive TTY.
45
+ */
46
+ export declare function selectPrompt<T extends string>(opts: SelectOptions<T>): Promise<T>;
47
+ /** Interactive yes/no prompt rendered as a two-row arrow-key selection. */
48
+ export declare function confirmPrompt(message: string, defaultValue: boolean): Promise<boolean>;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Zero-dependency terminal UI helpers for the interactive `belay config` wizard.
3
+ *
4
+ * Everything here is built on Node's built-in `readline`/`tty` only — no runtime
5
+ * dependencies — so it stays aligned with belay's zero-dependency posture.
6
+ *
7
+ * This module is intentionally TTY-only. Callers are responsible for non-TTY
8
+ * fallback behavior.
9
+ */
10
+ import { stdin as input, stdout as output } from 'node:process';
11
+ import readline from 'node:readline';
12
+ const isColorEnabled = () => isInteractiveTTY() && !process.env.NO_COLOR && process.env.TERM !== 'dumb';
13
+ const symbol = (pretty, plain) => (isColorEnabled() ? pretty : plain);
14
+ const wrap = (open, close) => (s) => isColorEnabled() ? `\x1b[${open}m${s}\x1b[${close}m` : s;
15
+ export const c = {
16
+ bold: wrap(1, 22),
17
+ dim: wrap(2, 22),
18
+ italic: wrap(3, 23),
19
+ underline: wrap(4, 24),
20
+ inverse: wrap(7, 27),
21
+ cyan: wrap(36, 39),
22
+ green: wrap(32, 39),
23
+ yellow: wrap(33, 39),
24
+ red: wrap(31, 39),
25
+ gray: wrap(90, 39),
26
+ };
27
+ /** A select wizard only makes sense when both stdin and stdout are real TTYs. */
28
+ export function isInteractiveTTY() {
29
+ return Boolean(input.isTTY && output.isTTY);
30
+ }
31
+ const symPointer = () => symbol('❯', '>');
32
+ const symCheck = () => symbol('✓', 'v');
33
+ const symBar = () => symbol('│', '|');
34
+ const symQuestion = () => '?';
35
+ /** Top banner shown once at the start of the wizard. */
36
+ export function intro(title, subtitle) {
37
+ output.write('\n');
38
+ output.write(`${c.green(symBar())} ${c.bold(title)}\n`);
39
+ if (subtitle) {
40
+ output.write(`${c.green(symBar())} ${c.dim(subtitle)}\n`);
41
+ }
42
+ output.write(`${c.green(symBar())}\n`);
43
+ }
44
+ /** Closing line shown once at the end of the wizard. */
45
+ export function outro(message) {
46
+ output.write(`${c.green(symBar())}\n`);
47
+ output.write(`${c.green(symCheck())} ${message}\n\n`);
48
+ }
49
+ /** Collapsed one-line summary of an answered question. */
50
+ export function summary(label, value) {
51
+ output.write(`${c.green(symCheck())} ${c.dim(label)} ${c.cyan(value)}\n`);
52
+ }
53
+ /**
54
+ * Interactive single-choice prompt driven by arrow keys (or j/k). Resolves with
55
+ * the selected choice value. Must only be called in an interactive TTY.
56
+ */
57
+ export function selectPrompt(opts) {
58
+ if (!isInteractiveTTY()) {
59
+ throw new Error('selectPrompt requires an interactive TTY.');
60
+ }
61
+ const { message, choices } = opts;
62
+ if (choices.length === 0) {
63
+ throw new Error('selectPrompt requires at least one choice.');
64
+ }
65
+ const startIndex = Math.max(0, choices.findIndex((ch) => ch.value === opts.defaultValue));
66
+ return new Promise((resolve, reject) => {
67
+ let index = startIndex;
68
+ let rendered = 0;
69
+ const lineFor = (ch, active) => {
70
+ const label = ch.label ?? ch.value;
71
+ if (active) {
72
+ const hint = ch.hint ? ` ${c.dim(ch.hint)}` : '';
73
+ return `${c.cyan(symPointer())} ${c.cyan(c.underline(label))}${hint}`;
74
+ }
75
+ return ` ${c.dim(label)}`;
76
+ };
77
+ const clearRendered = () => {
78
+ if (rendered > 0) {
79
+ output.write(`\x1b[${rendered}A`);
80
+ }
81
+ output.write('\x1b[J');
82
+ };
83
+ const render = () => {
84
+ clearRendered();
85
+ const lines = [
86
+ `${c.green(symQuestion())} ${c.bold(message)}`,
87
+ ...choices.map((ch, i) => lineFor(ch, i === index)),
88
+ ];
89
+ output.write(`${lines.join('\n')}\n`);
90
+ rendered = lines.length;
91
+ };
92
+ const cleanup = () => {
93
+ input.off('keypress', onKey);
94
+ if (input.isTTY && typeof input.setRawMode === 'function') {
95
+ input.setRawMode(false);
96
+ }
97
+ input.pause();
98
+ output.write('\x1b[?25h'); // show cursor
99
+ };
100
+ const finish = () => {
101
+ clearRendered();
102
+ cleanup();
103
+ const chosen = choices[index];
104
+ summary(message, chosen.label ?? chosen.value);
105
+ resolve(chosen.value);
106
+ };
107
+ const onKey = (_str, key) => {
108
+ if (!key)
109
+ return;
110
+ if (key.ctrl && key.name === 'c') {
111
+ clearRendered();
112
+ cleanup();
113
+ output.write('\n');
114
+ reject(new Error('Cancelled.'));
115
+ return;
116
+ }
117
+ if (key.name === 'up' || key.name === 'k') {
118
+ index = (index - 1 + choices.length) % choices.length;
119
+ render();
120
+ }
121
+ else if (key.name === 'down' || key.name === 'j') {
122
+ index = (index + 1) % choices.length;
123
+ render();
124
+ }
125
+ else if (key.name === 'return' || key.name === 'enter') {
126
+ finish();
127
+ }
128
+ };
129
+ readline.emitKeypressEvents(input);
130
+ if (input.isTTY && typeof input.setRawMode === 'function') {
131
+ input.setRawMode(true);
132
+ }
133
+ input.resume();
134
+ output.write('\x1b[?25l'); // hide cursor
135
+ input.on('keypress', onKey);
136
+ render();
137
+ });
138
+ }
139
+ /** Interactive yes/no prompt rendered as a two-row arrow-key selection. */
140
+ export async function confirmPrompt(message, defaultValue) {
141
+ const value = await selectPrompt({
142
+ message,
143
+ defaultValue: defaultValue ? 'yes' : 'no',
144
+ choices: [
145
+ { value: 'yes', label: 'Yes' },
146
+ { value: 'no', label: 'No' },
147
+ ],
148
+ });
149
+ return value === 'yes';
150
+ }
package/dist/config-io.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { getAdapterLayout } from './adapters/layouts/index.js';
5
5
  import { compactApprovals, isExpired, mergeApprovalStates } from './core/approval.js';
@@ -42,7 +42,7 @@ export function approvedApprovalsPath(repoRoot, config) {
42
42
  export { belayStateDir };
43
43
  export async function ensureBelayStateDir(config, repoRoot) {
44
44
  const stateDir = belayStateDir(config, repoLocalStateDirFor(repoRoot, config));
45
- await mkdir(stateDir, { recursive: true });
45
+ await mkdir(stateDir, { recursive: true, mode: 0o700 });
46
46
  return stateDir;
47
47
  }
48
48
  const APPROVAL_STATE_FILES = ['pending-approvals.json', 'approved-approvals.json'];
@@ -75,11 +75,15 @@ async function readApprovalStateFile(filePath) {
75
75
  };
76
76
  }
77
77
  async function writeApprovalStateFile(filePath, state) {
78
- await mkdir(path.dirname(filePath), { recursive: true });
79
- await writeFile(filePath, `${JSON.stringify(compactApprovals(state), null, 2)}\n`, 'utf8');
78
+ await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
79
+ await writeFile(filePath, `${JSON.stringify(compactApprovals(state), null, 2)}\n`, {
80
+ encoding: 'utf8',
81
+ mode: 0o600,
82
+ });
83
+ await chmod(filePath, 0o600);
80
84
  }
81
85
  async function migrateApprovalFilesBetween(sourceDir, targetDir) {
82
- await mkdir(targetDir, { recursive: true });
86
+ await mkdir(targetDir, { recursive: true, mode: 0o700 });
83
87
  for (const fileName of APPROVAL_STATE_FILES) {
84
88
  const from = path.join(sourceDir, fileName);
85
89
  const to = path.join(targetDir, fileName);
@@ -88,6 +92,7 @@ async function migrateApprovalFilesBetween(sourceDir, targetDir) {
88
92
  }
89
93
  if (!existsSync(to)) {
90
94
  await copyFile(from, to);
95
+ await chmod(to, 0o600);
91
96
  continue;
92
97
  }
93
98
  const targetState = await readApprovalStateFile(to);
@@ -86,10 +86,22 @@ export const GUARANTEE_SCENARIOS = {
86
86
  reason: 'external_effect',
87
87
  },
88
88
  {
89
+ // L1-full only: shell + tool paths use classifyGatedAction + brokerFsScope.
89
90
  id: 'l1f-deny-outside-repo',
90
91
  command: 'echo hi > ../../outside.txt',
91
92
  permission: 'deny',
92
93
  reason: 'outside_repo_redirect',
93
94
  },
95
+ {
96
+ id: 'l1f-deny-outside-repo-write',
97
+ kind: 'tool',
98
+ command: '',
99
+ toolPayload: {
100
+ tool_name: 'Write',
101
+ tool_input: { path: '../../outside.txt', contents: 'hi' },
102
+ },
103
+ permission: 'deny',
104
+ reason: 'outside_repo_mutation',
105
+ },
94
106
  ],
95
107
  };
@@ -3,4 +3,6 @@ export interface LayerConformanceScenario {
3
3
  command: string;
4
4
  permission: 'allow' | 'deny';
5
5
  reason?: string;
6
+ kind?: 'shell' | 'tool';
7
+ toolPayload?: Record<string, unknown>;
6
8
  }
@@ -0,0 +1,7 @@
1
+ export declare const DEFAULT_CLI_REPLAY_TIMEOUT_MS = 300000;
2
+ export declare function replayShellCommand(command: string, cwd: string, timeoutMs?: number): Promise<{
3
+ exitCode: number;
4
+ stdout: string;
5
+ stderr: string;
6
+ timedOut?: boolean;
7
+ }>;
@@ -0,0 +1,36 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ export const DEFAULT_CLI_REPLAY_TIMEOUT_MS = 300_000;
4
+ export async function replayShellCommand(command, cwd, timeoutMs = DEFAULT_CLI_REPLAY_TIMEOUT_MS) {
5
+ const resolvedCwd = path.resolve(cwd);
6
+ return new Promise((resolve, reject) => {
7
+ const child = spawn(command, {
8
+ cwd: resolvedCwd,
9
+ shell: true,
10
+ stdio: ['ignore', 'pipe', 'pipe'],
11
+ env: { ...process.env },
12
+ });
13
+ const stdout = [];
14
+ const stderr = [];
15
+ let timedOut = false;
16
+ const timer = setTimeout(() => {
17
+ timedOut = true;
18
+ child.kill('SIGTERM');
19
+ }, timeoutMs);
20
+ child.stdout?.on('data', (chunk) => stdout.push(Buffer.from(chunk)));
21
+ child.stderr?.on('data', (chunk) => stderr.push(Buffer.from(chunk)));
22
+ child.on('error', (error) => {
23
+ clearTimeout(timer);
24
+ reject(error);
25
+ });
26
+ child.on('close', (code) => {
27
+ clearTimeout(timer);
28
+ resolve({
29
+ exitCode: timedOut ? 124 : (code ?? 1),
30
+ stdout: Buffer.concat(stdout).toString('utf8'),
31
+ stderr: Buffer.concat(stderr).toString('utf8'),
32
+ ...(timedOut ? { timedOut: true } : {}),
33
+ });
34
+ });
35
+ });
36
+ }
@@ -0,0 +1,45 @@
1
+ import type { ApprovalFlow, BelayConfigV4 } from './config.js';
2
+ import type { ApprovalRecord } from './types.js';
3
+ export type { ApprovalFlow } from './config.js';
4
+ export type ReplayAdapterId = 'cursor' | 'claude' | 'codex';
5
+ export { DEFAULT_CLI_REPLAY_TIMEOUT_MS, replayShellCommand } from './approval-replay-cli.js';
6
+ export interface ApprovalReplayHint {
7
+ kind: 'shell' | 'tool' | 'subagent';
8
+ input: string;
9
+ cwd?: string;
10
+ toolName?: string;
11
+ fingerprint: string;
12
+ approvalId: string;
13
+ autoReplay: boolean;
14
+ fallbackToTwoStep: boolean;
15
+ }
16
+ export interface ReplayEnvelopeInput {
17
+ kind: ApprovalRecord['kind'];
18
+ cwd?: string;
19
+ toolName?: string;
20
+ command?: string;
21
+ input?: string;
22
+ inputKind?: 'shell' | 'tool' | 'subagent';
23
+ payload?: Record<string, unknown>;
24
+ fingerprint: string;
25
+ repoRoot: string;
26
+ }
27
+ export interface ReplayActionContext {
28
+ kind: ApprovalRecord['kind'];
29
+ cwd?: string;
30
+ toolName?: string;
31
+ command?: string;
32
+ payload?: Record<string, unknown>;
33
+ fingerprint: string;
34
+ repoRoot: string;
35
+ }
36
+ export declare function replayPayloadHash(kind: ApprovalRecord['kind'], payload: Record<string, unknown> | undefined, repoRoot: string): string | undefined;
37
+ export declare function getExecutionLeaseMs(config: BelayConfigV4): number;
38
+ export declare function approvalFlow(config: BelayConfigV4): ApprovalFlow;
39
+ export declare function buildReplayEnvelopeFields(input: ReplayEnvelopeInput): Partial<ApprovalRecord>;
40
+ export declare function hasReplayEnvelope(approval: ApprovalRecord): boolean;
41
+ export declare function validateReplayEnvelope(approval: ApprovalRecord, action: ReplayActionContext): boolean;
42
+ export declare function canAutoReplay(config: BelayConfigV4, kind: ApprovalRecord['kind'], adapter?: ReplayAdapterId): boolean;
43
+ export declare function buildRetryInstructionForConfig(config: BelayConfigV4, tokenPrefix: string, approvalId: string): string;
44
+ export declare function buildApprovalRecordedMessage(config: BelayConfigV4, approval: ApprovalRecord, adapter?: ReplayAdapterId): string;
45
+ export declare function buildReplayHint(config: BelayConfigV4, approval: ApprovalRecord, adapter?: ReplayAdapterId): ApprovalReplayHint | null;