@juzser/blacksmith 0.1.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.
- package/.claude/agents/auditor.md +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- package/package.json +98 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// factory/policies/crosscheck.yml loader (architecture §6 "Multi-provider
|
|
2
|
+
// cross-check"). Same parse-with-defaults pattern as scheduler.ts's
|
|
3
|
+
// parseSchedulerPolicy()/loadSchedulerPolicy() — a RawXYaml shape for the
|
|
4
|
+
// on-disk snake_case document, mapped onto a typed camelCase policy.
|
|
5
|
+
//
|
|
6
|
+
// `kind: native` (claude) has no transport at all — it's the in-process
|
|
7
|
+
// judge already wired through gate.ts/findings.ts, never dispatched through
|
|
8
|
+
// providers/index.ts. Every other provider declares a `transport` field
|
|
9
|
+
// (Phase 8) that selects which transport module (cli-transport.ts /
|
|
10
|
+
// api-transport.ts) runs it — whatever the provider is called, since nothing
|
|
11
|
+
// downstream dispatches on the name. `kind: api` is kept on those for
|
|
12
|
+
// backward compatibility with the Phase-1 scaffold (it predates the transport
|
|
13
|
+
// split and no longer carries meaning on its own — `transport` is now the
|
|
14
|
+
// field that actually selects behavior).
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { parse as parseYaml } from 'yaml';
|
|
17
|
+
import { SmithError } from './errors.js';
|
|
18
|
+
import { CROSSCHECK_POLICY_PATH } from './paths.js';
|
|
19
|
+
import { apiKeyPresent, commandOnPath } from './preconditions.js';
|
|
20
|
+
export class CrosscheckError extends SmithError {
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The concrete model a judge dispatch ran on, for the event log's required
|
|
24
|
+
* `model` field. An api-transport provider declares it; a cli-transport one
|
|
25
|
+
* usually doesn't, because the binary picks — `<command>:default` records
|
|
26
|
+
* that honestly rather than inventing a model id nobody verified.
|
|
27
|
+
*/
|
|
28
|
+
export function providerModel(config) {
|
|
29
|
+
if (config.kind === 'native')
|
|
30
|
+
return `${config.name}:native`;
|
|
31
|
+
if (config.transport === 'cli')
|
|
32
|
+
return config.model ?? `${config.command}:default`;
|
|
33
|
+
return config.model;
|
|
34
|
+
}
|
|
35
|
+
// Mirrors the shipped crosscheck.yml asymmetric_roles.pairs block, which in
|
|
36
|
+
// turn mirrors two sentences already in the role templates: spec-reviewer
|
|
37
|
+
// "never runs on the planner's own model", verifier "never runs on the
|
|
38
|
+
// reviewer's own model".
|
|
39
|
+
const DEFAULT_ASYMMETRIC_PAIRS = [
|
|
40
|
+
{ finder: 'planner', critic: 'spec-reviewer' },
|
|
41
|
+
{ finder: 'reviewer', critic: 'verifier' },
|
|
42
|
+
];
|
|
43
|
+
// Mirrors the shipped crosscheck.yml role_isolation.pairs block. One entry,
|
|
44
|
+
// and it is meant to stay one until a second role starts grading work it
|
|
45
|
+
// could have written: every other grader in the pipeline is a judge, and a
|
|
46
|
+
// judge cannot write at all (`role_write_scopes`, docs/standards/guardrails.md).
|
|
47
|
+
const DEFAULT_ROLE_ISOLATION_PAIRS = [
|
|
48
|
+
{ worker: 'coder', auditor: 'tester' },
|
|
49
|
+
];
|
|
50
|
+
// Mirrors the shipped crosscheck.yml independent_finder block. Every default
|
|
51
|
+
// here is the OFF position: a file that omits the block entirely gets a finder
|
|
52
|
+
// that is disabled, names nobody, gates nothing if enabled, and refuses to send
|
|
53
|
+
// a diff. The only way to any of the four powers is an operator writing it down.
|
|
54
|
+
//
|
|
55
|
+
// `providers` is empty rather than a name because this file is the fallback for
|
|
56
|
+
// a policy that did not say, and nothing here knows which vendors a given box
|
|
57
|
+
// has. A default naming one would dispatch a CLI the operator may never have
|
|
58
|
+
// installed, and then report `independent_finder.providers names "<vendor>"`
|
|
59
|
+
// back to someone who named no vendor at all. Empty says the true thing: nobody
|
|
60
|
+
// was asked. runIndependentFinder() refuses on it in those words.
|
|
61
|
+
const DEFAULT_INDEPENDENT_FINDER_PROVIDERS = [];
|
|
62
|
+
const DEFAULT_INDEPENDENT_FINDER_MAX_DIFF_BYTES = 120_000;
|
|
63
|
+
const DEFAULT_MODEL_TIER = 'mid'; // taxonomy.yml model_tier — judges run sonnet-tier per architecture §4, external judges default to the same tier absent an override.
|
|
64
|
+
// Defaults mirror the checked-in crosscheck.yml plan_quorum block exactly,
|
|
65
|
+
// so a hand-authored yaml that omits the block (e.g. in tests) still gets
|
|
66
|
+
// the same triggers the shipped policy defines.
|
|
67
|
+
const DEFAULT_PLAN_QUORUM_BUDGET_RATIO = 0.5;
|
|
68
|
+
const DEFAULT_PLAN_QUORUM_CONFIDENCE_THRESHOLD = 0.8;
|
|
69
|
+
const DEFAULT_PLAN_QUORUM_SECURITY_CASES = ['infra'];
|
|
70
|
+
const DEFAULT_PLAN_QUORUM_SECURITY_ROLES = ['security-reviewer'];
|
|
71
|
+
const DEFAULT_PLAN_QUORUM_SECURITY_KEYWORDS = [
|
|
72
|
+
'auth',
|
|
73
|
+
'authz',
|
|
74
|
+
'authentication',
|
|
75
|
+
'authorization',
|
|
76
|
+
'credential',
|
|
77
|
+
'secret',
|
|
78
|
+
'token',
|
|
79
|
+
'password',
|
|
80
|
+
'crypto',
|
|
81
|
+
'encryption',
|
|
82
|
+
'tls',
|
|
83
|
+
'injection',
|
|
84
|
+
'xss',
|
|
85
|
+
'csrf',
|
|
86
|
+
'sandbox',
|
|
87
|
+
'permission',
|
|
88
|
+
'privilege',
|
|
89
|
+
'sanitize',
|
|
90
|
+
'escalation',
|
|
91
|
+
];
|
|
92
|
+
/**
|
|
93
|
+
* Every knob this file hands to a comparison, checked rather than trusted --
|
|
94
|
+
* the same guard scheduler.ts carries for scheduler.yml, kept local because
|
|
95
|
+
* the two policies raise different error codes and default differently.
|
|
96
|
+
*
|
|
97
|
+
* `??` fills in on null/undefined only, so a YAML typo arrived at the
|
|
98
|
+
* comparison as itself while the declared type still said `number`. Nothing
|
|
99
|
+
* threw. The gate simply decided something else:
|
|
100
|
+
*
|
|
101
|
+
* - `min_providers: two` -- `1 < 'two'` is false, so the
|
|
102
|
+
* `insufficient-providers` escalation never fires and one judge returns a
|
|
103
|
+
* decided `1-of-1` for a gate the policy says needs two.
|
|
104
|
+
* - `budget_ratio: half` -- `totalTokens >= NaN` is false, so trigger 1
|
|
105
|
+
* never fires however much of the epic cap the plan claims.
|
|
106
|
+
* - `confidence_threshold: high` -- `0.1 < 'high'` is false, so trigger 3
|
|
107
|
+
* never fires however unsure the planner says it is.
|
|
108
|
+
*
|
|
109
|
+
* The list knobs fail differently again, because a spread over a bare scalar
|
|
110
|
+
* is a spread over its characters, not a one-element list:
|
|
111
|
+
*
|
|
112
|
+
* - `security_cases: infra` becomes ['i','n','f','r','a'] and the cases are
|
|
113
|
+
* matched with `Set.has`, so a task whose case IS infra stops tripping
|
|
114
|
+
* trigger 2. Same for `security_roles`.
|
|
115
|
+
* - `security_keywords: auth` becomes ['a','u','t','h'] and keywords match
|
|
116
|
+
* by substring, so "render a nice table" trips it instead. One typo
|
|
117
|
+
* silences two arms of trigger 2 and floods the third.
|
|
118
|
+
*
|
|
119
|
+
* The booleans fail a third way, and `enabled` is the one that matters. The
|
|
120
|
+
* `yaml` package is YAML 1.2, where only `false`/`False`/`FALSE` are
|
|
121
|
+
* booleans: `no`, `off`, `n` and a quoted `"false"` all arrive as non-empty
|
|
122
|
+
* strings, `??` does not fire on them, and all three readers ask the field by
|
|
123
|
+
* truthiness (quorum.ts's `enabledExternalProviders` and `runQuorum`,
|
|
124
|
+
* providers/index.ts's `runJudge`). So the operator who followed the runbook's
|
|
125
|
+
* rollback step -- "`enabled: false` means never invoked at all" -- and wrote
|
|
126
|
+
* it the YAML 1.1 way watched the provider keep being dispatched: prompts
|
|
127
|
+
* still leaving the machine, spend still accruing, `provider.disabled` never
|
|
128
|
+
* raised. That is the same silence as the others pointed at the one switch
|
|
129
|
+
* whose whole purpose is to stop something from happening.
|
|
130
|
+
*
|
|
131
|
+
* `asymmetric_roles.pairs` is a list of maps rather than of scalars, and so
|
|
132
|
+
* fails a fourth way: an entry missing either key -- `critics:` is one
|
|
133
|
+
* keystroke from `critic:` -- used to be filtered out of the list instead of
|
|
134
|
+
* refused, and `[] ?? DEFAULT` keeps an emptied list rather than the
|
|
135
|
+
* defaults. dispatchAudit walks the pairs that survived, finds nothing wrong
|
|
136
|
+
* with them, and reports `ok` over a shorter list than the file declares. The
|
|
137
|
+
* pair most likely to go missing that way is the verifier's own rule -- "never
|
|
138
|
+
* runs on the reviewer's own model" -- so a verifier dispatched on the
|
|
139
|
+
* reviewer's model passes an audit that never looked at it.
|
|
140
|
+
*
|
|
141
|
+
* Four of those five fail toward "no quorum needed", on a gate whose whole
|
|
142
|
+
* job is to demand a second opinion; the boolean fails toward "call out".
|
|
143
|
+
* A wrong *number* still passes, as in scheduler.ts: it changes what the gate
|
|
144
|
+
* demands, in the open, where the operator reads it. A wrong *type* changes it
|
|
145
|
+
* in silence.
|
|
146
|
+
*/
|
|
147
|
+
const DECIDES_DIFFERENTLY = 'A value the comparison cannot read does not fail the gate, it changes what the gate decides.';
|
|
148
|
+
function quorumNumber(field, value) {
|
|
149
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
150
|
+
throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml ${field} must be a finite number; got ${JSON.stringify(value)}. ${DECIDES_DIFFERENTLY}`, { field, value });
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
function quorumBoolean(field, value) {
|
|
155
|
+
if (typeof value !== 'boolean') {
|
|
156
|
+
throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml ${field} must be true or false; got ${JSON.stringify(value)}. This file is YAML 1.2, where no/off/yes/on are strings, and a non-empty string is truthy. On an external provider's enabled the one other legal value is auto, unquoted and lowercase, which resolves against that provider's precondition on the box reading the file. ${DECIDES_DIFFERENTLY}`, { field, value });
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* `enabled` reads one value more than the other booleans do. `auto` means
|
|
162
|
+
* "ask this box": the parser resolves it against the same precondition
|
|
163
|
+
* `smith judge preflight` reports on, so a policy that names a judge no box
|
|
164
|
+
* has to have is legal to ship, and silent where it cannot run.
|
|
165
|
+
*
|
|
166
|
+
* Only the exact lowercase word. `Auto` and a quoted `"auto"` are refused
|
|
167
|
+
* with everything else, because the whole reason this field is checked at all
|
|
168
|
+
* is that a near-miss here used to keep a judge switched on.
|
|
169
|
+
*/
|
|
170
|
+
function parseEnabled(field, value) {
|
|
171
|
+
if (value === 'auto')
|
|
172
|
+
return 'auto';
|
|
173
|
+
return quorumBoolean(field, value);
|
|
174
|
+
}
|
|
175
|
+
function quorumString(field, value) {
|
|
176
|
+
if (typeof value !== 'string') {
|
|
177
|
+
throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml ${field} must be a string; got ${JSON.stringify(value)}. ${DECIDES_DIFFERENTLY}`, { field, value });
|
|
178
|
+
}
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
/** Copied, not aliased: two parses must not share one array, or a caller mutating its policy would rewrite the defaults for every later parse. */
|
|
182
|
+
function quorumStringList(field, value) {
|
|
183
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
|
|
184
|
+
throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml ${field} must be a list of strings; got ${JSON.stringify(value)}. A bare scalar spreads into its characters, which match nothing -- or, for keywords, nearly everything.`, { field, value });
|
|
185
|
+
}
|
|
186
|
+
return [...value];
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The pair list, rebuilt entry by entry rather than filtered.
|
|
190
|
+
*
|
|
191
|
+
* A half-readable list is the same hole as no list: keeping only the entries
|
|
192
|
+
* that parsed would hand dispatchAudit a shorter set of rules than the
|
|
193
|
+
* operator wrote and let it report `ok` over the remainder. An empty list
|
|
194
|
+
* stays legal -- dispatchAudit answers it with one `unverifiable` check and
|
|
195
|
+
* `ok: false`, so it is a sentence the operator can read back -- and only a
|
|
196
|
+
* shape no comparison can read is refused. Copied, not aliased, for the same
|
|
197
|
+
* reason quorumStringList() is.
|
|
198
|
+
*/
|
|
199
|
+
function quorumPairs(field, value) {
|
|
200
|
+
if (!Array.isArray(value) || !value.every(isRolePair)) {
|
|
201
|
+
throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml ${field} must be a list of { finder, critic } maps naming two roles; got ${JSON.stringify(value)}. A pair the audit cannot read leaves the rule it names unchecked, and the audit still reports ok. ${DECIDES_DIFFERENTLY}`, { field, value });
|
|
202
|
+
}
|
|
203
|
+
return value.map((pair) => ({ finder: pair.finder, critic: pair.critic }));
|
|
204
|
+
}
|
|
205
|
+
function isRolePair(entry) {
|
|
206
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
|
|
207
|
+
return false;
|
|
208
|
+
const { finder, critic } = entry;
|
|
209
|
+
return typeof finder === 'string' && finder !== '' && typeof critic === 'string' && critic !== '';
|
|
210
|
+
}
|
|
211
|
+
function parsePlanQuorum(raw) {
|
|
212
|
+
return {
|
|
213
|
+
budgetRatio: quorumNumber('plan_quorum.budget_ratio', raw?.budget_ratio ?? DEFAULT_PLAN_QUORUM_BUDGET_RATIO),
|
|
214
|
+
confidenceThreshold: quorumNumber('plan_quorum.confidence_threshold', raw?.confidence_threshold ?? DEFAULT_PLAN_QUORUM_CONFIDENCE_THRESHOLD),
|
|
215
|
+
securityCases: quorumStringList('plan_quorum.security_cases', raw?.security_cases ?? DEFAULT_PLAN_QUORUM_SECURITY_CASES),
|
|
216
|
+
securityRoles: quorumStringList('plan_quorum.security_roles', raw?.security_roles ?? DEFAULT_PLAN_QUORUM_SECURITY_ROLES),
|
|
217
|
+
securityKeywords: quorumStringList('plan_quorum.security_keywords', raw?.security_keywords ?? DEFAULT_PLAN_QUORUM_SECURITY_KEYWORDS),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function parseProvider(name, raw) {
|
|
221
|
+
// `enabled` is checked before anything else this function does, because it
|
|
222
|
+
// is the field that decides whether the rest of the config is ever used.
|
|
223
|
+
const declared = parseEnabled(`providers.${name}.enabled`, raw.enabled ?? false);
|
|
224
|
+
const enabledSource = declared === 'auto' ? 'auto' : 'declared';
|
|
225
|
+
if (raw.kind === 'native') {
|
|
226
|
+
if (declared === 'auto') {
|
|
227
|
+
throw new CrosscheckError('crosscheck.invalid-provider', `crosscheck.yml providers.${name}.enabled is auto, but "${name}" is kind: native — it runs in this process and has no precondition to ask about. Write true or false.`, { provider: name, field: `providers.${name}.enabled` });
|
|
228
|
+
}
|
|
229
|
+
return { name, kind: 'native', enabled: declared };
|
|
230
|
+
}
|
|
231
|
+
// `mode` needs no check: anything that is not exactly 'active' becomes
|
|
232
|
+
// 'shadow', which is the safe direction for it -- a verdict that gates
|
|
233
|
+
// nothing. Left as it is, and named here so the asymmetry reads as chosen.
|
|
234
|
+
const mode = raw.mode === 'active' ? 'active' : 'shadow';
|
|
235
|
+
const modelTier = quorumString(`providers.${name}.model_tier`, raw.model_tier ?? DEFAULT_MODEL_TIER);
|
|
236
|
+
if (raw.transport === 'cli') {
|
|
237
|
+
if (!raw.command) {
|
|
238
|
+
throw new CrosscheckError('crosscheck.invalid-provider', `Provider "${name}" has transport: cli but no command.`, { provider: name });
|
|
239
|
+
}
|
|
240
|
+
const command = quorumString(`providers.${name}.command`, raw.command);
|
|
241
|
+
return {
|
|
242
|
+
name,
|
|
243
|
+
kind: 'api',
|
|
244
|
+
transport: 'cli',
|
|
245
|
+
// `auto` asks the same question judgePreflight reports on: is the
|
|
246
|
+
// binary runnable here. Not whether it is authenticated -- only a real
|
|
247
|
+
// call knows that, so `auto` can still resolve true on a box where
|
|
248
|
+
// `codex login` was never run, and the quorum records the failure.
|
|
249
|
+
enabled: declared === 'auto' ? commandOnPath(command) : declared,
|
|
250
|
+
enabledSource,
|
|
251
|
+
mode,
|
|
252
|
+
modelTier,
|
|
253
|
+
command,
|
|
254
|
+
// spawn(command, 'exec') throws ERR_INVALID_ARG_TYPE at dispatch, which
|
|
255
|
+
// quorum.ts catches and files as a provider error -- so the judge drops
|
|
256
|
+
// out of the quorum and the run reads as if the policy said so.
|
|
257
|
+
args: quorumStringList(`providers.${name}.args`, raw.args ?? []),
|
|
258
|
+
...(raw.model ? { model: raw.model } : {}),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (raw.transport === 'api') {
|
|
262
|
+
if (!raw.base_url || !raw.model || !raw.api_key_env) {
|
|
263
|
+
throw new CrosscheckError('crosscheck.invalid-provider', `Provider "${name}" has transport: api but is missing base_url/model/api_key_env.`, { provider: name });
|
|
264
|
+
}
|
|
265
|
+
const apiKeyEnv = quorumString(`providers.${name}.api_key_env`, raw.api_key_env);
|
|
266
|
+
return {
|
|
267
|
+
name,
|
|
268
|
+
kind: 'api',
|
|
269
|
+
transport: 'api',
|
|
270
|
+
// Set and non-empty, which is all that is knowable without spending a
|
|
271
|
+
// call; whether the key is VALID is not.
|
|
272
|
+
enabled: declared === 'auto' ? apiKeyPresent(apiKeyEnv) : declared,
|
|
273
|
+
enabledSource,
|
|
274
|
+
mode,
|
|
275
|
+
modelTier,
|
|
276
|
+
baseUrl: quorumString(`providers.${name}.base_url`, raw.base_url),
|
|
277
|
+
model: quorumString(`providers.${name}.model`, raw.model),
|
|
278
|
+
apiKeyEnv,
|
|
279
|
+
responseFormatJsonObject: quorumBoolean(`providers.${name}.response_format_json_object`, raw.response_format_json_object ?? true),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
throw new CrosscheckError('crosscheck.invalid-provider', `Provider "${name}" is not kind: native and has no recognized transport (got "${String(raw.transport)}").`, { provider: name, transport: raw.transport });
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The isolation pair list, rebuilt entry by entry for quorumPairs()' reason:
|
|
286
|
+
* a half-readable list is the same hole as no list. An empty list stays legal
|
|
287
|
+
* and testerAudit answers it with one `unverifiable` check.
|
|
288
|
+
*/
|
|
289
|
+
function isolationPairs(field, value) {
|
|
290
|
+
if (!Array.isArray(value) || !value.every(isIsolationPair)) {
|
|
291
|
+
throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml ${field} must be a list of { worker, auditor } maps naming two roles; got ${JSON.stringify(value)}. A pair the audit cannot read leaves the rule it names unchecked, and the audit still reports ok. ${DECIDES_DIFFERENTLY}`, { field, value });
|
|
292
|
+
}
|
|
293
|
+
return value.map((pair) => ({ worker: pair.worker, auditor: pair.auditor }));
|
|
294
|
+
}
|
|
295
|
+
function isIsolationPair(entry) {
|
|
296
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
|
|
297
|
+
return false;
|
|
298
|
+
const { worker, auditor } = entry;
|
|
299
|
+
return (typeof worker === 'string' && worker !== '' && typeof auditor === 'string' && auditor !== '');
|
|
300
|
+
}
|
|
301
|
+
function parseRoleIsolation(raw) {
|
|
302
|
+
return {
|
|
303
|
+
// Copied, not aliased — same reason as parsePlanQuorum() above.
|
|
304
|
+
pairs: isolationPairs('role_isolation.pairs', raw?.pairs ?? DEFAULT_ROLE_ISOLATION_PAIRS.map((p) => ({ ...p }))),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* `severity_resolution` gets a closed check where `mode` gets none, and the
|
|
309
|
+
* asymmetry is the point. An unreadable `mode` falls to `shadow`, which is the
|
|
310
|
+
* safe direction — a verdict that gates nothing. An unreadable
|
|
311
|
+
* `severity_resolution` has no safe direction: defaulting to `native-wins`
|
|
312
|
+
* silently discards the escalation the operator asked for, and defaulting to
|
|
313
|
+
* `highest-wins` silently grants one they did not. So it is refused.
|
|
314
|
+
*/
|
|
315
|
+
function parseSeverityResolution(value) {
|
|
316
|
+
if (value === 'highest-wins' || value === 'native-wins')
|
|
317
|
+
return value;
|
|
318
|
+
throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml independent_finder.severity_resolution must be "highest-wins" or "native-wins"; got ${JSON.stringify(value)}. ${DECIDES_DIFFERENTLY}`, { field: 'independent_finder.severity_resolution', value });
|
|
319
|
+
}
|
|
320
|
+
function parseIndependentFinder(raw) {
|
|
321
|
+
const maxDiffBytes = quorumNumber('independent_finder.max_diff_bytes', raw?.max_diff_bytes ?? DEFAULT_INDEPENDENT_FINDER_MAX_DIFF_BYTES);
|
|
322
|
+
if (!Number.isInteger(maxDiffBytes) || maxDiffBytes <= 0) {
|
|
323
|
+
throw new CrosscheckError('crosscheck.invalid-policy', `crosscheck.yml independent_finder.max_diff_bytes must be a positive integer; got ${JSON.stringify(maxDiffBytes)}. A cap of zero or less refuses every diff, which reads in the log exactly like a finder that ran and found nothing.`, { field: 'independent_finder.max_diff_bytes', value: maxDiffBytes });
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
enabled: quorumBoolean('independent_finder.enabled', raw?.enabled ?? false),
|
|
327
|
+
// Same fall-to-shadow rule as a provider's own `mode`, for the same reason.
|
|
328
|
+
mode: raw?.mode === 'active' ? 'active' : 'shadow',
|
|
329
|
+
// Copied, not aliased — same reason as parsePlanQuorum() above.
|
|
330
|
+
providers: quorumStringList('independent_finder.providers', raw?.providers ?? DEFAULT_INDEPENDENT_FINDER_PROVIDERS),
|
|
331
|
+
sendDiff: quorumBoolean('independent_finder.send_diff', raw?.send_diff ?? false),
|
|
332
|
+
maxDiffBytes,
|
|
333
|
+
severityResolution: parseSeverityResolution(quorumString('independent_finder.severity_resolution', raw?.severity_resolution ?? 'highest-wins')),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function parseAsymmetricRoles(raw) {
|
|
337
|
+
return {
|
|
338
|
+
// This one's wrong-shape direction is benign -- a truthy string keeps the
|
|
339
|
+
// rule enforced -- but an operator who writes `no` still got the opposite
|
|
340
|
+
// of what they wrote, and was not told.
|
|
341
|
+
finderNeCritic: quorumBoolean('asymmetric_roles.finder_ne_critic', raw?.finder_ne_critic ?? true),
|
|
342
|
+
// Copied, not aliased — same reason as parsePlanQuorum() above.
|
|
343
|
+
pairs: quorumPairs('asymmetric_roles.pairs', raw?.pairs ?? DEFAULT_ASYMMETRIC_PAIRS.map((p) => ({ ...p }))),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
export function parseCrosscheckPolicy(yamlText, options = {}) {
|
|
347
|
+
const doc = (parseYaml(yamlText) ?? {});
|
|
348
|
+
if (!doc.providers || Object.keys(doc.providers).length === 0) {
|
|
349
|
+
throw new CrosscheckError('crosscheck.invalid-policy', 'crosscheck.yml has no providers.');
|
|
350
|
+
}
|
|
351
|
+
const providers = {};
|
|
352
|
+
for (const [name, raw] of Object.entries(doc.providers)) {
|
|
353
|
+
const config = parseProvider(name, raw ?? {});
|
|
354
|
+
providers[name] =
|
|
355
|
+
options.offline && config.kind !== 'native'
|
|
356
|
+
? // The switch is the decider now, and says so: a reader that saw
|
|
357
|
+
// `auto` here would report a box that lacks the binary, which is a
|
|
358
|
+
// different fault with a different fix.
|
|
359
|
+
{ ...config, enabled: false, enabledSource: 'offline' }
|
|
360
|
+
: config;
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
providers,
|
|
364
|
+
quorumRule: {
|
|
365
|
+
agreement: doc.quorum_rule?.agreement ?? '2-of-3',
|
|
366
|
+
minProviders: quorumNumber('quorum_rule.min_providers', doc.quorum_rule?.min_providers ?? 2),
|
|
367
|
+
acceptNonGatingActives: quorumBoolean('quorum_rule.accept_non_gating_actives', doc.quorum_rule?.accept_non_gating_actives ?? false),
|
|
368
|
+
},
|
|
369
|
+
planQuorum: parsePlanQuorum(doc.plan_quorum),
|
|
370
|
+
asymmetricRoles: parseAsymmetricRoles(doc.asymmetric_roles),
|
|
371
|
+
roleIsolation: parseRoleIsolation(doc.role_isolation),
|
|
372
|
+
// Not touched by `offline`. That switch exists to stop this process
|
|
373
|
+
// reaching out of the machine, and it already does: it disables every
|
|
374
|
+
// non-native provider, and runIndependentFinder() invokes providers
|
|
375
|
+
// through the same enabled check runQuorumCase() does. Flipping
|
|
376
|
+
// `independent_finder.enabled` here as well would conflate "make no calls"
|
|
377
|
+
// with "make no reconciliations", and `crossfind reconcile` — which is
|
|
378
|
+
// pure, offline, and calls nothing — would stop answering under it.
|
|
379
|
+
independentFinder: parseIndependentFinder(doc.independent_finder),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Reads the policy, honouring `SMITH_CROSSCHECK_OFFLINE`: set it to anything
|
|
384
|
+
* non-empty and no external provider can be invoked, whatever the file says.
|
|
385
|
+
*
|
|
386
|
+
* The switch is read here rather than passed down from each call site because
|
|
387
|
+
* the property it protects is process-wide — "this process makes no judge
|
|
388
|
+
* calls out of the machine" — and because it has to survive `spawn`, which
|
|
389
|
+
* inherits the environment but not an argument. The CLI tests exercise the
|
|
390
|
+
* built binary as a subprocess; nothing else would reach them.
|
|
391
|
+
*/
|
|
392
|
+
export function loadCrosscheckPolicy(filePath = CROSSCHECK_POLICY_PATH, options = {}) {
|
|
393
|
+
return parseCrosscheckPolicy(readFileSync(filePath, 'utf8'), {
|
|
394
|
+
offline: options.offline ?? Boolean(process.env.SMITH_CROSSCHECK_OFFLINE),
|
|
395
|
+
});
|
|
396
|
+
}
|