@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,222 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import picomatch from 'picomatch';
|
|
3
|
+
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
import { globsOverlap, synthesizeLiteralSegment } from './claims.js';
|
|
5
|
+
import { SmithError } from './errors.js';
|
|
6
|
+
import { SENSITIVE_PATHS_POLICY_PATH } from './paths.js';
|
|
7
|
+
export class SecurityError extends SmithError {
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Every read below refuses a value of the wrong shape rather than substituting
|
|
11
|
+
* an empty one, for the reason the empty-`globs` throw already gives: a trigger
|
|
12
|
+
* the dispatcher cannot read does not fire weakly, it does not fire at all.
|
|
13
|
+
* `cases: infra` is valid YAML and a plausible slip -- the key takes a list,
|
|
14
|
+
* and a one-element list looks like a scalar -- and it turns
|
|
15
|
+
* `dispatchSecurityReviewer` from true to false with an empty trigger list.
|
|
16
|
+
*
|
|
17
|
+
* A *wrong* value stays legal: `cases: [infrastructure]` fires nothing either,
|
|
18
|
+
* but it is a sentence the operator wrote and can read back. Only shapes no
|
|
19
|
+
* comparison can read are refused.
|
|
20
|
+
*/
|
|
21
|
+
const NEVER_FIRES = 'A trigger the dispatcher cannot read does not fire weakly, it does not fire at all.';
|
|
22
|
+
/** Copied, not aliased: two parses must not share one array. */
|
|
23
|
+
function stringList(field, value) {
|
|
24
|
+
if (value === undefined || value === null)
|
|
25
|
+
return [];
|
|
26
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
|
|
27
|
+
throw new SecurityError('security.invalid-policy', `sensitive-paths.yml ${field} must be a list of strings; got ${JSON.stringify(value)}. ${NEVER_FIRES}`, { field, value });
|
|
28
|
+
}
|
|
29
|
+
return [...value];
|
|
30
|
+
}
|
|
31
|
+
function flag(field, value) {
|
|
32
|
+
if (value === undefined || value === null)
|
|
33
|
+
return false;
|
|
34
|
+
if (typeof value !== 'boolean') {
|
|
35
|
+
throw new SecurityError('security.invalid-policy', `sensitive-paths.yml ${field} must be true or false; got ${JSON.stringify(value)}. ${NEVER_FIRES}`, { field, value });
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
/** The block, not just its keys: a scalar here takes all three arms dark at once. */
|
|
40
|
+
function otherTriggers(value) {
|
|
41
|
+
if (value === undefined || value === null)
|
|
42
|
+
return {};
|
|
43
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
44
|
+
throw new SecurityError('security.invalid-policy', `sensitive-paths.yml other_triggers must be a block of keys; got ${JSON.stringify(value)}. ${NEVER_FIRES}`, { field: 'other_triggers', value });
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
export function parseSensitivePathsPolicy(yamlText) {
|
|
49
|
+
const doc = (parseYaml(yamlText) ?? {});
|
|
50
|
+
const globs = stringList('globs', doc.globs);
|
|
51
|
+
if (globs.length === 0) {
|
|
52
|
+
throw new SecurityError('security.invalid-policy', 'sensitive-paths.yml has no globs — a path trigger with an empty glob list never fires.');
|
|
53
|
+
}
|
|
54
|
+
const other = otherTriggers(doc.other_triggers);
|
|
55
|
+
// `epic_cases` was the original spelling, written before it was clear that
|
|
56
|
+
// `case` lives on the task spec and not on the epic. Both are read, and the
|
|
57
|
+
// error names whichever spelling the file actually used.
|
|
58
|
+
const usesCases = other.cases !== undefined && other.cases !== null;
|
|
59
|
+
return {
|
|
60
|
+
globs,
|
|
61
|
+
exclude: stringList('exclude', doc.exclude),
|
|
62
|
+
cases: usesCases
|
|
63
|
+
? stringList('other_triggers.cases', other.cases)
|
|
64
|
+
: stringList('other_triggers.epic_cases', other.epic_cases),
|
|
65
|
+
epicTags: stringList('other_triggers.epic_tags', other.epic_tags),
|
|
66
|
+
scheduledRecheck: flag('other_triggers.scheduled_recheck', other.scheduled_recheck),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function loadSensitivePathsPolicy(filePath = SENSITIVE_PATHS_POLICY_PATH) {
|
|
70
|
+
return parseSensitivePathsPolicy(readFileSync(filePath, 'utf8'));
|
|
71
|
+
}
|
|
72
|
+
/** Stands in for "some filename" when a pattern ends in `/**`. */
|
|
73
|
+
const PROBE_SEGMENT = '__smith_probe__';
|
|
74
|
+
/**
|
|
75
|
+
* Every literal a segment's brace set could pick, not just the leading one:
|
|
76
|
+
* `*auth*.{ts,py}` -> ['*auth*.ts', '*auth*.py'].
|
|
77
|
+
*
|
|
78
|
+
* `synthesizeLiteralSegment` keeps only the first alternative, which is the
|
|
79
|
+
* right call where it lives — it builds *a* candidate, and claims.ts asks
|
|
80
|
+
* whether one exists. Here the alternatives are the policy's nine-language
|
|
81
|
+
* extension list, so collapsing them to the leading `ts` made every file glob
|
|
82
|
+
* in the file silently TypeScript-only: a `.py` or `.tsx` claim had no
|
|
83
|
+
* candidate that could satisfy the glob at all (D-176).
|
|
84
|
+
*/
|
|
85
|
+
function braceAlternatives(segment) {
|
|
86
|
+
const brace = /\{([^{}]*)\}/.exec(segment);
|
|
87
|
+
if (brace === null)
|
|
88
|
+
return [segment];
|
|
89
|
+
const whole = brace[0];
|
|
90
|
+
return (brace[1] ?? '')
|
|
91
|
+
.split(',')
|
|
92
|
+
.flatMap((choice) => braceAlternatives(segment.replace(whole, () => choice)));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The concrete filenames a pattern's own final segment would accept. A pattern
|
|
96
|
+
* ending in `/**` accepts any filename and proposes the probe.
|
|
97
|
+
*/
|
|
98
|
+
function tailCandidates(pattern) {
|
|
99
|
+
const segments = pattern.split('/').filter((s) => s.length > 0);
|
|
100
|
+
const last = segments[segments.length - 1] ?? '';
|
|
101
|
+
if (last === '**')
|
|
102
|
+
return [PROBE_SEGMENT];
|
|
103
|
+
return braceAlternatives(last).map(synthesizeLiteralSegment);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* `dot: true` throughout: the two entries this policy most needs to catch are
|
|
107
|
+
* `**\/*.env*` and `**\/.github/workflows/**`, and picomatch's default hides
|
|
108
|
+
* every dotfile from `*` and `**`. A trigger that cannot see `.env` is not a
|
|
109
|
+
* trigger.
|
|
110
|
+
*/
|
|
111
|
+
function matches(pattern, value) {
|
|
112
|
+
return picomatch(pattern, { dot: true })(value);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Concrete paths built from a claim's static base plus a policy glob's literal
|
|
116
|
+
* segments, one per filename either side proposes: `src/**` + `**\/auth/**` ->
|
|
117
|
+
* `src/auth/__smith_probe__`, `ui/src/**\/*.tsx` + `**\/auth/**` ->
|
|
118
|
+
* `ui/src/auth/x.tsx`.
|
|
119
|
+
*
|
|
120
|
+
* This is the case `globsOverlap` gives up on. Its `couldJointlyMatch` only
|
|
121
|
+
* ever synthesizes a *tail* — `src/__smith_probe__` and `__smith_probe__` for
|
|
122
|
+
* that pair — and neither satisfies both sides, so it answers "no overlap" for
|
|
123
|
+
* a claim that plainly contains `src/auth/login.ts`. Policy globs are almost
|
|
124
|
+
* all of the `**\/<literal>/**` shape, so that gap is the common case here, not
|
|
125
|
+
* an edge one. It stays local to this module: `globsOverlap` is load-bearing
|
|
126
|
+
* for wave scheduling, where a wider answer means more serialization.
|
|
127
|
+
*
|
|
128
|
+
* Residual, named rather than closed: when both sides constrain the filename
|
|
129
|
+
* and neither side's proposal satisfies the other — claim `src/*.vue` against
|
|
130
|
+
* `**\/*jwt*`, where the path that satisfies both is `src/jwt.vue` — no
|
|
131
|
+
* candidate is synthesized and the answer is "no overlap". Closing it needs
|
|
132
|
+
* real glob intersection rather than a probe. It errs toward not firing, which
|
|
133
|
+
* is the unsafe direction here, so it is worth revisiting if the policy ever
|
|
134
|
+
* grows an extension-free glob with no directory-shaped sibling.
|
|
135
|
+
*/
|
|
136
|
+
function sharesSynthesizedPath(claim, glob) {
|
|
137
|
+
const segments = glob.split('/').filter((s) => s.length > 0);
|
|
138
|
+
const trailingGlobstar = segments[segments.length - 1] === '**';
|
|
139
|
+
// The directories the glob names, without its own filename segment.
|
|
140
|
+
const dirs = (trailingGlobstar ? segments : segments.slice(0, -1))
|
|
141
|
+
.filter((s) => s !== '**')
|
|
142
|
+
.map(synthesizeLiteralSegment);
|
|
143
|
+
const base = picomatch
|
|
144
|
+
.scan(claim)
|
|
145
|
+
.base.split('/')
|
|
146
|
+
.filter((s) => s.length > 0);
|
|
147
|
+
// Both sides propose a filename, because either side may be the one that
|
|
148
|
+
// constrains it: a glob ending in `**` leaves it free and the claim's own
|
|
149
|
+
// tail is then the only constraint — which is how `ui/src/**/*.tsx` used to
|
|
150
|
+
// miss `**/auth/**` while the narrower `ui/src/auth/Login.tsx` fired it,
|
|
151
|
+
// the inversion this matcher exists to prevent (D-176). Widening the
|
|
152
|
+
// candidate list cannot produce a false positive: a candidate still has to
|
|
153
|
+
// satisfy both patterns.
|
|
154
|
+
return [...tailCandidates(glob), ...tailCandidates(claim)].some((tail) => {
|
|
155
|
+
const candidate = [...base, ...dirs, tail].join('/');
|
|
156
|
+
if (candidate.length === 0)
|
|
157
|
+
return false;
|
|
158
|
+
return matches(claim, candidate) && matches(glob, candidate);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Overlap, not containment: a claim fires a glob when *some* real path
|
|
163
|
+
* satisfies both. A task claiming `src/**` must fire against `**\/auth/**`
|
|
164
|
+
* even though it is the wider pattern — containment-only matching is the
|
|
165
|
+
* failure mode where a broadly scoped task escapes the review a narrowly
|
|
166
|
+
* scoped one gets.
|
|
167
|
+
*
|
|
168
|
+
* The direct consequence, stated so it is not mistaken for a bug: an
|
|
169
|
+
* open-ended `<dir>/**` claim fires every directory-shaped glob in the policy,
|
|
170
|
+
* because such a claim really could add `<dir>/auth/session.ts` tomorrow. For
|
|
171
|
+
* a security trigger that is the safe direction to err in.
|
|
172
|
+
*/
|
|
173
|
+
export function claimTouchesGlob(claim, glob) {
|
|
174
|
+
if (matches(glob, claim))
|
|
175
|
+
return true; // a literal claim sitting inside the glob
|
|
176
|
+
if (globsOverlap(claim, glob))
|
|
177
|
+
return true;
|
|
178
|
+
return sharesSynthesizedPath(claim, glob);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Exclusions are containment, deliberately: a claim is silenced only when it
|
|
182
|
+
* lies entirely inside an exclusion. Overlap here would let `**\/*.test.ts`
|
|
183
|
+
* silence `src/**`, and every broadly scoped task could dodge the review by
|
|
184
|
+
* claiming a tree that happens to hold a test file.
|
|
185
|
+
*/
|
|
186
|
+
function isExcluded(claim, exclude) {
|
|
187
|
+
return exclude.some((pattern) => matches(pattern, claim));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The security-reviewer's three dispatch triggers, computed instead of
|
|
191
|
+
* remembered (agent-interviews.md N-7, punch list P9-4). A trigger that fires
|
|
192
|
+
* only when the orchestrator remembers to check is not a trigger.
|
|
193
|
+
*
|
|
194
|
+
* At most one trigger per claim: the glob is evidence for "this claim is
|
|
195
|
+
* sensitive", not an enumeration of every glob it could match.
|
|
196
|
+
*/
|
|
197
|
+
export function securityTriggers(task, policy = loadSensitivePathsPolicy(), context = {}) {
|
|
198
|
+
const triggers = [];
|
|
199
|
+
for (const claim of task.claims) {
|
|
200
|
+
if (isExcluded(claim, policy.exclude))
|
|
201
|
+
continue;
|
|
202
|
+
const glob = policy.globs.find((candidate) => claimTouchesGlob(claim, candidate));
|
|
203
|
+
if (glob !== undefined)
|
|
204
|
+
triggers.push({ trigger: 'sensitive-claim-path', claim, glob });
|
|
205
|
+
}
|
|
206
|
+
const taskCase = context.case ?? task.case;
|
|
207
|
+
if (taskCase !== undefined && policy.cases.includes(taskCase)) {
|
|
208
|
+
triggers.push({ trigger: 'case', case: taskCase });
|
|
209
|
+
}
|
|
210
|
+
for (const tag of context.epicTags ?? []) {
|
|
211
|
+
if (policy.epicTags.includes(tag))
|
|
212
|
+
triggers.push({ trigger: 'epic-tag', tag });
|
|
213
|
+
}
|
|
214
|
+
if (context.scheduledRecheck === true && policy.scheduledRecheck) {
|
|
215
|
+
triggers.push({ trigger: 'scheduled-recheck' });
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
taskId: task.task_id ?? null,
|
|
219
|
+
dispatchSecurityReviewer: triggers.length > 0,
|
|
220
|
+
triggers,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import picomatch from 'picomatch';
|
|
3
|
+
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
import { SmithError } from './errors.js';
|
|
5
|
+
import { SEVERITY_POLICY_PATH } from './paths.js';
|
|
6
|
+
export class SeverityError extends SmithError {
|
|
7
|
+
}
|
|
8
|
+
/** Escalation order, worst first — the same order severity.yml/taxonomy.yml document values in. */
|
|
9
|
+
export const SEVERITY_ORDER = [
|
|
10
|
+
'S1-stop-the-line',
|
|
11
|
+
'S2-major',
|
|
12
|
+
'S3-minor',
|
|
13
|
+
'S4-nit',
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* Position in SEVERITY_ORDER, or `null` for a value that is not in it.
|
|
17
|
+
*
|
|
18
|
+
* `null` rather than a sentinel index, because both sentinels lie:
|
|
19
|
+
* `Number.POSITIVE_INFINITY` makes an unknown severity the mildest thing in the
|
|
20
|
+
* list and `-1` makes it the worst, and a caller that forgets to check gets a
|
|
21
|
+
* confident wrong answer either way. Callers that must not silently pass an
|
|
22
|
+
* unknown value — escalate() below — turn the `null` into a
|
|
23
|
+
* `severity.unknown-severity` throw.
|
|
24
|
+
*/
|
|
25
|
+
export function severityRank(severity) {
|
|
26
|
+
const index = SEVERITY_ORDER.indexOf(severity);
|
|
27
|
+
return index === -1 ? null : index;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The worse of two severities, worst-wins. Used by crossFinding.ts's
|
|
31
|
+
* `highest-wins` resolution, where two providers raised the same fingerprint
|
|
32
|
+
* and disagree on how bad it is.
|
|
33
|
+
*
|
|
34
|
+
* An unknown value on either side throws rather than losing the comparison:
|
|
35
|
+
* mintFindings() already refuses a non-canonical severity, so an unknown one
|
|
36
|
+
* here means a stored finding predates a taxonomy edit, and quietly treating it
|
|
37
|
+
* as mild is how an S1 becomes an S4.
|
|
38
|
+
*/
|
|
39
|
+
export function worseSeverity(a, b) {
|
|
40
|
+
const rankA = severityRank(a);
|
|
41
|
+
const rankB = severityRank(b);
|
|
42
|
+
for (const [value, rank] of [
|
|
43
|
+
[a, rankA],
|
|
44
|
+
[b, rankB],
|
|
45
|
+
]) {
|
|
46
|
+
if (rank === null) {
|
|
47
|
+
throw new SeverityError('severity.unknown-severity', `Unknown severity "${value}".`, {
|
|
48
|
+
severity: value,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return rankA <= rankB ? a : b;
|
|
53
|
+
}
|
|
54
|
+
export function parseSeverityPolicy(yamlText) {
|
|
55
|
+
const doc = parseYaml(yamlText);
|
|
56
|
+
const rawLevels = doc.levels;
|
|
57
|
+
if (!rawLevels || typeof rawLevels !== 'object') {
|
|
58
|
+
throw new SeverityError('severity.invalid-document', 'severity.yml is missing `levels`.');
|
|
59
|
+
}
|
|
60
|
+
const levels = {};
|
|
61
|
+
for (const [name, level] of Object.entries(rawLevels)) {
|
|
62
|
+
if (typeof level.blocks_merge !== 'boolean') {
|
|
63
|
+
throw new SeverityError('severity.invalid-document', `severity.yml level "${name}" is missing boolean blocks_merge.`, { level: name });
|
|
64
|
+
}
|
|
65
|
+
levels[name] = { blocksMerge: level.blocks_merge };
|
|
66
|
+
}
|
|
67
|
+
return { levels };
|
|
68
|
+
}
|
|
69
|
+
export function loadSeverityPolicy(filePath = SEVERITY_POLICY_PATH) {
|
|
70
|
+
return parseSeverityPolicy(readFileSync(filePath, 'utf8'));
|
|
71
|
+
}
|
|
72
|
+
const ENTRY_HEADING = /^### /;
|
|
73
|
+
const SECTION_HEADING = /^## (.+)$/;
|
|
74
|
+
const BULLET = /^- ([a-z_]+):\s*(.*)$/;
|
|
75
|
+
/** An indented, non-bullet line — a continuation of the previous bullet's value. */
|
|
76
|
+
const CONTINUATION = /^\s+(\S.*)$/;
|
|
77
|
+
/**
|
|
78
|
+
* taxonomy.yml `lesson_scope`, in compiled-section order — only entries under
|
|
79
|
+
* one of these `##` sections are real lessons, which is also what stops a `##`
|
|
80
|
+
* heading smuggled into a statement from minting one. Single source for both
|
|
81
|
+
* the parse side (here) and the compile side (`lessons.ts` imports this);
|
|
82
|
+
* `test/severity.test.ts` asserts it still matches taxonomy.yml.
|
|
83
|
+
*/
|
|
84
|
+
export const LESSON_SCOPES = [
|
|
85
|
+
'agent-role',
|
|
86
|
+
'claim-path',
|
|
87
|
+
'case-type',
|
|
88
|
+
'stack-wide',
|
|
89
|
+
'security',
|
|
90
|
+
];
|
|
91
|
+
const VALID_SCOPES = new Set(LESSON_SCOPES);
|
|
92
|
+
/**
|
|
93
|
+
* Parse the "Same-mistake entry format" documented in lessons.md itself
|
|
94
|
+
* (§ Same-mistake entry format): a level-3 heading starts an entry, followed
|
|
95
|
+
* by `- key: value` bullets until a blank line or the next heading. A bullet
|
|
96
|
+
* value may wrap onto indented continuation lines (lessons.md's own example
|
|
97
|
+
* does exactly this for `statement`) — those are appended, trimmed and
|
|
98
|
+
* space-joined, to the bullet they follow; without this, a multi-line
|
|
99
|
+
* statement round-trips silently truncated mid-sentence. Entries missing
|
|
100
|
+
* statement/lesson_id are skipped, not fatal — lessons.md also carries
|
|
101
|
+
* non-rule / placeholder content ("_(none yet)_", prose) this parser has no
|
|
102
|
+
* business rejecting the whole file over. `finding_category` is OPTIONAL
|
|
103
|
+
* (lesson.schema.json says so, and agent-role/case-type entries never carry
|
|
104
|
+
* one) and parses as `''`: this file has two readers, the same-mistake match
|
|
105
|
+
* below and dispatch-time injection (lessons.ts, P9-2), and dropping a
|
|
106
|
+
* category-less entry here made every agent-role/case-type lesson — including
|
|
107
|
+
* every lesson `smith dream` raises — compile into lessons.md and then reach
|
|
108
|
+
* no one. `findMatchingLesson` skips the empty category explicitly, so
|
|
109
|
+
* escalation still matches exactly what it matched before. Only
|
|
110
|
+
* `###` headings inside a real `lesson_scope` section (`##
|
|
111
|
+
* agent-role`/`claim-path`/`case-type`/`stack-wide`) are parsed as entries —
|
|
112
|
+
* this also keeps the illustrative fenced example in lessons.md's own
|
|
113
|
+
* "Same-mistake entry format" doc section (before any `##` scope heading)
|
|
114
|
+
* from being parsed as a real lesson.
|
|
115
|
+
*
|
|
116
|
+
* Three bullets are SELECTORS — the one field a scope is filtered by at
|
|
117
|
+
* dispatch (D-129): `claim_path` for `claim-path`, `agent_role` for
|
|
118
|
+
* `agent-role`, `case_type` for `case-type`. Each is optional here and parses
|
|
119
|
+
* to `''` when absent, except `claim_path`, which keeps its historical `'**'`
|
|
120
|
+
* default. That asymmetry is deliberate and `lessonsForScope` depends on it: a
|
|
121
|
+
* missing selector must match NOTHING rather than everything, so an entry that
|
|
122
|
+
* names no role reaches no role instead of every one.
|
|
123
|
+
*/
|
|
124
|
+
export function parseLessons(markdown) {
|
|
125
|
+
const lines = markdown.split('\n');
|
|
126
|
+
const rules = [];
|
|
127
|
+
let scope = '';
|
|
128
|
+
let current = null;
|
|
129
|
+
let lastKey = null;
|
|
130
|
+
function flush() {
|
|
131
|
+
if (!current)
|
|
132
|
+
return;
|
|
133
|
+
const { lesson_id, finding_category, claim_path, agent_role, case_type, statement } = current;
|
|
134
|
+
if (lesson_id && statement) {
|
|
135
|
+
rules.push({
|
|
136
|
+
lessonId: lesson_id,
|
|
137
|
+
scope,
|
|
138
|
+
category: finding_category ?? '',
|
|
139
|
+
claimPath: claim_path ?? '**',
|
|
140
|
+
agentRole: agent_role ?? '',
|
|
141
|
+
caseType: case_type ?? '',
|
|
142
|
+
statement,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
current = null;
|
|
146
|
+
lastKey = null;
|
|
147
|
+
}
|
|
148
|
+
for (const line of lines) {
|
|
149
|
+
const sectionMatch = line.match(SECTION_HEADING);
|
|
150
|
+
if (sectionMatch) {
|
|
151
|
+
flush();
|
|
152
|
+
scope = sectionMatch[1].trim();
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (ENTRY_HEADING.test(line)) {
|
|
156
|
+
flush();
|
|
157
|
+
current = VALID_SCOPES.has(scope) ? {} : null;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (current) {
|
|
161
|
+
const bulletMatch = line.match(BULLET);
|
|
162
|
+
if (bulletMatch) {
|
|
163
|
+
const key = bulletMatch[1];
|
|
164
|
+
current[key] = bulletMatch[2].trim();
|
|
165
|
+
lastKey = key;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (line.trim() === '') {
|
|
169
|
+
// A blank line doesn't close the entry (only the next heading, or
|
|
170
|
+
// EOF, does) but it does end any bullet's continuation run.
|
|
171
|
+
lastKey = null;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const continuationMatch = line.match(CONTINUATION);
|
|
175
|
+
if (continuationMatch && lastKey) {
|
|
176
|
+
const existingValue = current[lastKey] ?? '';
|
|
177
|
+
current[lastKey] = `${existingValue} ${continuationMatch[1].trim()}`.trim();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
flush();
|
|
182
|
+
return rules;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Scopes whose claim_path is meaningful for the per-file same-mistake match
|
|
186
|
+
* (lessons.md convention). `security` is here because a security lesson is
|
|
187
|
+
* written against the paths it guards ("src/auth/**") — a repeat security
|
|
188
|
+
* finding on one of those paths is exactly what escalation is for. Left out, a
|
|
189
|
+
* security lesson would compile and be read by agents but never escalate
|
|
190
|
+
* anything (interview N-8).
|
|
191
|
+
*/
|
|
192
|
+
const FILE_SCOPED = new Set(['claim-path', 'stack-wide', 'security']);
|
|
193
|
+
/**
|
|
194
|
+
* Whether this lesson's scope makes its `claim_path` mean anything. An
|
|
195
|
+
* `agent-role`/`case-type` entry has no file to check a finding against, so it
|
|
196
|
+
* never participates in the per-file match however it is written.
|
|
197
|
+
*/
|
|
198
|
+
export function isFileScoped(lesson) {
|
|
199
|
+
return FILE_SCOPED.has(lesson.scope);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Whether this lesson's compiled `claim_path` covers `filePath` — the second
|
|
203
|
+
* half of the escalation match, exported so `lessonAudit.ts` can ask the match
|
|
204
|
+
* its own question rather than reimplementing the glob semantics beside it.
|
|
205
|
+
*/
|
|
206
|
+
export function lessonCoversFile(lesson, filePath) {
|
|
207
|
+
if (!isFileScoped(lesson))
|
|
208
|
+
return false;
|
|
209
|
+
const isMatch = picomatch(lesson.claimPath);
|
|
210
|
+
return isMatch(filePath);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Whether this lesson can ever escalate anything — the two conditions of the
|
|
214
|
+
* match below that depend on the lesson alone, not on the finding it is being
|
|
215
|
+
* matched against.
|
|
216
|
+
*
|
|
217
|
+
* 1. It names a `finding_category`. The match is an equality against the new
|
|
218
|
+
* finding's category, so an entry naming none is skipped before its claim
|
|
219
|
+
* path is ever consulted. A category-less lesson (agent-role/case-type, or
|
|
220
|
+
* anything `smith dream` raised) is injected at dispatch and read by
|
|
221
|
+
* agents; it is simply never a *same mistake*, because there is no
|
|
222
|
+
* category for it to be the same mistake as.
|
|
223
|
+
* 2. Its scope is file-scoped. `agent-role`/`case-type` entries have no file
|
|
224
|
+
* to check a finding against.
|
|
225
|
+
*
|
|
226
|
+
* Exported because the same-mistake KPI has to count the instrument as well as
|
|
227
|
+
* the reading (sameMistakeKpi.ts), and a second copy of these two conditions is
|
|
228
|
+
* a second thing to keep true — interview N-8, where the compile side and the
|
|
229
|
+
* parse side each kept their own scope list and `security` vanished between
|
|
230
|
+
* them with no error anywhere.
|
|
231
|
+
*/
|
|
232
|
+
export function canEscalate(lesson) {
|
|
233
|
+
return lesson.category !== '' && isFileScoped(lesson);
|
|
234
|
+
}
|
|
235
|
+
function findMatchingLesson(category, filePath, lessons) {
|
|
236
|
+
for (const lesson of lessons) {
|
|
237
|
+
if (!canEscalate(lesson))
|
|
238
|
+
continue;
|
|
239
|
+
if (lesson.category === category && lessonCoversFile(lesson, filePath))
|
|
240
|
+
return lesson;
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
function escalate(severity, policy) {
|
|
245
|
+
const index = severityRank(severity);
|
|
246
|
+
if (index === null) {
|
|
247
|
+
throw new SeverityError('severity.unknown-severity', `Unknown severity "${severity}".`, {
|
|
248
|
+
severity,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const escalated = SEVERITY_ORDER[Math.max(0, index - 1)];
|
|
252
|
+
if (!policy.levels[escalated]) {
|
|
253
|
+
throw new SeverityError('severity.unknown-severity', `Escalated severity "${escalated}" is not defined in severity.yml.`, { severity: escalated });
|
|
254
|
+
}
|
|
255
|
+
return escalated;
|
|
256
|
+
}
|
|
257
|
+
function actionFor(severity, level) {
|
|
258
|
+
if (level.blocksMerge)
|
|
259
|
+
return 'block';
|
|
260
|
+
return severity.startsWith('S3') ? 'waiver-batch' : 'log-only';
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Pure, deterministic gate decision for one finding: S1/S2 block, S3
|
|
264
|
+
* waiver-batch, S4 log-only (severity.yml) — escalated one severity level,
|
|
265
|
+
* up-capped at S1, when the finding matches an approved lesson for the same
|
|
266
|
+
* file + category (architecture §9.7).
|
|
267
|
+
*/
|
|
268
|
+
export function decide(finding, context, policy = loadSeverityPolicy()) {
|
|
269
|
+
// Corroboration first, escalation second, and the order is the decision:
|
|
270
|
+
// a repeat mistake is one level worse than whatever the finding actually
|
|
271
|
+
// IS, so the two readings have to be reconciled before the lesson has
|
|
272
|
+
// something to escalate. Reversed, a corroborated S2 that repeats a lesson
|
|
273
|
+
// would land at S2 instead of S1.
|
|
274
|
+
const corroborated = context.corroboratedSeverity === undefined
|
|
275
|
+
? finding.severity
|
|
276
|
+
: worseSeverity(finding.severity, context.corroboratedSeverity);
|
|
277
|
+
const matched = findMatchingLesson(finding.finding_category, context.filePath, context.lessons);
|
|
278
|
+
const severity = matched ? escalate(corroborated, policy) : corroborated;
|
|
279
|
+
const level = policy.levels[severity];
|
|
280
|
+
if (!level) {
|
|
281
|
+
throw new SeverityError('severity.unknown-severity', `Unknown severity "${severity}".`, {
|
|
282
|
+
severity,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
severity,
|
|
287
|
+
blocks: level.blocksMerge,
|
|
288
|
+
action: actionFor(severity, level),
|
|
289
|
+
sameMistake: matched !== null,
|
|
290
|
+
matchedLessonId: matched?.lessonId ?? null,
|
|
291
|
+
corroborated: corroborated !== finding.severity,
|
|
292
|
+
};
|
|
293
|
+
}
|