opstruth 0.1.1 → 0.1.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
@@ -6,13 +6,15 @@ Read-only operational truth checks for AI-assisted engineering workflows.
6
6
 
7
7
  ## Install
8
8
 
9
- After npm publication:
9
+ Current published package:
10
10
 
11
11
  ```bash
12
12
  npm install -g opstruth
13
13
  opstruth
14
14
  ```
15
15
 
16
+ The public npm package is `opstruth@0.1.2`.
17
+
16
18
  One-off usage:
17
19
 
18
20
  ```bash
@@ -24,18 +26,37 @@ npx opstruth
24
26
  ```bash
25
27
  opstruth
26
28
  opstruth welcome
29
+ opstruth init --yes
27
30
  opstruth probes
28
31
  opstruth secrets
29
32
  opstruth routes --base-url https://example.com
30
33
  opstruth local --port 3000 --health /health
34
+ opstruth --json
35
+ opstruth --no-color
31
36
  ```
32
37
 
38
+ ## Terminal Output
39
+
40
+ Human output uses a restrained colour theme for status, warnings, proof gaps, evidence, and next safe steps when the terminal supports ANSI colour.
41
+
42
+ Use `--no-color` or `NO_COLOR=1` to disable colour. Use `--color` to force colour for demos. JSON output remains machine-readable and does not include ANSI codes. Evidence markdown output remains ANSI-free.
43
+
33
44
  ## Safety Model
34
45
 
35
46
  opstruth is read-only by default. CLI checks do not deploy, mutate databases, trigger queues or jobs, call OpenAI, restart services, publish content, or print raw secrets.
36
47
 
37
48
  Skipped checks and not-verified areas are reported as proof gaps instead of being treated as safe.
38
49
 
50
+ ## Configuration
51
+
52
+ `opstruth.config.json` can provide route paths, local ports/health paths, and secret allowlists. Generate a starter config:
53
+
54
+ ```bash
55
+ opstruth init --yes
56
+ ```
57
+
58
+ CLI flags remain the clearest way to provide runtime inputs.
59
+
39
60
  ## Repository
40
61
 
41
62
  Source, docs, and release evidence live at:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opstruth",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Read-only operational truth checks for AI-assisted engineering workflows.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -12,6 +12,7 @@ import { runLocal } from './commands/local.js';
12
12
  import { runEvidence } from './commands/evidence.js';
13
13
  import { runProbes } from './commands/probes.js';
14
14
  import { resultToMarkdown } from './lib/markdown.js';
15
+ import { formatTerminalOutput } from './lib/terminal.js';
15
16
  import { writeFileSafe } from './lib/fs.js';
16
17
  import { redactObject } from './lib/redact.js';
17
18
  import { exitCodeFor } from './lib/result.js';
@@ -26,6 +27,8 @@ export function parseArgs(argv) {
26
27
  if (!arg.startsWith('-') && !command && COMMANDS.has(arg)) { command = arg; continue; }
27
28
  if (arg === '--help' || arg === '-h') options.help = true;
28
29
  if (arg === '--json') options.json = true;
30
+ else if (arg === '--color') options.color = true;
31
+ else if (arg === '--no-color') options.noColor = true;
29
32
  else if (arg === '--out') options.out = take(argv, i++);
30
33
  else if (arg === '--base-url') options.baseUrl = take(argv, i++);
31
34
  else if (arg === '--routes') options.routesFile = take(argv, i++);
@@ -108,32 +111,47 @@ function helpText(command) {
108
111
  ' --out <file> Write command output/evidence',
109
112
  ' --skip <area|id> Skip a command or probe area',
110
113
  ' --only <area|id> Select a probe area or id',
114
+ ' --color Force colour for human terminal output',
115
+ ' --no-color Disable colour for human terminal output',
111
116
  ' -h, --help Print help and exit 0'
112
117
  ];
113
- const route = [
114
- ASCII_HEADER,
115
- 'Usage: opstruth routes --base-url <url> [--routes file] [--strict]',
116
- '',
117
- 'Read-only route probes collect URL, method, status, latency, redirects, and security-header evidence.',
118
- '',
119
- 'Options:',
120
- ' --base-url <url> Base URL to probe',
121
- ' --routes <file> JSON route config',
122
- ' --json Print JSON output',
123
- ' -h, --help Print help and exit 0'
124
- ];
125
- const repo = [
126
- ASCII_HEADER,
127
- 'Usage: opstruth repo [--json] [--strict]',
128
- '',
129
- 'Read-only repository inspection reports cwd, git root, branch, latest commit, dirty files, and detected stack.',
130
- '',
131
- 'Options:',
132
- ' --json Print JSON output',
133
- ' -h, --help Print help and exit 0'
134
- ];
135
- if (command === 'routes') return route.join('\n') + '\n';
136
- if (command === 'repo') return repo.join('\n') + '\n';
118
+ const commandHelp = {
119
+ repo: ['Usage: opstruth repo [--json] [--strict]', 'Inspect cwd, git root, branch, latest commit, dirty files, and detected stack.'],
120
+ quality: ['Usage: opstruth quality [--script name] [--continue] [--json]', 'Run only existing safe package scripts; missing scripts and npm placeholder tests are skipped.'],
121
+ routes: ['Usage: opstruth routes --base-url <url> [--routes file] [--json]', 'Collect read-only URL, method, status, latency, redirect, and header evidence.'],
122
+ secrets: ['Usage: opstruth secrets [--json]', 'Scan source text for risky secret/auth references with redacted previews; .env contents are skipped.'],
123
+ supabase: ['Usage: opstruth supabase [--protected-table name] [--frontend-dir dir] [--migrations-dir dir]', 'Run a static Supabase migration/frontend exposure audit without credentials or database calls.'],
124
+ cloudflare: ['Usage: opstruth cloudflare [--url https://example.com] [--json]', 'Inspect Wrangler config, deploy scripts, and optional read-only route status; no deploy is run.'],
125
+ local: ['Usage: opstruth local --port 3000 [--health /health] [--process name] [--service name]', 'Check explicit local runtime inputs without starting, stopping, or killing services.'],
126
+ probes: ['Usage: opstruth probes [--json] [--only area|id] [--skip area|id]', 'Inspect probe catalogue metadata, eligible probes, skipped probes, required inputs, and proof gaps.'],
127
+ evidence: ['Usage: opstruth evidence [--title text] [--out evidence/opstruth.md] [--include file]', 'Write a markdown evidence pack with verified facts, proof gaps, boundaries, and next safe step.'],
128
+ init: ['Usage: opstruth init [--yes]', 'Create a safe starter opstruth.config.json after confirmation.']
129
+ };
130
+ if (commandHelp[command]) {
131
+ const [usage, description] = commandHelp[command];
132
+ return [
133
+ ASCII_HEADER,
134
+ usage,
135
+ '',
136
+ description,
137
+ '',
138
+ 'Examples:',
139
+ ' opstruth',
140
+ ' opstruth --base-url https://example.com',
141
+ ' opstruth routes --base-url https://example.com',
142
+ ' opstruth local --port 3000 --health /health',
143
+ ' opstruth --json',
144
+ ' opstruth --no-color',
145
+ ' opstruth --color',
146
+ '',
147
+ 'Options:',
148
+ ' --json Print JSON output',
149
+ ' --strict Treat warnings/skips as failing confidence',
150
+ ' --color Force colour for human terminal output',
151
+ ' --no-color Disable colour for human terminal output',
152
+ ' -h, --help Print help and exit 0'
153
+ ].join('\n') + '\n';
154
+ }
137
155
  return common.join('\n') + '\n';
138
156
  }
139
157
 
@@ -156,8 +174,17 @@ function welcomeText() {
156
174
  '- opstruth --strict',
157
175
  '- opstruth routes --base-url https://example.com',
158
176
  '- opstruth local --port 3000 --health /health',
177
+ '- opstruth --json',
178
+ '- opstruth --no-color',
179
+ '- opstruth --color',
159
180
  '- opstruth evidence --title "Release proof"',
160
181
  '',
182
+ 'Improving confidence:',
183
+ '- provide --base-url or route config when route proof matters',
184
+ '- provide --port and --health when local runtime proof matters',
185
+ '- run inside a git repo for stronger change evidence',
186
+ '- attach evidence/opstruth-report.md to reviews or CI artifacts',
187
+ '',
161
188
  'Safety philosophy:',
162
189
  'opstruth prefers skipped or not verified over pretending something is safe. Dangerous actions require explicit approval and are not part of the default run.',
163
190
  ''
@@ -165,27 +192,34 @@ function welcomeText() {
165
192
  }
166
193
 
167
194
  const INIT_CONFIG = {
168
- routes: {
169
- baseUrl: '',
170
- paths: ['/', '/login', '/healthz'],
171
- requiredHeaders: [
172
- 'content-security-policy',
173
- 'strict-transport-security',
174
- 'x-frame-options',
175
- 'referrer-policy'
176
- ]
177
- },
195
+ projectName: 'example',
196
+ routes: [
197
+ { path: '/', expectedStatus: 200 },
198
+ { path: '/health', expectedStatus: 200 }
199
+ ],
178
200
  local: {
179
201
  ports: [],
180
- healthPath: '/health'
202
+ healthPaths: ['/health']
203
+ },
204
+ quality: {
205
+ skipScripts: [],
206
+ requiredScripts: []
207
+ },
208
+ secrets: {
209
+ allowlistPaths: [],
210
+ allowlistPatterns: []
181
211
  },
182
212
  supabase: {
213
+ enabled: true,
183
214
  protectedTables: [
184
215
  'agent_jobs',
185
216
  'platform_credentials',
186
217
  'worker_logs'
187
218
  ]
188
219
  },
220
+ cloudflare: {
221
+ enabled: true
222
+ },
189
223
  ignore: [
190
224
  '.cache',
191
225
  '.agents',
@@ -234,13 +268,13 @@ export async function runCli(argv, cwd = process.cwd()) {
234
268
  const { command, options } = parseArgs(argv);
235
269
  options.cwd = cwd;
236
270
  if (options.help) {
237
- await writeStdout(helpText(command));
271
+ await writeStdout(formatTerminalOutput(helpText(command), options));
238
272
  process.exitCode = 0;
239
273
  return;
240
274
  }
241
275
  const result = await dispatch(command, options);
242
276
  if (typeof result === 'string') {
243
- await writeStdout(result);
277
+ await writeStdout(formatTerminalOutput(result, options));
244
278
  process.exitCode = 0;
245
279
  return;
246
280
  }
@@ -249,6 +283,6 @@ export async function runCli(argv, cwd = process.cwd()) {
249
283
  const outPath = path.isAbsolute(options.out) ? options.out : path.join(cwd, options.out);
250
284
  await writeFileSafe(outPath, output);
251
285
  }
252
- await writeStdout(output);
286
+ await writeStdout(options.json ? output : formatTerminalOutput(output, options));
253
287
  process.exitCode = exitCodeFor(result, { strict: options.strict });
254
288
  }
@@ -1,9 +1,15 @@
1
1
  import { createFinding, createResult, finalizeStatus } from '../lib/result.js';
2
2
  import { runCommand } from '../lib/exec.js';
3
3
  import { probeUrl } from '../lib/http.js';
4
+ import { loadOpstruthConfig } from '../lib/config.js';
4
5
 
5
6
  export async function runLocal({ cwd = process.cwd(), port = [], health = '/', process: processName, service, strict = false } = {}) {
6
- const ports = Array.isArray(port) ? port : [port].filter(Boolean);
7
+ const loaded = await loadOpstruthConfig(cwd);
8
+ const configLocal = loaded.config?.local || {};
9
+ const ports = (Array.isArray(port) && port.length ? port : Array.isArray(configLocal.ports) ? configLocal.ports : [port].filter(Boolean));
10
+ const healthPaths = Array.isArray(configLocal.healthPaths) ? configLocal.healthPaths : [configLocal.healthPath].filter(Boolean);
11
+ if ((!health || health === '/') && healthPaths.length) health = healthPaths[0];
12
+ if (loaded.warning && !ports.length && !processName && !service) return createResult('local', 'warn', { warnings: [loaded.warning], notVerified: ['Local runtime liveness'], nextSafeStep: 'Fix opstruth.config.json or pass --port and --health explicitly.' });
7
13
  if (!ports.length && !processName && !service) return createResult('local', 'skipped', { skipped: ['Local runtime checks skipped because no --port, --process, or --service was provided'], notVerified: ['Local runtime liveness'], nextSafeStep: 'Run opstruth local --port 3000 --health /health when the app is running.' });
8
14
  const checks = [];
9
15
  const warnings = [];
@@ -13,6 +13,29 @@ function summarizeCounts(label, counts) {
13
13
  return `${label}: ${Object.entries(counts).map(([name, count]) => `${name}=${count}`).join(', ')}`;
14
14
  }
15
15
 
16
+ function probeJson(probe) {
17
+ return {
18
+ id: probe.id,
19
+ name: probe.name,
20
+ area: probe.area,
21
+ stack: probe.stack,
22
+ mode: probe.mode,
23
+ safetyLevel: probe.safetyLevel,
24
+ defaultMode: probe.defaultMode,
25
+ mutability: probe.mutability,
26
+ inputsRequired: probe.inputsRequired,
27
+ evidenceCollected: probe.evidenceCollected,
28
+ evidenceExpectation: probe.evidenceExpectation,
29
+ proves: probe.proves,
30
+ doesNotProve: probe.doesNotProve,
31
+ proofLimitation: probe.proofLimitation,
32
+ skipReason: probe.skipReason,
33
+ nextSafeStep: probe.nextSafeStep,
34
+ supportedStacks: probe.supportedStacks,
35
+ notVerified: probe.notVerified
36
+ };
37
+ }
38
+
16
39
  export async function runProbes({ cwd = process.cwd(), strict = false, skip = [], only = [] } = {}) {
17
40
  const boundary = await resolveProjectBoundary(cwd);
18
41
  const stack = await detectStack(boundary.root);
@@ -20,6 +43,7 @@ export async function runProbes({ cwd = process.cwd(), strict = false, skip = []
20
43
  const byArea = countBy(PROBE_CATALOGUE, 'area');
21
44
  const byMode = countBy(PROBE_CATALOGUE, 'defaultMode');
22
45
  const bySafety = countBy(PROBE_CATALOGUE, 'safetyLevel');
46
+ const explicitInputProbes = PROBE_CATALOGUE.filter((probe) => probe.inputsRequired?.length);
23
47
  const result = createResult('probes', 'pass', {
24
48
  summary: 'Probe catalogue inspected for the current project without running mutating actions.',
25
49
  verified: [
@@ -28,19 +52,20 @@ export async function runProbes({ cwd = process.cwd(), strict = false, skip = []
28
52
  summarizeCounts('Probes by default mode', byMode),
29
53
  summarizeCounts('Probes by safety level', bySafety),
30
54
  'Detected safe automatic probes for this project: ' + selection.selected.length,
55
+ 'Explicit input probes: ' + explicitInputProbes.map((probe) => `${probe.id} (${probe.inputsRequired.join(' + ')})`).join('; '),
31
56
  'Project boundary: ' + boundary.root,
32
57
  'Detected platforms: ' + (stack.platforms.length ? stack.platforms.join(', ') : 'none')
33
58
  ],
34
59
  skipped: [
35
60
  ...(boundary.message ? [boundary.message] : []),
36
- ...selection.skipped.slice(0, 20).map((probe) => `${probe.id}: ${probe.reason}`)
61
+ ...selection.skipped.slice(0, 20).map((probe) => `${probe.id}: ${probe.reason}; next: ${probe.nextSafeStep}`)
37
62
  ],
38
63
  checks: selection.selected.map((probe) => ({
39
64
  name: probe.id,
40
65
  status: 'pass',
41
66
  message: `${probe.area}/${probe.stack}: ${probe.name}`
42
67
  })),
43
- notVerified: ['Probe catalogue inspection does not run route, local runtime, deploy, or external service checks by itself.'],
68
+ notVerified: ['Probe catalogue inspection does not run route, local runtime, deploy, or external service checks by itself.', 'Skipped probes are proof gaps, not failures.'],
44
69
  data: {
45
70
  boundary,
46
71
  stack,
@@ -48,19 +73,12 @@ export async function runProbes({ cwd = process.cwd(), strict = false, skip = []
48
73
  byArea,
49
74
  byMode,
50
75
  bySafety,
51
- detected: selection.selected.map((probe) => ({
52
- id: probe.id,
53
- name: probe.name,
54
- area: probe.area,
55
- stack: probe.stack,
56
- safetyLevel: probe.safetyLevel,
57
- defaultMode: probe.defaultMode,
58
- evidenceCollected: probe.evidenceCollected,
59
- proves: probe.proves,
60
- doesNotProve: probe.doesNotProve,
61
- nextSafeStep: probe.nextSafeStep
62
- })),
63
- skipped: selection.skipped.map((probe) => ({ id: probe.id, area: probe.area, stack: probe.stack, reason: probe.reason }))
76
+ catalogue: PROBE_CATALOGUE.map(probeJson),
77
+ detected: selection.selected.map(probeJson),
78
+ skipped: selection.skipped.map((probe) => ({
79
+ ...probeJson(probe),
80
+ reason: probe.reason
81
+ }))
64
82
  },
65
83
  nextSafeStep: 'Run opstruth for selected safe probes, or add explicit route/local inputs for stronger runtime evidence.'
66
84
  });
@@ -8,12 +8,12 @@ export async function runRoutes({ cwd = process.cwd(), baseUrl, routesFile, stri
8
8
  const boundary = await resolveProjectBoundary(cwd);
9
9
  cwd = boundary.root;
10
10
  let config = routesFile ? await loadRoutesConfig(cwd, routesFile) : null;
11
- if (config?.routes?.baseUrl !== undefined || config?.routes?.paths) config = config.routes;
12
- if (!config) config = (await findDefaultRoutesConfig(cwd))?.config;
11
+ const defaultConfig = config ? null : await findDefaultRoutesConfig(cwd);
12
+ if (!config) config = defaultConfig?.config;
13
+ if (defaultConfig?.warning) return createResult('routes', 'warn', { warnings: [defaultConfig.warning], notVerified: ['Public route availability'], nextSafeStep: 'Fix opstruth.config.json or pass --routes with valid JSON.' });
13
14
  const finalBase = baseUrl || config?.baseUrl;
14
15
  if (!finalBase) return createResult('routes', 'skipped', { skipped: ['Route checks skipped because no --base-url or route config was provided'], notVerified: ['Public route availability'], nextSafeStep: 'Run opstruth routes --base-url https://example.com or provide --routes.' });
15
- const paths = config?.paths?.length ? config.paths.map((routePath) => ({ path: routePath, method: routePath.includes('health') ? 'GET' : 'HEAD', expectStatus: [200, 301, 302] })) : [];
16
- const routes = config?.routes?.length ? config.routes : paths.length ? paths : [{ path: '/', method: 'HEAD', expectStatus: [200, 301, 302] }];
16
+ const routes = config?.routes?.length ? config.routes : [{ path: '/', method: 'HEAD', expectStatus: [200, 301, 302] }];
17
17
  const requiredHeaders = config?.requiredHeaders || DEFAULT_HEADERS;
18
18
  const checks = [];
19
19
  const warnings = [];
@@ -1,10 +1,16 @@
1
1
  import { createFinding, createResult, finalizeStatus } from '../lib/result.js';
2
2
  import { scanRiskyReferences } from '../lib/scan.js';
3
3
  import { resolveProjectBoundary } from '../lib/boundary.js';
4
+ import { loadOpstruthConfig } from '../lib/config.js';
4
5
 
5
6
  export async function runSecrets({ cwd = process.cwd(), strict = false } = {}) {
6
7
  const boundary = await resolveProjectBoundary(cwd);
7
- const findings = await scanRiskyReferences(boundary.root);
8
+ const loaded = await loadOpstruthConfig(boundary.root);
9
+ const secretConfig = loaded.config?.secrets || {};
10
+ const findings = await scanRiskyReferences(boundary.root, {
11
+ allowlistPaths: secretConfig.allowlistPaths || [],
12
+ allowlistPatterns: secretConfig.allowlistPatterns || []
13
+ });
8
14
  const findingObjects = findings.map((item) => createFinding({
9
15
  status: 'warn',
10
16
  area: 'secrets',
@@ -14,19 +20,21 @@ export async function runSecrets({ cwd = process.cwd(), strict = false } = {}) {
14
20
  'file: ' + item.file,
15
21
  'line: ' + item.line,
16
22
  'pattern: ' + item.pattern,
23
+ 'kind: ' + item.kind,
24
+ 'context: ' + item.context,
17
25
  'redacted preview: ' + item.preview
18
26
  ],
19
27
  whyItMatters: 'Secret-like values and service-role references can create account, data, or infrastructure exposure if committed or exposed to browsers.',
20
28
  nextSafeStep: 'Confirm whether this is a harmless reference. Move real secrets to secret storage and keep only names/placeholders in source.'
21
29
  }));
22
- const result = createResult('secrets', findings.length ? 'warn' : 'pass', {
30
+ const result = createResult('secrets', loaded.warning || findings.length ? 'warn' : 'pass', {
23
31
  summary: 'Redacted risky secret/reference scan completed. .env file contents are skipped.',
24
32
  verified: ['Project boundary scanned: ' + boundary.root, 'Source files scanned with redaction', '.env contents were not printed'],
25
- warnings: findingObjects.map((finding) => finding.finding),
33
+ warnings: [...(loaded.warning ? [loaded.warning] : []), ...findingObjects.map((finding) => finding.finding)],
26
34
  findings: findingObjects,
27
35
  skipped: boundary.message ? [boundary.message] : [],
28
36
  checks: [{ name: 'secret reference scan', status: findings.length ? 'warn' : 'pass', message: findings.length + ' finding(s)' }],
29
- data: { boundary, findings },
37
+ data: { boundary, configFile: loaded.file, allowlistPaths: secretConfig.allowlistPaths || [], allowlistPatterns: secretConfig.allowlistPatterns || [], findings },
30
38
  nextSafeStep: findings.length ? 'Review whether each reference is expected and move real secrets to safe storage.' : 'Keep secrets out of source and rerun before publishing.'
31
39
  });
32
40
  return finalizeStatus(result, { strict });
package/src/lib/config.js CHANGED
@@ -1,18 +1,56 @@
1
1
  import path from 'node:path';
2
2
  import { pathExists, readJson, readText } from './fs.js';
3
3
 
4
+ export async function loadOpstruthConfig(root) {
5
+ const full = path.join(root, 'opstruth.config.json');
6
+ if (!(await pathExists(full))) return { config: null, warning: null, file: null };
7
+ try {
8
+ return { config: await readJson(full), warning: null, file: 'opstruth.config.json' };
9
+ } catch (error) {
10
+ return { config: null, warning: 'Invalid opstruth.config.json: ' + error.message, file: 'opstruth.config.json' };
11
+ }
12
+ }
13
+
14
+ export function normalizeRoutesConfig(config) {
15
+ if (!config) return null;
16
+ const routesConfig = config.routes?.baseUrl !== undefined || config.routes?.paths || Array.isArray(config.routes)
17
+ ? config.routes
18
+ : config;
19
+ const normalizeRoute = (route) => ({
20
+ ...route,
21
+ expectStatus: route.expectStatus || (Number.isFinite(route.expectedStatus) ? [route.expectedStatus] : route.expectedStatus)
22
+ });
23
+ if (Array.isArray(routesConfig)) return { routes: routesConfig.map(normalizeRoute) };
24
+ if (Array.isArray(routesConfig?.routes)) return { ...routesConfig, routes: routesConfig.routes.map(normalizeRoute) };
25
+ if (Array.isArray(routesConfig?.paths)) {
26
+ return {
27
+ ...routesConfig,
28
+ routes: routesConfig.paths.map((routePath) => ({
29
+ path: routePath,
30
+ method: String(routePath).includes('health') ? 'GET' : 'HEAD',
31
+ expectStatus: [200, 301, 302]
32
+ }))
33
+ };
34
+ }
35
+ return routesConfig;
36
+ }
37
+
4
38
  export async function loadRoutesConfig(root, file) {
5
39
  if (!file) return null;
6
40
  const full = path.isAbsolute(file) ? file : path.join(root, file);
7
41
  if (!(await pathExists(full))) return null;
8
- return readJson(full);
42
+ return normalizeRoutesConfig(await readJson(full));
9
43
  }
10
44
  export async function findDefaultRoutesConfig(root) {
11
45
  for (const file of ['opstruth.config.json', 'opstruth.routes.json', 'routes.json']) {
12
46
  const full = path.join(root, file);
13
47
  if (await pathExists(full)) {
14
- const config = await readJson(full);
15
- return { file, config: config.routes?.baseUrl !== undefined || config.routes?.paths ? config.routes : config };
48
+ try {
49
+ const config = await readJson(full);
50
+ return { file, config: normalizeRoutesConfig(config) };
51
+ } catch (error) {
52
+ return { file, config: null, warning: 'Invalid route config ' + file + ': ' + error.message };
53
+ }
16
54
  }
17
55
  }
18
56
  return null;
@@ -9,7 +9,7 @@ const ASCII_HEADER = ` ____ _______ __ __
9
9
 
