opstruth 0.1.2 → 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,17 +26,20 @@ 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
 
33
38
  ## Terminal Output
34
39
 
35
40
  Human output uses a restrained colour theme for status, warnings, proof gaps, evidence, and next safe steps when the terminal supports ANSI colour.
36
41
 
37
- 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.
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.
38
43
 
39
44
  ## Safety Model
40
45
 
@@ -42,6 +47,16 @@ opstruth is read-only by default. CLI checks do not deploy, mutate databases, tr
42
47
 
43
48
  Skipped checks and not-verified areas are reported as proof gaps instead of being treated as safe.
44
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
+
45
60
  ## Repository
46
61
 
47
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.2",
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
@@ -115,34 +115,43 @@ function helpText(command) {
115
115
  ' --no-color Disable colour for human terminal output',
116
116
  ' -h, --help Print help and exit 0'
117
117
  ];
118
- const route = [
119
- ASCII_HEADER,
120
- 'Usage: opstruth routes --base-url <url> [--routes file] [--strict]',
121
- '',
122
- 'Read-only route probes collect URL, method, status, latency, redirects, and security-header evidence.',
123
- '',
124
- 'Options:',
125
- ' --base-url <url> Base URL to probe',
126
- ' --routes <file> JSON route config',
127
- ' --json Print JSON output',
128
- ' --color Force colour for human terminal output',
129
- ' --no-color Disable colour for human terminal output',
130
- ' -h, --help Print help and exit 0'
131
- ];
132
- const repo = [
133
- ASCII_HEADER,
134
- 'Usage: opstruth repo [--json] [--strict]',
135
- '',
136
- 'Read-only repository inspection reports cwd, git root, branch, latest commit, dirty files, and detected stack.',
137
- '',
138
- 'Options:',
139
- ' --json Print JSON output',
140
- ' --color Force colour for human terminal output',
141
- ' --no-color Disable colour for human terminal output',
142
- ' -h, --help Print help and exit 0'
143
- ];
144
- if (command === 'routes') return route.join('\n') + '\n';
145
- 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
+ }
146
155
  return common.join('\n') + '\n';
147
156
  }
148
157
 
@@ -165,8 +174,17 @@ function welcomeText() {
165
174
  '- opstruth --strict',
166
175
  '- opstruth routes --base-url https://example.com',
167
176
  '- opstruth local --port 3000 --health /health',
177
+ '- opstruth --json',
178
+ '- opstruth --no-color',
179
+ '- opstruth --color',
168
180
  '- opstruth evidence --title "Release proof"',
169
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
+ '',
170
188
  'Safety philosophy:',
171
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.',
172
190
  ''
@@ -174,27 +192,34 @@ function welcomeText() {
174
192
  }
175
193
 
176
194
  const INIT_CONFIG = {
177
- routes: {
178
- baseUrl: '',
179
- paths: ['/', '/login', '/healthz'],
180
- requiredHeaders: [
181
- 'content-security-policy',
182
- 'strict-transport-security',
183
- 'x-frame-options',
184
- 'referrer-policy'
185
- ]
186
- },
195
+ projectName: 'example',
196
+ routes: [
197
+ { path: '/', expectedStatus: 200 },
198
+ { path: '/health', expectedStatus: 200 }
199
+ ],
187
200
  local: {
188
201
  ports: [],
189
- healthPath: '/health'
202
+ healthPaths: ['/health']
203
+ },
204
+ quality: {
205
+ skipScripts: [],
206
+ requiredScripts: []
207
+ },
208
+ secrets: {
209
+ allowlistPaths: [],
210
+ allowlistPatterns: []
190
211
  },
191
212
  supabase: {
213
+ enabled: true,
192
214
  protectedTables: [
193
215
  'agent_jobs',
194
216
  'platform_credentials',
195
217
  'worker_logs'
196
218
  ]
197
219
  },
220
+ cloudflare: {
221
+ enabled: true
222
+ },
198
223
  ignore: [
199
224
  '.cache',
200
225
  '.agents',
@@ -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;
@@ -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'
@@ -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
  },