al-sem 0.0.1
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/LICENSE +21 -0
- package/README.md +361 -0
- package/package.json +64 -0
- package/scripts/d40-diff.ts +44 -0
- package/scripts/fetch-native-parser.ts +179 -0
- package/scripts/precision-sample.ts +99 -0
- package/scripts/precision-study.ts +42 -0
- package/scripts/precision-tabulate.ts +52 -0
- package/src/cli/baseline.ts +31 -0
- package/src/cli/diff.ts +199 -0
- package/src/cli/events-chains.ts +56 -0
- package/src/cli/events-fanout.ts +87 -0
- package/src/cli/exit-code.ts +30 -0
- package/src/cli/fingerprint-indexes.ts +130 -0
- package/src/cli/fingerprint-query.ts +543 -0
- package/src/cli/fingerprint-witness.ts +493 -0
- package/src/cli/fingerprint.ts +292 -0
- package/src/cli/format-compact-json.ts +45 -0
- package/src/cli/format-events.ts +77 -0
- package/src/cli/format-fingerprint.ts +295 -0
- package/src/cli/format-html.ts +503 -0
- package/src/cli/format-json.ts +13 -0
- package/src/cli/format-policy.ts +95 -0
- package/src/cli/format-sarif.ts +186 -0
- package/src/cli/format-terminal.ts +153 -0
- package/src/cli/index.ts +566 -0
- package/src/cli/policy.ts +204 -0
- package/src/config/roots-config.ts +302 -0
- package/src/deps/cache-versions.ts +74 -0
- package/src/deps/canonical-json.ts +27 -0
- package/src/deps/dependency-artifact.ts +144 -0
- package/src/deps/dependency-cache.ts +262 -0
- package/src/deps/dependency-dag.ts +128 -0
- package/src/deps/dependency-package-discovery.ts +85 -0
- package/src/deps/dependency-pipeline.ts +483 -0
- package/src/deps/dependency-projection.ts +211 -0
- package/src/deps/dependency-resolver.ts +154 -0
- package/src/deps/workspace-dependencies.ts +114 -0
- package/src/detectors/capability-query.ts +145 -0
- package/src/detectors/confidence.ts +52 -0
- package/src/detectors/d1-db-op-in-loop.ts +457 -0
- package/src/detectors/d10-self-modifying-loop.ts +114 -0
- package/src/detectors/d11-modify-without-get.ts +129 -0
- package/src/detectors/d12-dead-integration-event.ts +81 -0
- package/src/detectors/d13-cross-app-internal-call.ts +105 -0
- package/src/detectors/d14-dead-routine.ts +151 -0
- package/src/detectors/d16-obsolete-routine-call.ts +94 -0
- package/src/detectors/d17-min-version-drift.ts +157 -0
- package/src/detectors/d18-constant-filter-in-loop.ts +151 -0
- package/src/detectors/d19-unused-parameter.ts +116 -0
- package/src/detectors/d2-event-fanout-in-loop.ts +240 -0
- package/src/detectors/d20-unreachable-after-exit.ts +92 -0
- package/src/detectors/d21-read-without-load.ts +128 -0
- package/src/detectors/d22-flowfield-without-calcfields.ts +168 -0
- package/src/detectors/d29-subscriber-modify-on-event-record.ts +163 -0
- package/src/detectors/d3-load-state.ts +72 -0
- package/src/detectors/d3-missing-setloadfields.ts +234 -0
- package/src/detectors/d32-constant-boolean-parameter.ts +185 -0
- package/src/detectors/d33-unfiltered-bulk-write.ts +173 -0
- package/src/detectors/d34-commit-in-loop.ts +206 -0
- package/src/detectors/d35-commit-in-event-subscriber.ts +138 -0
- package/src/detectors/d36-late-setloadfields.ts +162 -0
- package/src/detectors/d37-validate-without-persist.ts +271 -0
- package/src/detectors/d38-subscriber-to-obsolete-event.ts +140 -0
- package/src/detectors/d39-record-left-dirty-across-chain.ts +165 -0
- package/src/detectors/d4-repeated-lookup-in-loop.ts +128 -0
- package/src/detectors/d40-transitive-load-missing.ts +217 -0
- package/src/detectors/d41-transitive-filter-loss.ts +200 -0
- package/src/detectors/d42-cross-call-wrong-setloadfields.ts +243 -0
- package/src/detectors/d43-event-ishandled-skip.ts +257 -0
- package/src/detectors/d44-event-multi-subscriber-overlap.ts +223 -0
- package/src/detectors/d45-event-transitive-table-exposure.ts +159 -0
- package/src/detectors/d5-set-based-opportunity.ts +162 -0
- package/src/detectors/d7-recursive-event-expansion.ts +151 -0
- package/src/detectors/d8-commit-in-transaction.ts +132 -0
- package/src/detectors/d9-transaction-span-summary.ts +107 -0
- package/src/detectors/detector-context.ts +121 -0
- package/src/detectors/finding-grouping.ts +61 -0
- package/src/detectors/path-merge.ts +174 -0
- package/src/detectors/registry.ts +176 -0
- package/src/detectors/table-display.ts +42 -0
- package/src/diff/diff-abi.ts +195 -0
- package/src/diff/diff-capabilities.ts +179 -0
- package/src/diff/diff-engine.ts +146 -0
- package/src/diff/diff-events.ts +323 -0
- package/src/diff/diff-identity.ts +73 -0
- package/src/diff/diff-indexes.ts +199 -0
- package/src/diff/diff-permissions.ts +260 -0
- package/src/diff/diff-policy.ts +101 -0
- package/src/diff/diff-preflight.ts +66 -0
- package/src/diff/diff-renames.ts +104 -0
- package/src/diff/diff-schema.ts +232 -0
- package/src/diff/format-diff.ts +148 -0
- package/src/engine/attribute-parser.ts +50 -0
- package/src/engine/capability-cone.ts +531 -0
- package/src/engine/combined-graph.ts +357 -0
- package/src/engine/control-flow-walker.ts +1317 -0
- package/src/engine/dispatch-sites.ts +199 -0
- package/src/engine/effect-lattice.ts +81 -0
- package/src/engine/entry-points.ts +57 -0
- package/src/engine/event-flow.ts +524 -0
- package/src/engine/event-relay.ts +92 -0
- package/src/engine/op-classification.ts +92 -0
- package/src/engine/path-walker.ts +189 -0
- package/src/engine/reverse-call-graph.ts +23 -0
- package/src/engine/root-classifier-overlay.ts +194 -0
- package/src/engine/root-classifier.ts +135 -0
- package/src/engine/scc.ts +110 -0
- package/src/engine/source-anchor.ts +25 -0
- package/src/engine/summary-context.ts +104 -0
- package/src/engine/summary-engine.ts +296 -0
- package/src/engine/summary-runner.ts +560 -0
- package/src/engine/transaction-spans.ts +112 -0
- package/src/engine/uncertainty-util.ts +54 -0
- package/src/hash.ts +31 -0
- package/src/index/attribute-from-node.ts +141 -0
- package/src/index/callee-from-node.ts +181 -0
- package/src/index/capability/background.ts +90 -0
- package/src/index/capability/commit.ts +44 -0
- package/src/index/capability/dispatch.ts +164 -0
- package/src/index/capability/events.ts +65 -0
- package/src/index/capability/extractor.ts +124 -0
- package/src/index/capability/file-blob.ts +137 -0
- package/src/index/capability/http.ts +159 -0
- package/src/index/capability/hyperlink.ts +60 -0
- package/src/index/capability/isolated-storage.ts +179 -0
- package/src/index/capability/table.ts +113 -0
- package/src/index/capability/telemetry.ts +84 -0
- package/src/index/capability/ui.ts +55 -0
- package/src/index/capability/value-source.ts +202 -0
- package/src/index/expression-from-node.ts +117 -0
- package/src/index/indexer.ts +102 -0
- package/src/index/intraprocedural-body.ts +1467 -0
- package/src/index/intraprocedural-ops.ts +253 -0
- package/src/index/intraprocedural-refs.ts +188 -0
- package/src/index/object-indexer.ts +279 -0
- package/src/index/routine-indexer.ts +282 -0
- package/src/index/routine-signature.ts +46 -0
- package/src/index/variable-indexer.ts +134 -0
- package/src/index/variable-initializer-extractor.ts +155 -0
- package/src/index/variable-type-normalizer.ts +83 -0
- package/src/index.ts +267 -0
- package/src/mcp/server.ts +72 -0
- package/src/mcp/session.ts +49 -0
- package/src/mcp/tools/explain-path.ts +75 -0
- package/src/mcp/tools/get-analysis-health.ts +62 -0
- package/src/mcp/tools/get-finding.ts +47 -0
- package/src/mcp/tools/get-routine-summary.ts +126 -0
- package/src/mcp/tools/list-findings.ts +85 -0
- package/src/mcp/tools/list-hotspots.ts +78 -0
- package/src/mcp/tools/list-rollups.ts +103 -0
- package/src/mcp/tools/validators.ts +25 -0
- package/src/model/attributes.ts +120 -0
- package/src/model/callee.ts +45 -0
- package/src/model/capability.ts +187 -0
- package/src/model/coverage.ts +85 -0
- package/src/model/entities.ts +628 -0
- package/src/model/expression.ts +98 -0
- package/src/model/finding.ts +110 -0
- package/src/model/graph-edge.ts +93 -0
- package/src/model/graph.ts +62 -0
- package/src/model/identity.ts +81 -0
- package/src/model/ids.ts +90 -0
- package/src/model/index.ts +13 -0
- package/src/model/model.ts +51 -0
- package/src/model/permission.ts +76 -0
- package/src/model/root-classification.ts +116 -0
- package/src/model/stable-identity.ts +102 -0
- package/src/model/summary.ts +96 -0
- package/src/parser/ast.ts +82 -0
- package/src/parser/native/ffi.ts +145 -0
- package/src/parser/native/parse-index-pool.ts +148 -0
- package/src/parser/native/parse-index-worker.ts +94 -0
- package/src/parser/native/wrapper.ts +353 -0
- package/src/parser/parser-init.ts +43 -0
- package/src/perf/profiler.ts +66 -0
- package/src/policy/policy-default.yaml +83 -0
- package/src/policy/policy-engine.ts +339 -0
- package/src/policy/policy-loader.ts +257 -0
- package/src/policy/policy-schema.json +379 -0
- package/src/policy/policy-types.ts +81 -0
- package/src/policy/predicate-compiler.ts +151 -0
- package/src/policy/predicate-evaluator.ts +267 -0
- package/src/policy/predicate-fields.ts +439 -0
- package/src/projection/actionable-anchor.ts +48 -0
- package/src/projection/finding-filters.ts +44 -0
- package/src/projection/finding-fingerprint.ts +54 -0
- package/src/projection/finding-groups.ts +41 -0
- package/src/projection/finding-summary.ts +110 -0
- package/src/projection/rollup-findings.ts +105 -0
- package/src/providers/discover.ts +88 -0
- package/src/providers/external.ts +46 -0
- package/src/providers/types.ts +36 -0
- package/src/providers/workspace.ts +117 -0
- package/src/resolve/call-resolver.ts +117 -0
- package/src/resolve/coverage.ts +61 -0
- package/src/resolve/event-graph.ts +166 -0
- package/src/resolve/implicit-edges.ts +53 -0
- package/src/resolve/record-types.ts +36 -0
- package/src/resolve/resolver.ts +23 -0
- package/src/resolve/semantic-graph.ts +29 -0
- package/src/resolve/symbol-table.ts +69 -0
- package/src/snapshot/app-snapshot.ts +74 -0
- package/src/snapshot/compose.ts +100 -0
- package/src/snapshot/derive/callsite-evidence.ts +76 -0
- package/src/snapshot/derive/capability-facts.ts +70 -0
- package/src/snapshot/derive/contracts.ts +131 -0
- package/src/snapshot/derive/coverage.ts +35 -0
- package/src/snapshot/derive/event-declarations.ts +140 -0
- package/src/snapshot/derive/identity-table.ts +58 -0
- package/src/snapshot/derive/inputs.ts +91 -0
- package/src/snapshot/derive/operation-evidence.ts +70 -0
- package/src/snapshot/derive/permissions.ts +186 -0
- package/src/snapshot/derive/root-classifications.ts +56 -0
- package/src/snapshot/derive/schema.ts +130 -0
- package/src/snapshot/derive/typed-edges.ts +60 -0
- package/src/snapshot/derive/workspace-fingerprint.ts +19 -0
- package/src/snapshot/deserialize.ts +40 -0
- package/src/snapshot/serialize-cbor-gz.ts +12 -0
- package/src/snapshot/serialize-cbor.ts +19 -0
- package/src/snapshot/serialize-json.ts +22 -0
- package/src/snapshot/shard.ts +134 -0
- package/src/snapshot/types.ts +181 -0
- package/src/symbols/app-manifest.ts +96 -0
- package/src/symbols/app-package-zip.ts +50 -0
- package/src/symbols/embedded-source-reader.ts +41 -0
- package/src/symbols/package-hash.ts +81 -0
- package/src/symbols/symbol-reader.ts +101 -0
- package/src/symbols/symbol-reference-parser.ts +378 -0
- package/src/symbols/symbol-reference-reader.ts +27 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { compareStrings } from "../engine/uncertainty-util.ts";
|
|
2
|
+
import type { CapabilityFact } from "../model/capability.ts";
|
|
3
|
+
import { type Routine, type Scope, roleOf } from "../model/entities.ts";
|
|
4
|
+
import type { Diagnostic, EvidenceStep, Finding } from "../model/finding.ts";
|
|
5
|
+
import type { SemanticModel } from "../model/model.ts";
|
|
6
|
+
import { fingerprintOf } from "../projection/finding-fingerprint.ts";
|
|
7
|
+
import type {
|
|
8
|
+
CoveragePolicy,
|
|
9
|
+
PolicyDoc,
|
|
10
|
+
PolicyRunResult,
|
|
11
|
+
Rule,
|
|
12
|
+
RuleRunSummary,
|
|
13
|
+
UnknownPolicy,
|
|
14
|
+
} from "./policy-types.ts";
|
|
15
|
+
import { evaluateApplicability, evaluateResult } from "./predicate-evaluator.ts";
|
|
16
|
+
import { type FieldEvalContext, buildFieldIndexes } from "./predicate-fields.ts";
|
|
17
|
+
|
|
18
|
+
function pickCoverage(rule: Rule, doc: PolicyDoc): CoveragePolicy {
|
|
19
|
+
return rule.requireCoverage ?? doc.defaults?.requireCoverage ?? "any";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function pickUnknown(rule: Rule, doc: PolicyDoc): UnknownPolicy {
|
|
23
|
+
return rule.onUnknown ?? doc.defaults?.onUnknown ?? "fail-closed";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function passesCoverageGate(coverageStatus: string | undefined, gate: CoveragePolicy): boolean {
|
|
27
|
+
if (gate === "any") return true;
|
|
28
|
+
if (gate === "partial") return coverageStatus !== "unknown";
|
|
29
|
+
// gate === "complete"
|
|
30
|
+
return coverageStatus === "complete";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function selectFacts(
|
|
34
|
+
rule: Rule,
|
|
35
|
+
summary:
|
|
36
|
+
| {
|
|
37
|
+
capabilityFactsDirect?: readonly CapabilityFact[];
|
|
38
|
+
capabilityFactsInherited?: readonly CapabilityFact[];
|
|
39
|
+
}
|
|
40
|
+
| undefined,
|
|
41
|
+
): readonly CapabilityFact[] {
|
|
42
|
+
if (summary === undefined) return [];
|
|
43
|
+
const direct = summary.capabilityFactsDirect ?? [];
|
|
44
|
+
const inherited = summary.capabilityFactsInherited ?? [];
|
|
45
|
+
switch (rule.facts ?? "any") {
|
|
46
|
+
case "direct":
|
|
47
|
+
return direct;
|
|
48
|
+
case "inherited":
|
|
49
|
+
return inherited;
|
|
50
|
+
case "any":
|
|
51
|
+
return [...direct, ...inherited];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function factSortKey(f: CapabilityFact): string {
|
|
56
|
+
return [
|
|
57
|
+
f.op,
|
|
58
|
+
f.resourceKind,
|
|
59
|
+
f.resourceId ?? "",
|
|
60
|
+
f.witnessOperationId ?? "",
|
|
61
|
+
f.confidence,
|
|
62
|
+
f.provenance,
|
|
63
|
+
f.via,
|
|
64
|
+
f.witnessCallsiteId ?? "",
|
|
65
|
+
].join("|");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function runPolicyEngine(
|
|
69
|
+
model: SemanticModel,
|
|
70
|
+
policy: PolicyDoc,
|
|
71
|
+
opts?: { scope?: Scope },
|
|
72
|
+
): Omit<PolicyRunResult, "policySource" | "policyVersion"> {
|
|
73
|
+
const findings: Finding[] = [];
|
|
74
|
+
const ruleSummaries: RuleRunSummary[] = [];
|
|
75
|
+
const diagnostics: Diagnostic[] = [];
|
|
76
|
+
|
|
77
|
+
const scope = opts?.scope ?? "all";
|
|
78
|
+
const sortedRules = [...policy.rules].sort((a, b) => compareStrings(a.id, b.id));
|
|
79
|
+
const allRoutines = [...model.routines].sort((a, b) => compareStrings(a.id, b.id));
|
|
80
|
+
// scope=primary restricts to the workspace's own routines (a dependency match is not
|
|
81
|
+
// author-actionable); filtering the loop keeps ruleSummaries counts consistent with findings.
|
|
82
|
+
const sortedRoutines =
|
|
83
|
+
scope === "all" ? allRoutines : allRoutines.filter((r) => roleOf(r) !== "dependency");
|
|
84
|
+
const indexes = buildFieldIndexes(model);
|
|
85
|
+
|
|
86
|
+
// Sorted-fact lists are identical across rules that share a `facts` mode (all bundled
|
|
87
|
+
// defaults use "any"), so computing `[...direct, ...inherited].sort()` per (rule × routine)
|
|
88
|
+
// re-did the same allocation+sort up to 8× per routine — the dominant cost on a large merged
|
|
89
|
+
// model. Memoize by (routineId, factsMode); output-identical.
|
|
90
|
+
const factsCache = new Map<string, readonly CapabilityFact[]>();
|
|
91
|
+
const sortedFactsFor = (rule: Rule, routine: Routine): readonly CapabilityFact[] => {
|
|
92
|
+
const mode = rule.facts ?? "any";
|
|
93
|
+
const key = `${routine.id}|${mode}`;
|
|
94
|
+
const cached = factsCache.get(key);
|
|
95
|
+
if (cached !== undefined) return cached;
|
|
96
|
+
const computed = [...selectFacts(rule, routine.summary)].sort((a, b) =>
|
|
97
|
+
compareStrings(factSortKey(a), factSortKey(b)),
|
|
98
|
+
);
|
|
99
|
+
factsCache.set(key, computed);
|
|
100
|
+
return computed;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
for (const rule of sortedRules) {
|
|
104
|
+
const summary: RuleRunSummary = {
|
|
105
|
+
ruleId: rule.id,
|
|
106
|
+
routinesEvaluated: 0,
|
|
107
|
+
routinesMatched: 0,
|
|
108
|
+
routinesSkippedCoverage: 0,
|
|
109
|
+
routinesSkippedUnknown: 0,
|
|
110
|
+
routinesPassed: 0,
|
|
111
|
+
findingsEmitted: 0,
|
|
112
|
+
};
|
|
113
|
+
const coverageGate = pickCoverage(rule, policy);
|
|
114
|
+
const unknownPolicy = pickUnknown(rule, policy);
|
|
115
|
+
const ruleFindings: Finding[] = [];
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
for (const routine of sortedRoutines) {
|
|
119
|
+
summary.routinesEvaluated++;
|
|
120
|
+
|
|
121
|
+
// Applicability: skip routines this rule structurally cannot apply to.
|
|
122
|
+
// `false` ⇒ no fact can satisfy `when` ⇒ no coverage finding, no fact loop.
|
|
123
|
+
if (evaluateApplicability(rule.when, { routine, model, indexes }) === "false") {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Structural except: if `except` is definitely true regardless of facts,
|
|
128
|
+
// the routine is exempt — skip before coverage (no coverage finding).
|
|
129
|
+
// Safe to skip: applicability "true" means a routine-scoped branch already
|
|
130
|
+
// forces except true via Kleene OR-absorption — no concrete fact can lower it
|
|
131
|
+
// to a non-exempt result, so this routine is structurally exempt.
|
|
132
|
+
if (
|
|
133
|
+
rule.except !== undefined &&
|
|
134
|
+
evaluateApplicability(rule.except, { routine, model, indexes }) === "true"
|
|
135
|
+
) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const coverageStatus = routine.summary?.coverage?.inheritedStatus as string | undefined;
|
|
140
|
+
if (!passesCoverageGate(coverageStatus, coverageGate)) {
|
|
141
|
+
summary.routinesSkippedCoverage++;
|
|
142
|
+
if (unknownPolicy === "fail-closed") {
|
|
143
|
+
ruleFindings.push(
|
|
144
|
+
emitCoverageFinding(rule, routine, model, coverageStatus ?? "unknown", coverageGate),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const facts = sortedFactsFor(rule, routine);
|
|
151
|
+
if (facts.length === 0) {
|
|
152
|
+
// Applicable + covered + no selected facts ⇒ rule does not fire (E2).
|
|
153
|
+
summary.routinesPassed++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const matchedFacts: CapabilityFact[] = [];
|
|
158
|
+
let sawUnknown = false;
|
|
159
|
+
|
|
160
|
+
for (const fact of facts) {
|
|
161
|
+
const ctx: FieldEvalContext = { routine, fact, model, indexes };
|
|
162
|
+
const whenResult = evaluateResult(rule.when, ctx);
|
|
163
|
+
if (whenResult === "false") continue;
|
|
164
|
+
if (whenResult === "unknown") {
|
|
165
|
+
sawUnknown = true;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// when === "true" — evaluate except per-fact if present.
|
|
169
|
+
if (rule.except !== undefined && evaluateResult(rule.except, ctx) === "true") {
|
|
170
|
+
continue; // except=true → carve-out; except=false/unknown → violation.
|
|
171
|
+
}
|
|
172
|
+
matchedFacts.push(fact);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (matchedFacts.length > 0) {
|
|
176
|
+
summary.routinesMatched++;
|
|
177
|
+
ruleFindings.push(emitMatchFinding(rule, routine, model, matchedFacts));
|
|
178
|
+
} else if (sawUnknown) {
|
|
179
|
+
summary.routinesSkippedUnknown++;
|
|
180
|
+
if (unknownPolicy === "fail-closed") {
|
|
181
|
+
ruleFindings.push(emitUnknownFinding(rule, routine, model));
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
summary.routinesPassed++;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
diagnostics.push({
|
|
189
|
+
severity: "warning",
|
|
190
|
+
stage: "detect",
|
|
191
|
+
message: `policy rule '${rule.id}' threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
192
|
+
});
|
|
193
|
+
summary.errors = [err instanceof Error ? err.message : String(err)];
|
|
194
|
+
ruleSummaries.push(summary);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
summary.findingsEmitted = ruleFindings.length;
|
|
199
|
+
findings.push(...ruleFindings);
|
|
200
|
+
ruleSummaries.push(summary);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
findings.sort((a, b) => compareStrings(a.id, b.id));
|
|
204
|
+
return { ruleSummaries, findings, diagnostics };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function buildPrimaryLocation(routine: Routine) {
|
|
208
|
+
return { ...routine.sourceAnchor, enclosingRoutineId: routine.id };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function emitMatchFinding(
|
|
212
|
+
rule: Rule,
|
|
213
|
+
routine: Routine,
|
|
214
|
+
model: SemanticModel,
|
|
215
|
+
matched: readonly CapabilityFact[],
|
|
216
|
+
): Finding {
|
|
217
|
+
const rootCauseKey = `policy-${rule.id}/${routine.id}`;
|
|
218
|
+
const primaryLocation = buildPrimaryLocation(routine);
|
|
219
|
+
const evidence: EvidenceStep[] = [
|
|
220
|
+
{
|
|
221
|
+
routineId: routine.id,
|
|
222
|
+
sourceAnchor: primaryLocation,
|
|
223
|
+
note: rule.message ?? rule.title ?? `Policy rule ${rule.id} violation`,
|
|
224
|
+
},
|
|
225
|
+
...matched.map((fact) => ({
|
|
226
|
+
routineId: routine.id,
|
|
227
|
+
sourceAnchor: primaryLocation,
|
|
228
|
+
note: `matched on capability.op=${fact.op}, resourceKind=${fact.resourceKind}${fact.resourceId !== undefined ? `, resourceId=${fact.resourceId}` : ""}`,
|
|
229
|
+
operationId: fact.witnessOperationId,
|
|
230
|
+
callsiteId: fact.witnessCallsiteId,
|
|
231
|
+
})),
|
|
232
|
+
];
|
|
233
|
+
const finding: Finding = {
|
|
234
|
+
id: rootCauseKey,
|
|
235
|
+
rootCauseKey,
|
|
236
|
+
detector: `policy-${rule.id}`,
|
|
237
|
+
title: rule.title ?? rule.id,
|
|
238
|
+
rootCause:
|
|
239
|
+
rule.message ??
|
|
240
|
+
rule.description ??
|
|
241
|
+
`Policy ${rule.id} matched ${matched.length} fact(s) on routine ${routine.id}.`,
|
|
242
|
+
severity: rule.severity,
|
|
243
|
+
confidence: { level: "likely", evidence: [] },
|
|
244
|
+
primaryLocation,
|
|
245
|
+
evidencePath: evidence,
|
|
246
|
+
affectedObjects: [routine.objectId],
|
|
247
|
+
affectedTables: [],
|
|
248
|
+
fixOptions: [],
|
|
249
|
+
provenance: [{ source: "tree-sitter" }],
|
|
250
|
+
};
|
|
251
|
+
finding.fingerprint = fingerprintOf(finding, model);
|
|
252
|
+
return finding;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function emitCoverageFinding(
|
|
256
|
+
rule: Rule,
|
|
257
|
+
routine: Routine,
|
|
258
|
+
model: SemanticModel,
|
|
259
|
+
status: string,
|
|
260
|
+
effectiveGate: CoveragePolicy,
|
|
261
|
+
): Finding {
|
|
262
|
+
const rootCauseKey = `policy-${rule.id}/${routine.id}`;
|
|
263
|
+
const primaryLocation = buildPrimaryLocation(routine);
|
|
264
|
+
const finding: Finding = {
|
|
265
|
+
id: rootCauseKey,
|
|
266
|
+
rootCauseKey,
|
|
267
|
+
detector: `policy-${rule.id}`,
|
|
268
|
+
title: rule.title ?? rule.id,
|
|
269
|
+
rootCause: `Coverage gate (requireCoverage=${effectiveGate}) failed: routine coverage is ${status}.`,
|
|
270
|
+
severity: rule.severity,
|
|
271
|
+
confidence: { level: "possible", evidence: [] },
|
|
272
|
+
primaryLocation,
|
|
273
|
+
evidencePath: [
|
|
274
|
+
{
|
|
275
|
+
routineId: routine.id,
|
|
276
|
+
sourceAnchor: primaryLocation,
|
|
277
|
+
note: `coverage=${status}`,
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
affectedObjects: [routine.objectId],
|
|
281
|
+
affectedTables: [],
|
|
282
|
+
fixOptions: [],
|
|
283
|
+
provenance: [{ source: "tree-sitter" }],
|
|
284
|
+
};
|
|
285
|
+
finding.fingerprint = fingerprintOf(finding, model);
|
|
286
|
+
return finding;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function emitUnknownFinding(rule: Rule, routine: Routine, model: SemanticModel): Finding {
|
|
290
|
+
const rootCauseKey = `policy-${rule.id}/${routine.id}`;
|
|
291
|
+
const primaryLocation = buildPrimaryLocation(routine);
|
|
292
|
+
const finding: Finding = {
|
|
293
|
+
id: rootCauseKey,
|
|
294
|
+
rootCauseKey,
|
|
295
|
+
detector: `policy-${rule.id}`,
|
|
296
|
+
title: rule.title ?? rule.id,
|
|
297
|
+
rootCause: "Predicate could not be resolved on any fact; onUnknown=fail-closed.",
|
|
298
|
+
severity: rule.severity,
|
|
299
|
+
confidence: { level: "possible", evidence: [] },
|
|
300
|
+
primaryLocation,
|
|
301
|
+
evidencePath: [
|
|
302
|
+
{
|
|
303
|
+
routineId: routine.id,
|
|
304
|
+
sourceAnchor: primaryLocation,
|
|
305
|
+
note: "policy unknown — fail-closed",
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
affectedObjects: [routine.objectId],
|
|
309
|
+
affectedTables: [],
|
|
310
|
+
fixOptions: [],
|
|
311
|
+
provenance: [{ source: "tree-sitter" }],
|
|
312
|
+
};
|
|
313
|
+
finding.fingerprint = fingerprintOf(finding, model);
|
|
314
|
+
return finding;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Public wrapper that adds policySource + policyVersion to the engine result.
|
|
319
|
+
* Use this from the CLI and integration layer; use runPolicyEngine directly
|
|
320
|
+
* in unit tests that don't need the source/version envelope.
|
|
321
|
+
*/
|
|
322
|
+
export function runPolicy(
|
|
323
|
+
model: SemanticModel,
|
|
324
|
+
policy: PolicyDoc | undefined,
|
|
325
|
+
source: string,
|
|
326
|
+
opts?: { scope?: Scope },
|
|
327
|
+
): PolicyRunResult {
|
|
328
|
+
if (policy === undefined) {
|
|
329
|
+
return {
|
|
330
|
+
policySource: source,
|
|
331
|
+
policyVersion: 0,
|
|
332
|
+
ruleSummaries: [],
|
|
333
|
+
findings: [],
|
|
334
|
+
diagnostics: [],
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
const r = runPolicyEngine(model, policy, opts);
|
|
338
|
+
return { policySource: source, policyVersion: policy.version, ...r };
|
|
339
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parseDocument } from "yaml";
|
|
3
|
+
import type {
|
|
4
|
+
CoveragePolicy,
|
|
5
|
+
FactOriginFilter,
|
|
6
|
+
PolicyDoc,
|
|
7
|
+
PolicySeverity,
|
|
8
|
+
Rule,
|
|
9
|
+
UnknownPolicy,
|
|
10
|
+
} from "./policy-types.ts";
|
|
11
|
+
import { compilePredicate } from "./predicate-compiler.ts";
|
|
12
|
+
import { PREDICATE_FIELDS } from "./predicate-fields.ts";
|
|
13
|
+
|
|
14
|
+
export type LoadResult =
|
|
15
|
+
| { ok: true; policy: PolicyDoc; warnings: readonly string[] }
|
|
16
|
+
| { ok: false; errors: readonly string[]; warnings: readonly string[] };
|
|
17
|
+
|
|
18
|
+
const RULE_ID_RE = /^[a-z][a-z0-9-]{2,80}$/;
|
|
19
|
+
const ALLOWED_SEVERITIES: readonly PolicySeverity[] = ["critical", "high", "medium", "low", "info"];
|
|
20
|
+
const ALLOWED_UNKNOWN: readonly UnknownPolicy[] = ["fail-open", "fail-closed"];
|
|
21
|
+
const ALLOWED_COVERAGE: readonly CoveragePolicy[] = ["complete", "partial", "any"];
|
|
22
|
+
const ALLOWED_FACTS: readonly FactOriginFilter[] = ["direct", "inherited", "any"];
|
|
23
|
+
const ALLOWED_TOP_FIELDS = new Set(["version", "description", "defaults", "rules"]);
|
|
24
|
+
const ALLOWED_RULE_FIELDS = new Set([
|
|
25
|
+
"id",
|
|
26
|
+
"title",
|
|
27
|
+
"description",
|
|
28
|
+
"message",
|
|
29
|
+
"severity",
|
|
30
|
+
"when",
|
|
31
|
+
"except",
|
|
32
|
+
"requireCoverage",
|
|
33
|
+
"onUnknown",
|
|
34
|
+
"facts",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export function loadPolicyFromString(yaml: string): LoadResult {
|
|
38
|
+
const errors: string[] = [];
|
|
39
|
+
const warnings: string[] = [];
|
|
40
|
+
|
|
41
|
+
const doc = parseDocument(yaml, { uniqueKeys: true, customTags: [] });
|
|
42
|
+
if (doc.errors.length > 0) {
|
|
43
|
+
for (const e of doc.errors) errors.push(`yaml: ${e.message}`);
|
|
44
|
+
return { ok: false, errors, warnings };
|
|
45
|
+
}
|
|
46
|
+
for (const w of doc.warnings) warnings.push(`yaml: ${w.message}`);
|
|
47
|
+
|
|
48
|
+
// maxAliasCount: 0 disallows all alias nodes (anti-billion-laughs)
|
|
49
|
+
const obj = doc.toJS({ maxAliasCount: 0 });
|
|
50
|
+
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) {
|
|
51
|
+
return { ok: false, errors: ["policy root must be a map"], warnings };
|
|
52
|
+
}
|
|
53
|
+
const top = obj as Record<string, unknown>;
|
|
54
|
+
|
|
55
|
+
for (const k of Object.keys(top)) {
|
|
56
|
+
if (!ALLOWED_TOP_FIELDS.has(k)) errors.push(`unknown top-level field '${k}'`);
|
|
57
|
+
}
|
|
58
|
+
if (top.version !== 1) errors.push(`policy version must be 1 (got ${String(top.version)})`);
|
|
59
|
+
|
|
60
|
+
const defaults: { onUnknown?: UnknownPolicy; requireCoverage?: CoveragePolicy } = {};
|
|
61
|
+
if (top.defaults !== undefined) {
|
|
62
|
+
if (typeof top.defaults !== "object" || top.defaults === null || Array.isArray(top.defaults)) {
|
|
63
|
+
errors.push("defaults must be a map");
|
|
64
|
+
} else {
|
|
65
|
+
const d = top.defaults as Record<string, unknown>;
|
|
66
|
+
if (d.onUnknown !== undefined) {
|
|
67
|
+
if (ALLOWED_UNKNOWN.includes(d.onUnknown as UnknownPolicy)) {
|
|
68
|
+
defaults.onUnknown = d.onUnknown as UnknownPolicy;
|
|
69
|
+
} else {
|
|
70
|
+
errors.push(`defaults.onUnknown: must be one of ${ALLOWED_UNKNOWN.join(", ")}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (d.requireCoverage !== undefined) {
|
|
74
|
+
if (ALLOWED_COVERAGE.includes(d.requireCoverage as CoveragePolicy)) {
|
|
75
|
+
defaults.requireCoverage = d.requireCoverage as CoveragePolicy;
|
|
76
|
+
} else {
|
|
77
|
+
errors.push(`defaults.requireCoverage: must be one of ${ALLOWED_COVERAGE.join(", ")}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!Array.isArray(top.rules)) {
|
|
84
|
+
return { ok: false, errors: [...errors, "rules must be an array"], warnings };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const rules: Rule[] = [];
|
|
88
|
+
const seenIds = new Set<string>();
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < top.rules.length; i++) {
|
|
91
|
+
const raw = top.rules[i];
|
|
92
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
93
|
+
errors.push(`rules[${i}]: must be a map`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const r = raw as Record<string, unknown>;
|
|
97
|
+
const path = `rules[${i}]`;
|
|
98
|
+
|
|
99
|
+
for (const k of Object.keys(r)) {
|
|
100
|
+
if (!ALLOWED_RULE_FIELDS.has(k)) errors.push(`${path}.${k}: unknown rule field`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const id = typeof r.id === "string" ? r.id : "";
|
|
104
|
+
if (!RULE_ID_RE.test(id)) {
|
|
105
|
+
errors.push(`${path}.id: must match ${RULE_ID_RE.source} (got '${id}')`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (seenIds.has(id)) {
|
|
109
|
+
errors.push(`${path}: duplicate rule id '${id}'`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
seenIds.add(id);
|
|
113
|
+
|
|
114
|
+
if (
|
|
115
|
+
typeof r.severity !== "string" ||
|
|
116
|
+
!ALLOWED_SEVERITIES.includes(r.severity as PolicySeverity)
|
|
117
|
+
) {
|
|
118
|
+
errors.push(`${path}.severity: must be one of ${ALLOWED_SEVERITIES.join(", ")}`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (r.when === undefined) {
|
|
123
|
+
errors.push(`${path}.when: required`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const whenC = compilePredicate(r.when, `${path}.when`);
|
|
127
|
+
if (!whenC.ok) {
|
|
128
|
+
errors.push(whenC.error);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let except: Rule["except"] | undefined;
|
|
133
|
+
if (r.except !== undefined) {
|
|
134
|
+
const exceptC = compilePredicate(r.except, `${path}.except`);
|
|
135
|
+
if (!exceptC.ok) {
|
|
136
|
+
errors.push(exceptC.error);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
except = exceptC.value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const requireCoverage = r.requireCoverage as CoveragePolicy | undefined;
|
|
143
|
+
if (requireCoverage !== undefined && !ALLOWED_COVERAGE.includes(requireCoverage)) {
|
|
144
|
+
errors.push(`${path}.requireCoverage: invalid value`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const onUnknown = r.onUnknown as UnknownPolicy | undefined;
|
|
149
|
+
if (onUnknown !== undefined && !ALLOWED_UNKNOWN.includes(onUnknown)) {
|
|
150
|
+
errors.push(`${path}.onUnknown: invalid value`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const facts = r.facts as FactOriginFilter | undefined;
|
|
155
|
+
if (facts !== undefined && !ALLOWED_FACTS.includes(facts)) {
|
|
156
|
+
errors.push(`${path}.facts: invalid value`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
rules.push({
|
|
161
|
+
id,
|
|
162
|
+
title: typeof r.title === "string" ? r.title : undefined,
|
|
163
|
+
description: typeof r.description === "string" ? r.description : undefined,
|
|
164
|
+
message: typeof r.message === "string" ? r.message : undefined,
|
|
165
|
+
severity: r.severity as PolicySeverity,
|
|
166
|
+
when: whenC.value,
|
|
167
|
+
except,
|
|
168
|
+
requireCoverage,
|
|
169
|
+
onUnknown,
|
|
170
|
+
facts,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (errors.length > 0) return { ok: false, errors, warnings };
|
|
175
|
+
|
|
176
|
+
const policy: PolicyDoc = {
|
|
177
|
+
version: 1,
|
|
178
|
+
description: typeof top.description === "string" ? top.description : undefined,
|
|
179
|
+
defaults: Object.keys(defaults).length > 0 ? defaults : undefined,
|
|
180
|
+
rules,
|
|
181
|
+
};
|
|
182
|
+
return { ok: true, policy, warnings };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function loadPolicyFromFile(path: string): LoadResult {
|
|
186
|
+
let yaml: string;
|
|
187
|
+
try {
|
|
188
|
+
yaml = readFileSync(path, "utf8");
|
|
189
|
+
} catch (err) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
errors: [`failed to read ${path}: ${(err as Error).message}`],
|
|
193
|
+
warnings: [],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return loadPolicyFromString(yaml);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Generate the JSON Schema for the policy file format. Derived from the
|
|
201
|
+
* predicate-fields registry (single source of truth). The checked-in
|
|
202
|
+
* `src/policy/policy-schema.json` artifact is regenerated from this
|
|
203
|
+
* function; CI guard test (T12) catches drift.
|
|
204
|
+
*/
|
|
205
|
+
export function generatePolicySchema(): unknown {
|
|
206
|
+
const predicateProps: Record<string, unknown> = {};
|
|
207
|
+
for (const f of PREDICATE_FIELDS) predicateProps[f.name] = f.jsonSchemaFragment;
|
|
208
|
+
return {
|
|
209
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
210
|
+
title: "al-sem policy",
|
|
211
|
+
type: "object",
|
|
212
|
+
required: ["version", "rules"],
|
|
213
|
+
properties: {
|
|
214
|
+
version: { const: 1 },
|
|
215
|
+
description: { type: "string" },
|
|
216
|
+
defaults: {
|
|
217
|
+
type: "object",
|
|
218
|
+
properties: {
|
|
219
|
+
onUnknown: { type: "string", enum: ["fail-open", "fail-closed"] },
|
|
220
|
+
requireCoverage: { type: "string", enum: ["complete", "partial", "any"] },
|
|
221
|
+
},
|
|
222
|
+
additionalProperties: false,
|
|
223
|
+
},
|
|
224
|
+
rules: { type: "array", items: { $ref: "#/definitions/Rule" } },
|
|
225
|
+
},
|
|
226
|
+
additionalProperties: false,
|
|
227
|
+
definitions: {
|
|
228
|
+
Rule: {
|
|
229
|
+
type: "object",
|
|
230
|
+
required: ["id", "severity", "when"],
|
|
231
|
+
properties: {
|
|
232
|
+
id: { type: "string", pattern: "^[a-z][a-z0-9-]{2,80}$" },
|
|
233
|
+
title: { type: "string" },
|
|
234
|
+
description: { type: "string" },
|
|
235
|
+
message: { type: "string" },
|
|
236
|
+
severity: { type: "string", enum: ["critical", "high", "medium", "low", "info"] },
|
|
237
|
+
when: { $ref: "#/definitions/Predicate" },
|
|
238
|
+
except: { $ref: "#/definitions/Predicate" },
|
|
239
|
+
requireCoverage: { type: "string", enum: ["complete", "partial", "any"] },
|
|
240
|
+
onUnknown: { type: "string", enum: ["fail-open", "fail-closed"] },
|
|
241
|
+
facts: { type: "string", enum: ["direct", "inherited", "any"] },
|
|
242
|
+
},
|
|
243
|
+
additionalProperties: false,
|
|
244
|
+
},
|
|
245
|
+
Predicate: {
|
|
246
|
+
type: "object",
|
|
247
|
+
properties: {
|
|
248
|
+
...predicateProps,
|
|
249
|
+
all: { type: "array", items: { $ref: "#/definitions/Predicate" } },
|
|
250
|
+
any: { type: "array", items: { $ref: "#/definitions/Predicate" } },
|
|
251
|
+
not: { $ref: "#/definitions/Predicate" },
|
|
252
|
+
},
|
|
253
|
+
additionalProperties: false,
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|