10
10
  Operational truth checks for AI-assisted engineering.`;
11
11
 
12
- function statusLabel(status) {
12
+ export function statusLabel(status) {
13
13
  if (status === 'pass') return 'Pass';
14
14
  if (status === 'warn') return 'Partial pass';
15
15
  if (status === 'fail') return 'Fail';
@@ -178,9 +178,24 @@ export function evidenceMarkdown({ title = 'opstruth Evidence Pack', status = 'n
178
178
  '## Check Results',
179
179
  list(checks, '- No checks attached', 25),
180
180
  '',
181
+ '## Probe Results',
182
+ list(checks, '- No probe results attached', 25),
183
+ '',
181
184
  '## Verified Facts',
182
185
  list(liveVerification, '- No live verification evidence attached', 20),
183
186
  '',
187
+ '## Warnings',
188
+ list(risks.filter((risk) => /warn|warning|review/i.test(risk)), '- None recorded', 20),
189
+ '',
190
+ '## Failures',
191
+ list(risks.filter((risk) => /fail|failure|blocked/i.test(risk)), '- None recorded', 20),
192
+ '',
193
+ '## Skipped / Not Configured',
194
+ 'Skipped checks are proof gaps, not failures. See command output for skipped probe IDs and reasons.',
195
+ '',
196
+ '## Not Verified',
197
+ 'Production, local runtime, database state, queues, publishing, and external AI usage are not verified unless explicit read-only inputs or external evidence are attached.',
198
+ '',
184
199
  '## Risks And Gaps',
185
200
  table(['Severity', 'Finding'], riskRows.slice(0, 25)),
186
201
  '',
@@ -196,6 +211,12 @@ export function evidenceMarkdown({ title = 'opstruth Evidence Pack', status = 'n
196
211
  '## Safety Boundaries',
197
212
  list(safetyBoundaries, '- Read-only checks only'),
198
213
  '',
214
+ '## Evidence Files / Paths',
215
+ list(scope.concat(filesChanged).filter(Boolean), '- No evidence paths attached', 30),
216
+ '',
217
+ '## Confidence',
218
+ confidenceFor({ status, failures: risks.filter((risk) => /fail|failure|blocked/i.test(risk)), warnings: risks, skipped: [], notVerified: [] }),
219
+ '',
199
220
  '## Next Safe Step',
200
221
  nextSafeStep || 'Run the narrowest missing read-only verification and attach the result.'
201
222
  ].join('\n') + '\n';
package/src/lib/probes.js CHANGED
@@ -10,7 +10,7 @@ function nodeDependencyDetector(name) {
10
10
  return async (_root, stack) => stack.dependencies?.includes(name);
11
11
  }
12
12
 
13
- export const PROBE_CATALOGUE = [
13
+ const RAW_PROBE_CATALOGUE = [
14
14
  {
15
15
  id: 'git.status',
16
16
  name: 'Git status',
@@ -463,6 +463,48 @@ export const PROBE_CATALOGUE = [
463
463
  }
464
464
  ];
465
465
 
466
+ function inputsRequiredFor(probe) {
467
+ if (probe.id.startsWith('routes.')) return ['--base-url or route config'];
468
+ if (probe.id === 'local.ports') return ['--port or local config'];
469
+ if (probe.id === 'local.health') return ['--port and --health or local config'];
470
+ if (probe.id === 'supabase.migrations') return ['supabase/migrations directory'];
471
+ if (probe.id === 'cloudflare.wrangler') return ['wrangler.toml, wrangler.json, or wrangler.jsonc'];
472
+ if (probe.id.startsWith('quality.')) return ['matching package.json script'];
473
+ if (probe.id.startsWith('node.')) return ['matching package metadata/config/source'];
474
+ if (probe.id.startsWith('git.')) return ['git repository'];
475
+ return [];
476
+ }
477
+
478
+ function skipReasonFor(probe) {
479
+ if (probe.id.startsWith('routes.')) return 'Requires --base-url, --routes, or opstruth.config.json route entries';
480
+ if (probe.id === 'local.ports') return 'Requires --port or opstruth.config.json local ports';
481
+ if (probe.id === 'local.health') return 'Requires --port with --health or opstruth.config.json local health paths';
482
+ if (probe.id === 'supabase.migrations') return 'Requires a Supabase migrations directory';
483
+ if (probe.id === 'cloudflare.wrangler') return 'Requires Wrangler configuration';
484
+ if (probe.id.startsWith('quality.')) return 'Requires a matching non-placeholder package.json script';
485
+ if (probe.id.startsWith('git.')) return 'Requires a git repository';
486
+ return 'Not relevant to detected stack or missing configuration';
487
+ }
488
+
489
+ function normalizeProbe(probe) {
490
+ const inputsRequired = probe.inputsRequired || inputsRequiredFor(probe);
491
+ return {
492
+ ...probe,
493
+ mode: probe.mode || probe.defaultMode,
494
+ mutability: probe.mutability || 'none',
495
+ inputsRequired,
496
+ evidenceExpectation: probe.evidenceExpectation || probe.evidenceCollected || [],
497
+ skipReason: probe.skipReason || skipReasonFor(probe),
498
+ proofLimitation: probe.proofLimitation || probe.doesNotProve,
499
+ supportedStacks: probe.supportedStacks || [probe.stack],
500
+ notVerified: probe.notVerified || [probe.doesNotProve],
501
+ falsePositiveRisk: probe.falsePositiveRisk || 'Low to medium; depends on project conventions and fixture/demo content.',
502
+ falseNegativeRisk: probe.falseNegativeRisk || 'Does not prove absence outside scanned files, configured inputs, or supported stack heuristics.'
503
+ };
504
+ }
505
+
506
+ export const PROBE_CATALOGUE = RAW_PROBE_CATALOGUE.map(normalizeProbe);
507
+
466
508
  export async function selectProbes({ root, stack, boundary, options = {} }) {
467
509
  const only = new Set(options.only || []);
468
510
  const skip = new Set(options.skip || []);
@@ -483,7 +525,7 @@ export async function selectProbes({ root, stack, boundary, options = {} }) {
483
525
  }
484
526
  const relevant = await probe.detector(root, stack, boundary, options);
485
527
  if (relevant) selected.push(probe);
486
- else skipped.push({ ...probe, reason: 'Not relevant to detected stack or missing configuration' });
528
+ else skipped.push({ ...probe, reason: probe.skipReason || 'Not relevant to detected stack or missing configuration' });
487
529
  }
488
530
  return { selected, skipped, catalogueSize: PROBE_CATALOGUE.length };
489
531
  }
package/src/lib/scan.js CHANGED
@@ -10,21 +10,79 @@ const OPSTRUTH_SCANNER_FILES = new Set([
10
10
  'src/lib/scan.js',
11
11
  'src/lib/probes.js',
12
12
  'test/typescript-compatibility.test.js',
13
+ 'cli/src/lib/redact.js',
14
+ 'cli/src/lib/scan.js',
15
+ 'cli/src/lib/probes.js',
16
+ 'cli/test/typescript-compatibility.test.js',
13
17
  'fixtures/risky-secret-app/src/config.js'
14
18
  ]);
19
+ const FIXTURE_PACKAGE_NAMES = new Set([
20
+ 'plain-node-app',
21
+ 'vite-react-app',
22
+ 'next-app',
23
+ 'tanstack-app',
24
+ 'cloudflare-worker-app',
25
+ 'supabase-app',
26
+ 'default-npm-placeholder-test',
27
+ 'failing-real-test-script',
28
+ 'risky-secret-app',
29
+ 'missing-build-script',
30
+ 'route-config-app'
31
+ ]);
15
32
 
16
33
  export function isLikelyText(file) { return /\.(js|mjs|cjs|ts|tsx|jsx|json|jsonc|toml|yml|yaml|md|txt|env|sql|html|css)$/i.test(file) || !path.extname(file); }
17
34
 
35
+ function matchesAllowlist(file, line, { allowlistPaths = [], allowlistPatterns = [] } = {}) {
36
+ if (allowlistPaths.some((item) => file === item || file.startsWith(item.replace(/\/$/, '') + '/'))) return true;
37
+ return allowlistPatterns.some((pattern) => {
38
+ try { return new RegExp(pattern).test(line) || new RegExp(pattern).test(file); } catch { return false; }
39
+ });
40
+ }
41
+
42
+ function classifySecretLine(line) {
43
+ if (/[=:]\s*["']?[^"'\s;]+/.test(line)) return 'secret-like value';
44
+ return 'secret reference';
45
+ }
46
+
47
+ function classifySourceContext(file, rootContext = 'source file') {
48
+ if (file.startsWith('fixtures/') || file.startsWith('cli/fixtures/')) return 'fixture/demo file';
49
+ if (file.startsWith('docs/') || file.startsWith('cli/docs/')) return 'documentation reference';
50
+ return rootContext;
51
+ }
52
+
53
+ async function classifyRootContext(root) {
54
+ const packageFile = path.join(root, 'package.json');
55
+ if (!(await pathExists(packageFile))) return 'source file';
56
+ try {
57
+ const name = (await readJson(packageFile)).name;
58
+ return FIXTURE_PACKAGE_NAMES.has(name) ? 'fixture/demo file' : 'source file';
59
+ } catch {
60
+ return 'source file';
61
+ }
62
+ }
63
+
18
64
  async function isOpstruthRoot(root) {
19
65
  const packageFile = path.join(root, 'package.json');
20
- if (!(await pathExists(packageFile))) return false;
21
- try { return (await readJson(packageFile)).name === 'opstruth'; } catch { return false; }
66
+ const cliPackageFile = path.join(root, 'cli/package.json');
67
+ try {
68
+ if (await pathExists(packageFile)) {
69
+ const name = (await readJson(packageFile)).name;
70
+ if (name === 'opstruth' || name === 'opstruth-monorepo') return true;
71
+ }
72
+ if (await pathExists(cliPackageFile)) {
73
+ return (await readJson(cliPackageFile)).name === 'opstruth';
74
+ }
75
+ } catch {
76
+ return false;
77
+ }
78
+ return false;
22
79
  }
23
80
 
24
- export async function scanRiskyReferences(root, { skipDirs = DEFAULT_SKIP_DIRS } = {}) {
81
+ export async function scanRiskyReferences(root, { skipDirs = DEFAULT_SKIP_DIRS, allowlistPaths = [], allowlistPatterns = [] } = {}) {
25
82
  const files = await walkFiles(root, { skipDirs: mergeIgnores(skipDirs) });
26
83
  const findings = [];
27
84
  const suppressInternalScannerDefinitions = await isOpstruthRoot(root);
85
+ const rootContext = await classifyRootContext(root);
28
86
  for (const file of files) {
29
87
  if (suppressInternalScannerDefinitions && OPSTRUTH_SCANNER_FILES.has(file.rel)) continue;
30
88
  if (!isLikelyText(file.rel)) continue;
@@ -33,6 +91,7 @@ export async function scanRiskyReferences(root, { skipDirs = DEFAULT_SKIP_DIRS }
33
91
  try { text = await readText(file.full); } catch { continue; }
34
92
  const lines = text.split(/\r?\n/);
35
93
  lines.forEach((line, index) => {
94
+ if (matchesAllowlist(file.rel, line, { allowlistPaths, allowlistPatterns })) return;
36
95
  for (const pattern of RISK_PATTERNS) {
37
96
  if (pattern.test(line)) {
38
97
  findings.push({
@@ -40,6 +99,8 @@ export async function scanRiskyReferences(root, { skipDirs = DEFAULT_SKIP_DIRS }
40
99
  line: index + 1,
41
100
  pattern: pattern.source.replaceAll('\\', ''),
42
101
  match: pattern.source.replaceAll('\\', ''),
102
+ kind: classifySecretLine(line),
103
+ context: classifySourceContext(file.rel, rootContext),
43
104
  preview: redact(line.trim()).slice(0, 160),
44
105
  excerpt: redact(line.trim()).slice(0, 160),
45
106
  severity: 'review'
@@ -0,0 +1,119 @@
1
+ const ANSI_PATTERN = /\x1B\[[0-?]*[ -/]*[@-~]/g;
2
+
3
+ const RESET = '\x1b[0m';
4
+
5
+ // Website-derived terminal approximations from website/src/styles.css.
6
+ // The website tokens are OKLCH; these RGB values keep the same calm dark,
7
+ // muted, evidence-first feel in common ANSI terminals.
8
+ export const THEME = {
9
+ heading: ['\x1b[1m', '\x1b[38;2;235;237;240m'],
10
+ muted: ['\x1b[38;2;150;154;163m'],
11
+ accent: ['\x1b[38;2;126;190;164m'],
12
+ success: ['\x1b[38;2;104;194;142m'],
13
+ warning: ['\x1b[38;2;219;178;80m'],
14
+ failure: ['\x1b[38;2;221;105;92m'],
15
+ code: ['\x1b[38;2;226;229;232m'],
16
+ border: ['\x1b[38;2;82;87;96m']
17
+ };
18
+
19
+ function hasAnsi(text) {
20
+ return ANSI_PATTERN.test(text);
21
+ }
22
+
23
+ export function stripAnsi(text) {
24
+ return String(text).replace(ANSI_PATTERN, '');
25
+ }
26
+
27
+ export function supportsColor(options = {}, stream = process.stdout, env = process.env) {
28
+ if (options.json || options.noColor) return false;
29
+ if (options.color) return true;
30
+ if ('NO_COLOR' in env) return false;
31
+ if (env.TERM === 'dumb') return false;
32
+ return Boolean(stream?.isTTY);
33
+ }
34
+
35
+ export function style(text, token, options = {}) {
36
+ if (!options.colorEnabled) return String(text);
37
+ const open = THEME[token] || THEME.heading;
38
+ return open.join('') + text + RESET;
39
+ }
40
+
41
+ function statusToken(label = '') {
42
+ const normalized = label.toLowerCase();
43
+ if (normalized === 'pass' || normalized === 'verified') return 'success';
44
+ if (normalized === 'partial pass' || normalized === 'warn' || normalized === 'warning') return 'warning';
45
+ if (normalized === 'fail' || normalized === 'failure') return 'failure';
46
+ if (normalized === 'skipped' || normalized === 'not verified' || normalized === 'not_verified') return 'muted';
47
+ return 'heading';
48
+ }
49
+
50
+ function sectionToken(line) {
51
+ if (/^## Verified\b/.test(line)) return 'success';
52
+ if (/^## Warnings\b/.test(line)) return 'warning';
53
+ if (/^## Failures\b/.test(line)) return 'failure';
54
+ if (/^## Skipped\b/.test(line)) return 'muted';
55
+ if (/^## Not Verified\b/.test(line)) return 'warning';
56
+ if (/^## Evidence\b/.test(line)) return 'accent';
57
+ if (/^## Next Safe Step\b/.test(line)) return 'accent';
58
+ if (/^## Overall Confidence\b/.test(line)) return 'accent';
59
+ if (/^## Checks\b|^## Check Summary\b|^## What Matters Most\b/.test(line)) return 'heading';
60
+ return 'heading';
61
+ }
62
+
63
+ function colorStatusWords(line, options) {
64
+ return line.replace(/\b(Partial pass|Not verified|Pass|Fail|Skipped|pass|warn|fail|skipped|not_verified)\b/g, (label) => {
65
+ return style(label, statusToken(label), options);
66
+ });
67
+ }
68
+
69
+ function colorTableLine(line, options) {
70
+ if (/^\| [-| ]+\|$/.test(line) || /^\| Status \|/.test(line) || /^\| Area \|/.test(line)) {
71
+ return style(line, 'border', options);
72
+ }
73
+ return line.replace(/\| (Partial pass|Not verified|Pass|Fail|Skipped|pass|warn|fail|skipped|not_verified) \|/g, (_, label) => {
74
+ return '| ' + style(label, statusToken(label), options) + ' |';
75
+ });
76
+ }
77
+
78
+ function colorEvidenceLine(line, options) {
79
+ const fileMatch = line.match(/^(\s*evidence: )(file:)(\s+)(.+)$/);
80
+ if (fileMatch) {
81
+ return style(fileMatch[1], 'muted', options) + style(fileMatch[2], 'accent', options) + fileMatch[3] + style(fileMatch[4], 'code', options);
82
+ }
83
+ const writtenMatch = line.match(/^(.*Evidence written to:\s+)(.+)$/);
84
+ if (writtenMatch) {
85
+ return style(writtenMatch[1], 'accent', options) + style(writtenMatch[2], 'code', options);
86
+ }
87
+ let formatted = line.replace(/^(\s*evidence: )([^:]+:)/, (_, prefix, key) => {
88
+ return style(prefix, 'muted', options) + style(key, 'accent', options);
89
+ });
90
+ formatted = formatted.replace(/^(\s*(why it matters|next safe step):)/, (match) => style(match, 'accent', options));
91
+ return colorStatusWords(formatted, options);
92
+ }
93
+
94
+ function colorLine(line, options) {
95
+ if (!line || hasAnsi(line)) return line;
96
+ if (/^ ____|^ \/|^ \/ |^\/ \/_|^\\____|^ \/_/.test(line)) return style(line, 'accent', options);
97
+ if (/^Operational truth checks/.test(line)) return style(line, 'muted', options);
98
+ if (/^# /.test(line)) return style(line.replace(/^# /, ''), 'heading', options);
99
+ if (/^## /.test(line)) return style(line.replace(/^## /, ''), sectionToken(line), options);
100
+ if (/^STATUS: /.test(line)) {
101
+ const label = line.slice('STATUS: '.length);
102
+ return style('STATUS:', 'muted', options) + ' ' + style(label, statusToken(label), options);
103
+ }
104
+ if (/^\|/.test(line)) return colorTableLine(line, options);
105
+ if (/^- \[(pass|warn|fail|skipped|not_verified)\]/.test(line)) return colorStatusWords(line, options);
106
+ if (/^\s*(evidence|why it matters|next safe step):/.test(line)) return colorEvidenceLine(line, options);
107
+ if (/Evidence written to:|Evidence file was not written/.test(line)) return colorEvidenceLine(line, options);
108
+ if (/^Usage:|^Commands:|^Global options:|^Options:|^Common workflows:|^Safety philosophy:/.test(line)) return style(line, 'heading', options);
109
+ if (/^\s{2}(opstruth|--|-h|npm|npx)\b/.test(line) || /^- opstruth\b/.test(line)) return style(line, 'code', options);
110
+ if (/^- None$|^- No failures$/.test(line)) return style(line, 'muted', options);
111
+ return line;
112
+ }
113
+
114
+ export function formatTerminalOutput(text, options = {}) {
115
+ const colorEnabled = supportsColor(options);
116
+ if (!colorEnabled) return String(text);
117
+ const styleOptions = { ...options, colorEnabled };
118
+ return String(text).split('\n').map((line) => colorLine(line, styleOptions)).join('\n');
119
+ }
@@ -25,6 +25,29 @@ function nextStepFor(aggregate) {
25
25
  return 'Attach the evidence pack to the change or handoff.';
26
26
  }
27
27
 
28
+ function probeJson(probe) {
29
+ return {
30
+ id: probe.id,
31
+ name: probe.name,
32
+ area: probe.area,
33
+ stack: probe.stack,
34
+ mode: probe.mode,
35
+ safetyLevel: probe.safetyLevel,
36
+ defaultMode: probe.defaultMode,
37
+ mutability: probe.mutability,
38
+ inputsRequired: probe.inputsRequired,
39
+ evidenceCollected: probe.evidenceCollected,
40
+ evidenceExpectation: probe.evidenceExpectation,
41
+ proves: probe.proves,
42
+ doesNotProve: probe.doesNotProve,
43
+ proofLimitation: probe.proofLimitation,
44
+ skipReason: probe.skipReason,
45
+ nextSafeStep: probe.nextSafeStep,
46
+ supportedStacks: probe.supportedStacks,
47
+ notVerified: probe.notVerified
48
+ };
49
+ }
50
+
28
51
  export async function runOrchestrator(options = {}) {
29
52
  const startCwd = options.cwd || process.cwd();
30
53
  const boundary = await resolveProjectBoundary(startCwd);
@@ -75,18 +98,8 @@ export async function runOrchestrator(options = {}) {
75
98
  stack,
76
99
  probes: {
77
100
  catalogueSize: probeSelection.catalogueSize,
78
- selected: probeSelection.selected.map((probe) => ({
79
- id: probe.id,
80
- name: probe.name,
81
- area: probe.area,
82
- stack: probe.stack,
83
- safetyLevel: probe.safetyLevel,
84
- defaultMode: probe.defaultMode,
85
- proves: probe.proves,
86
- doesNotProve: probe.doesNotProve,
87
- evidenceCollected: probe.evidenceCollected
88
- })),
89
- skipped: probeSelection.skipped.map((probe) => ({ id: probe.id, area: probe.area, stack: probe.stack, reason: probe.reason }))
101
+ selected: probeSelection.selected.map(probeJson),
102
+ skipped: probeSelection.skipped.map((probe) => ({ ...probeJson(probe), reason: probe.reason }))
90
103
  },
91
104
  childResults
92
105
  },