@meetless/mla 0.1.4
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 +201 -0
- package/README.md +81 -0
- package/dist/build-info.json +9 -0
- package/dist/bundles/ask-core.js +396 -0
- package/dist/bundles/mcp.js +16592 -0
- package/dist/bundles/trace-core.js +263 -0
- package/dist/cli.js +828 -0
- package/dist/commands/activate.js +781 -0
- package/dist/commands/adoption.js +130 -0
- package/dist/commands/ask.js +290 -0
- package/dist/commands/context.js +114 -0
- package/dist/commands/debug.js +313 -0
- package/dist/commands/doctor.js +1021 -0
- package/dist/commands/enrich.js +427 -0
- package/dist/commands/evidence.js +229 -0
- package/dist/commands/flush.js +184 -0
- package/dist/commands/graph.js +104 -0
- package/dist/commands/init.js +272 -0
- package/dist/commands/internal-active-review.js +322 -0
- package/dist/commands/internal-auto-index.js +188 -0
- package/dist/commands/internal-capture-decisions.js +320 -0
- package/dist/commands/internal-evidence-correlate.js +239 -0
- package/dist/commands/internal-evidence-hooks.js +240 -0
- package/dist/commands/internal-evidence-inject.js +231 -0
- package/dist/commands/internal-finalize.js +221 -0
- package/dist/commands/internal-pretool-observe.js +225 -0
- package/dist/commands/internal-refresh.js +136 -0
- package/dist/commands/internal-session-nudge.js +120 -0
- package/dist/commands/internal-steer-sync.js +117 -0
- package/dist/commands/internal-turn-recap.js +140 -0
- package/dist/commands/kb.js +375 -0
- package/dist/commands/kb_add.js +681 -0
- package/dist/commands/kb_forget.js +283 -0
- package/dist/commands/kb_move.js +45 -0
- package/dist/commands/kb_pending.js +410 -0
- package/dist/commands/kb_personal.js +149 -0
- package/dist/commands/kb_promote.js +188 -0
- package/dist/commands/kb_purge.js +168 -0
- package/dist/commands/kb_reingest.js +335 -0
- package/dist/commands/kb_retime.js +170 -0
- package/dist/commands/kb_review.js +391 -0
- package/dist/commands/kb_revision.js +179 -0
- package/dist/commands/kb_show.js +385 -0
- package/dist/commands/label.js +226 -0
- package/dist/commands/login.js +295 -0
- package/dist/commands/logout.js +108 -0
- package/dist/commands/mcp-supervisor.js +93 -0
- package/dist/commands/mcp.js +227 -0
- package/dist/commands/queue-prune.js +98 -0
- package/dist/commands/review.js +358 -0
- package/dist/commands/rewire.js +124 -0
- package/dist/commands/rules.js +728 -0
- package/dist/commands/scan-context.js +67 -0
- package/dist/commands/session.js +347 -0
- package/dist/commands/stats.js +479 -0
- package/dist/commands/status.js +61 -0
- package/dist/commands/summary.js +250 -0
- package/dist/commands/turn.js +114 -0
- package/dist/commands/uninstall.js +222 -0
- package/dist/commands/whoami.js +102 -0
- package/dist/commands/workspace.js +130 -0
- package/dist/hooks-template/ce0-post-tool-use.sh +34 -0
- package/dist/hooks-template/ce0-session-start.sh +49 -0
- package/dist/hooks-template/ce0-stop.sh +29 -0
- package/dist/hooks-template/ce0-user-prompt-submit.sh +38 -0
- package/dist/hooks-template/common.sh +934 -0
- package/dist/hooks-template/event-batch-filter.jq +67 -0
- package/dist/hooks-template/flush.sh +503 -0
- package/dist/hooks-template/post-tool-use.sh +423 -0
- package/dist/hooks-template/pre-tool-use.sh +69 -0
- package/dist/hooks-template/session-start.sh +140 -0
- package/dist/hooks-template/stop.sh +308 -0
- package/dist/hooks-template/user-prompt-submit.sh +1162 -0
- package/dist/lib/activation.js +79 -0
- package/dist/lib/active-conflict-cache.js +141 -0
- package/dist/lib/active-memory.js +59 -0
- package/dist/lib/active-review-runner.js +26 -0
- package/dist/lib/agent-decision/index.js +25 -0
- package/dist/lib/agent-decision/keys.js +49 -0
- package/dist/lib/agent-decision/normalize-claude.js +183 -0
- package/dist/lib/agent-decision/types.js +21 -0
- package/dist/lib/agent-decision/validate.js +216 -0
- package/dist/lib/analytics/capture.js +96 -0
- package/dist/lib/analytics/command-event.js +267 -0
- package/dist/lib/analytics/consent.js +58 -0
- package/dist/lib/analytics/coverage-gap.js +96 -0
- package/dist/lib/analytics/envelope.js +236 -0
- package/dist/lib/analytics/event-id.js +86 -0
- package/dist/lib/analytics/evidence.js +150 -0
- package/dist/lib/analytics/followthrough.js +194 -0
- package/dist/lib/analytics/forwarder.js +109 -0
- package/dist/lib/analytics/logs.js +78 -0
- package/dist/lib/analytics/metrics.js +78 -0
- package/dist/lib/analytics/recorder.js +92 -0
- package/dist/lib/analytics/review-analytics.js +75 -0
- package/dist/lib/analytics/sequence.js +77 -0
- package/dist/lib/analytics/store.js +131 -0
- package/dist/lib/analytics/turn-recap.js +279 -0
- package/dist/lib/artifact_id.js +108 -0
- package/dist/lib/auth-breaker.js +161 -0
- package/dist/lib/auto-index.js +112 -0
- package/dist/lib/classifier.js +88 -0
- package/dist/lib/config.js +298 -0
- package/dist/lib/conflict-advisory.js +64 -0
- package/dist/lib/debug-bundle.js +520 -0
- package/dist/lib/enrichment/ingest.js +301 -0
- package/dist/lib/enrichment/plan.js +253 -0
- package/dist/lib/enrichment/protocol.js +359 -0
- package/dist/lib/enrichment/scout-brief.js +176 -0
- package/dist/lib/failure-telemetry.js +444 -0
- package/dist/lib/git.js +200 -0
- package/dist/lib/governance-cache.js +77 -0
- package/dist/lib/governed-path-cache.js +76 -0
- package/dist/lib/http.js +677 -0
- package/dist/lib/identity-envelope.js +23 -0
- package/dist/lib/kb-candidate.js +65 -0
- package/dist/lib/kb_acl.js +98 -0
- package/dist/lib/login.js +353 -0
- package/dist/lib/mcp-fetchers.js +130 -0
- package/dist/lib/mcp-restart.js +47 -0
- package/dist/lib/observability.js +805 -0
- package/dist/lib/open-url.js +33 -0
- package/dist/lib/orphan-guard.js +70 -0
- package/dist/lib/packaged.js +21 -0
- package/dist/lib/reconcile-sessions.js +171 -0
- package/dist/lib/redactor.js +89 -0
- package/dist/lib/relationship-candidate-query.js +27 -0
- package/dist/lib/render.js +611 -0
- package/dist/lib/rules/applicability.js +64 -0
- package/dist/lib/rules/attest-code-rule-version.js +47 -0
- package/dist/lib/rules/attest-notes-location.js +217 -0
- package/dist/lib/rules/attest-rule-version.js +69 -0
- package/dist/lib/rules/canonical-json.js +97 -0
- package/dist/lib/rules/ce0-emit.js +64 -0
- package/dist/lib/rules/ce0-evidence.js +281 -0
- package/dist/lib/rules/ce0-recall-sample.js +82 -0
- package/dist/lib/rules/ce0-rule.js +55 -0
- package/dist/lib/rules/ce0-sampling-bucket.js +15 -0
- package/dist/lib/rules/ce0-store.js +683 -0
- package/dist/lib/rules/ce0-telemetry-project.js +93 -0
- package/dist/lib/rules/ce0-telemetry.js +158 -0
- package/dist/lib/rules/code-rule-registry.js +17 -0
- package/dist/lib/rules/command-match.js +185 -0
- package/dist/lib/rules/consult-evidence-binding.js +27 -0
- package/dist/lib/rules/consultation-capture-adapter.js +193 -0
- package/dist/lib/rules/content-match.js +56 -0
- package/dist/lib/rules/deny-admission.js +99 -0
- package/dist/lib/rules/durable-observation.js +190 -0
- package/dist/lib/rules/enforce-notes-version.js +421 -0
- package/dist/lib/rules/evaluation-input-hash.js +126 -0
- package/dist/lib/rules/evaluator.js +108 -0
- package/dist/lib/rules/inert-rule-families.js +51 -0
- package/dist/lib/rules/input-authority-resolver.js +241 -0
- package/dist/lib/rules/interception-schema.js +170 -0
- package/dist/lib/rules/interception-store.js +267 -0
- package/dist/lib/rules/live-input-authority.js +66 -0
- package/dist/lib/rules/local-matcher.js +108 -0
- package/dist/lib/rules/local-observe.js +79 -0
- package/dist/lib/rules/local-rule-version-repo.js +214 -0
- package/dist/lib/rules/memory-requirement.js +109 -0
- package/dist/lib/rules/notes-observe.js +39 -0
- package/dist/lib/rules/notes-path.js +261 -0
- package/dist/lib/rules/notes-rule.js +75 -0
- package/dist/lib/rules/observe-adapter.js +114 -0
- package/dist/lib/rules/observed-rule-hash.js +119 -0
- package/dist/lib/rules/prompt-submit-adapter.js +132 -0
- package/dist/lib/rules/requirement-subject.js +240 -0
- package/dist/lib/rules/rule-activity.js +67 -0
- package/dist/lib/rules/rule-version-hash.js +151 -0
- package/dist/lib/rules/runtime-scope.js +55 -0
- package/dist/lib/rules/stop-adapter.js +116 -0
- package/dist/lib/rules/stop-response-snapshot.js +174 -0
- package/dist/lib/rules/types.js +10 -0
- package/dist/lib/rules/ulid.js +46 -0
- package/dist/lib/rules/version-evaluation.js +156 -0
- package/dist/lib/scanner/agent-memory.js +99 -0
- package/dist/lib/scanner/bootstrap-summary.js +87 -0
- package/dist/lib/scanner/cache.js +59 -0
- package/dist/lib/scanner/frontmatter.js +42 -0
- package/dist/lib/scanner/parse-directives.js +69 -0
- package/dist/lib/scanner/parse-structured.js +72 -0
- package/dist/lib/scanner/render.js +73 -0
- package/dist/lib/scanner/scan.js +132 -0
- package/dist/lib/scanner/score.js +38 -0
- package/dist/lib/scanner/scout-mission.js +126 -0
- package/dist/lib/scanner/types.js +7 -0
- package/dist/lib/session-scope.js +195 -0
- package/dist/lib/spool.js +355 -0
- package/dist/lib/staleness.js +100 -0
- package/dist/lib/steer-cache.js +87 -0
- package/dist/lib/tagged-reference.js +20 -0
- package/dist/lib/temporal.js +109 -0
- package/dist/lib/turn-recap-emit.js +67 -0
- package/dist/lib/unwire.js +253 -0
- package/dist/lib/update-check.js +469 -0
- package/dist/lib/update-notifier.js +217 -0
- package/dist/lib/upgrade-apply.js +643 -0
- package/dist/lib/wire.js +1087 -0
- package/dist/lib/workspace.js +96 -0
- package/dist/lib/zip.js +154 -0
- package/dist/pretool-entry.js +37 -0
- package/package.json +75 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.recordDenyDecision = recordDenyDecision;
|
|
4
|
+
exports.evaluateAndEnforceNotesVersion = evaluateAndEnforceNotesVersion;
|
|
5
|
+
exports.evaluateAndEnforceLiveRules = evaluateAndEnforceLiveRules;
|
|
6
|
+
exports.evaluateEnforceOrObserveNotesRule = evaluateEnforceOrObserveNotesRule;
|
|
7
|
+
const evaluation_input_hash_1 = require("./evaluation-input-hash");
|
|
8
|
+
const interception_store_1 = require("./interception-store");
|
|
9
|
+
const local_rule_version_repo_1 = require("./local-rule-version-repo");
|
|
10
|
+
const durable_observation_1 = require("./durable-observation");
|
|
11
|
+
const notes_path_1 = require("./notes-path");
|
|
12
|
+
const inert_rule_families_1 = require("./inert-rule-families");
|
|
13
|
+
const observe_adapter_1 = require("./observe-adapter");
|
|
14
|
+
const evaluator_1 = require("./evaluator");
|
|
15
|
+
const attest_notes_location_1 = require("./attest-notes-location");
|
|
16
|
+
const ulid_1 = require("./ulid");
|
|
17
|
+
const deny_admission_1 = require("./deny-admission");
|
|
18
|
+
const version_evaluation_1 = require("./version-evaluation");
|
|
19
|
+
const PASS_THROUGH = {};
|
|
20
|
+
/**
|
|
21
|
+
* Build the tool_attempt + version-arm rule_evaluation_record pair for one interception. The
|
|
22
|
+
* evaluation-input-v1 snapshot carries the RUNNING evaluator's supported triple, byte-identical to
|
|
23
|
+
* slice 8's observe writer, so a snapshot-only replay reproduces the verdict. The deny accounting
|
|
24
|
+
* (aggregate decision + emission status) is derived from the effective enforcement through the slice-9
|
|
25
|
+
* kernel so this seam never re-decides what the kernel already owns.
|
|
26
|
+
*/
|
|
27
|
+
function buildArmRows(ids, subject, enforcement, ctx) {
|
|
28
|
+
const evaluationInput = {
|
|
29
|
+
toolName: subject.toolName,
|
|
30
|
+
target: subject.target,
|
|
31
|
+
forbiddenRootRelativePath: subject.payload.compliance.config.forbiddenRootRelativePath,
|
|
32
|
+
evaluatorContractVersion: durable_observation_1.EVALUATOR_CONTRACT_VERSION,
|
|
33
|
+
matcherSchemaVersion: durable_observation_1.MATCHER_SCHEMA_VERSION,
|
|
34
|
+
pathCanonicalizerVersion: durable_observation_1.PATH_CANONICALIZER_VERSION,
|
|
35
|
+
};
|
|
36
|
+
const accounting = (0, deny_admission_1.planDenyAccounting)(enforcement.effectiveEnforcement);
|
|
37
|
+
const attempt = {
|
|
38
|
+
attemptId: ids.attemptId,
|
|
39
|
+
runtimeScopeId: ctx.runtimeScopeId,
|
|
40
|
+
sessionId: ctx.sessionId,
|
|
41
|
+
toolName: subject.toolName,
|
|
42
|
+
evaluationInputSnapshot: (0, evaluation_input_hash_1.serializeEvaluationInput)(evaluationInput),
|
|
43
|
+
evaluationInputHash: (0, evaluation_input_hash_1.evaluationInputHash)(evaluationInput),
|
|
44
|
+
aggregateDecision: accounting.aggregateDecision,
|
|
45
|
+
denyEmissionStatus: accounting.denyEmissionStatus,
|
|
46
|
+
inputAuthorityConfigHash: enforcement.inputAuthorityConfigHash,
|
|
47
|
+
createdAt: ctx.createdAt,
|
|
48
|
+
};
|
|
49
|
+
const evaluation = {
|
|
50
|
+
evaluationId: ids.evaluationId,
|
|
51
|
+
attemptId: ids.attemptId,
|
|
52
|
+
runtimeScopeId: ctx.runtimeScopeId,
|
|
53
|
+
result: enforcement.result,
|
|
54
|
+
eligibleEnforcement: enforcement.eligibleEnforcement,
|
|
55
|
+
effectiveEnforcement: enforcement.effectiveEnforcement,
|
|
56
|
+
verdictReasonCode: enforcement.verdictReasonCode,
|
|
57
|
+
gateReasonCode: enforcement.gateReasonCode,
|
|
58
|
+
evaluatorContractVersion: durable_observation_1.EVALUATOR_CONTRACT_VERSION,
|
|
59
|
+
ruleVersionId: subject.version.versionId,
|
|
60
|
+
canonicalPayloadHash: subject.version.canonicalPayloadHash,
|
|
61
|
+
createdAt: ctx.createdAt,
|
|
62
|
+
};
|
|
63
|
+
return { attempt, evaluation };
|
|
64
|
+
}
|
|
65
|
+
/** Write a version-arm row pair atomically (a single BEGIN IMMEDIATE), so an interception is never
|
|
66
|
+
* half-recorded. */
|
|
67
|
+
function writeArm(store, attempt, evaluation) {
|
|
68
|
+
store.db
|
|
69
|
+
.transaction(() => {
|
|
70
|
+
(0, interception_store_1.insertToolAttempt)(store, attempt);
|
|
71
|
+
(0, local_rule_version_repo_1.insertVersionEvaluationRecord)(store, evaluation);
|
|
72
|
+
})
|
|
73
|
+
.immediate();
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Persist a would-be deny that a gate (or a generation churn) lowered to effective NONE, and report
|
|
77
|
+
* the row ids. The eligible enforcement stays DENY (the rule WOULD have denied) but the effective
|
|
78
|
+
* enforcement is NONE with the single primary gate reason RULE_ENFORCEMENT_UNAVAILABLE, the deny
|
|
79
|
+
* status is NOT_APPLICABLE, and the input-authority config hash resolved at this deny is recorded for
|
|
80
|
+
* the audit and for `mla doctor`. This is the honest record behind a fail-open.
|
|
81
|
+
*/
|
|
82
|
+
function recordEnforcementUnavailable(store, subject, verdict, inputAuthorityConfigHash, ctx) {
|
|
83
|
+
const ids = { attemptId: (0, ulid_1.ulid)(ctx.now, ctx.rand), evaluationId: (0, ulid_1.ulid)(ctx.now, ctx.rand) };
|
|
84
|
+
const { attempt, evaluation } = buildArmRows(ids, subject, {
|
|
85
|
+
result: verdict.result,
|
|
86
|
+
verdictReasonCode: verdict.verdictReasonCode,
|
|
87
|
+
eligibleEnforcement: "DENY",
|
|
88
|
+
effectiveEnforcement: "NONE",
|
|
89
|
+
gateReasonCode: "RULE_ENFORCEMENT_UNAVAILABLE",
|
|
90
|
+
inputAuthorityConfigHash,
|
|
91
|
+
}, ctx);
|
|
92
|
+
writeArm(store, attempt, evaluation);
|
|
93
|
+
return ids;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The linearized deny commit (P0.52). Mints the two ULIDs and builds the DENY / DECISION_RECORDED row
|
|
97
|
+
* pair OUTSIDE any write transaction, then opens a single BEGIN IMMEDIATE and RE-READS the LIVE version
|
|
98
|
+
* for the deny's (scope, rule). It persists ONLY when that is still the exact generation the deny was
|
|
99
|
+
* evaluated against; if a concurrent attest superseded it in the window, the deny is inadmissible, the
|
|
100
|
+
* transaction writes nothing, and it fails open with GENERATION_CHURN.
|
|
101
|
+
*
|
|
102
|
+
* This is the minimal safe floor of the contract: the spec permits one retry, but we treat the first
|
|
103
|
+
* observed churn as inadmissible (no retry loop), which is strictly more conservative. `beforeCommit`
|
|
104
|
+
* is the test seam that lands a supersede in the window; it runs BEFORE the IMMEDIATE acquires the
|
|
105
|
+
* write lock, exactly as a real concurrent attest that commits ahead of our deny-commit would.
|
|
106
|
+
*/
|
|
107
|
+
function recordDenyDecision(store, subject, ctx, opts = {}) {
|
|
108
|
+
const attemptId = (0, ulid_1.ulid)(ctx.now, ctx.rand);
|
|
109
|
+
const evaluationId = (0, ulid_1.ulid)(ctx.now, ctx.rand);
|
|
110
|
+
const { attempt, evaluation } = buildArmRows({ attemptId, evaluationId }, subject, {
|
|
111
|
+
result: subject.result,
|
|
112
|
+
verdictReasonCode: subject.verdictReasonCode,
|
|
113
|
+
eligibleEnforcement: "DENY",
|
|
114
|
+
effectiveEnforcement: "DENY",
|
|
115
|
+
gateReasonCode: null,
|
|
116
|
+
inputAuthorityConfigHash: subject.inputAuthorityConfigHash,
|
|
117
|
+
}, ctx);
|
|
118
|
+
// The concurrent attest, if any, commits ahead of our write lock (it cannot land once BEGIN IMMEDIATE
|
|
119
|
+
// holds the lock, so the realistic race is "it committed in the window before us").
|
|
120
|
+
opts.beforeCommit?.();
|
|
121
|
+
let churned = false;
|
|
122
|
+
store.db
|
|
123
|
+
.transaction(() => {
|
|
124
|
+
const live = (0, local_rule_version_repo_1.getLiveLocalRuleVersion)(store, subject.version.runtimeScopeId, subject.version.ruleId);
|
|
125
|
+
if (!live || live.versionId !== subject.version.versionId) {
|
|
126
|
+
churned = true;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
(0, interception_store_1.insertToolAttempt)(store, attempt);
|
|
130
|
+
(0, local_rule_version_repo_1.insertVersionEvaluationRecord)(store, evaluation);
|
|
131
|
+
})
|
|
132
|
+
.immediate();
|
|
133
|
+
if (churned) {
|
|
134
|
+
return { committed: false, cause: "GENERATION_CHURN" };
|
|
135
|
+
}
|
|
136
|
+
return { committed: true, attemptId, evaluationId };
|
|
137
|
+
}
|
|
138
|
+
/** Describe the blocked target for the deny reason; a non-relative target degrades to a generic phrase
|
|
139
|
+
* rather than leaking an unclassified path shape. */
|
|
140
|
+
function describeTarget(target) {
|
|
141
|
+
return target.kind === "RUNTIME_RELATIVE" ? target.path : "the requested file";
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Build the human-facing deny reason for any PROHIBIT forbidden-root rule. It is grounded in the
|
|
145
|
+
* attested prose (`payload.text`, the human-attested directive) plus the concrete blocked target and
|
|
146
|
+
* the configured forbidden root, and it names the violated rule id so the block is self-explaining in
|
|
147
|
+
* the Claude Code surface. No rule-specific copy is hard-coded here: the attested prose carries the
|
|
148
|
+
* steer (where the file SHOULD go), so the one reason builder serves the whole family, not just the
|
|
149
|
+
* notes pilot.
|
|
150
|
+
*/
|
|
151
|
+
function buildDenyReason(ruleId, payload, target) {
|
|
152
|
+
const where = describeTarget(target);
|
|
153
|
+
const forbidden = payload.compliance.config.forbiddenRootRelativePath;
|
|
154
|
+
return (`Blocked by Meetless rule ${ruleId}. Writing ${where} under the forbidden ` +
|
|
155
|
+
`"${forbidden}/" root is prohibited. ${payload.text}`);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The enforced version-backed PreToolUse seam for the notes-location pilot. See the module header for
|
|
159
|
+
* the full pipeline. Returns the hook response (pass-through, or the one admitted deny) plus the
|
|
160
|
+
* durable outcome on the side channel. Skip semantics match slice 8 exactly: malformed payload or
|
|
161
|
+
* absent session id is INFRA, no LIVE version is NO_LIVE_VERSION, a non-Write/Edit tool or a glob
|
|
162
|
+
* non-match is NOT_APPLICABLE, and none of those persist a row.
|
|
163
|
+
*/
|
|
164
|
+
async function evaluateAndEnforceNotesVersion(store, input) {
|
|
165
|
+
const parsed = (0, observe_adapter_1.parsePreToolUseInput)(input.rawStdin);
|
|
166
|
+
if (!parsed) {
|
|
167
|
+
return { response: PASS_THROUGH, outcome: { kind: "INFRA", diagnostic: "malformed hook input" } };
|
|
168
|
+
}
|
|
169
|
+
if (parsed.session_id === undefined) {
|
|
170
|
+
return { response: PASS_THROUGH, outcome: { kind: "INFRA", diagnostic: "missing session_id" } };
|
|
171
|
+
}
|
|
172
|
+
const ruleId = input.ruleId ?? attest_notes_location_1.NOTES_LOCATION_RULE_ID;
|
|
173
|
+
const version = (0, local_rule_version_repo_1.getLiveLocalRuleVersion)(store, input.runtimeScopeId, ruleId);
|
|
174
|
+
if (!version) {
|
|
175
|
+
return { response: PASS_THROUGH, outcome: { kind: "NO_LIVE_VERSION" } };
|
|
176
|
+
}
|
|
177
|
+
const payload = JSON.parse(version.rulePayload);
|
|
178
|
+
const call = { toolName: parsed.tool_name, toolInput: parsed.tool_input };
|
|
179
|
+
if ((0, evaluator_1.selectRule)(call, payload.applicability) === "NOT_APPLICABLE" || payload.applicability.mode !== "action") {
|
|
180
|
+
return { response: PASS_THROUGH, outcome: { kind: "NOT_APPLICABLE" } };
|
|
181
|
+
}
|
|
182
|
+
// The selector proved the tool is in the version's {Write, Edit} list and the matcher field holds a
|
|
183
|
+
// matching string, so this narrowing is sound (mirrors slice 8).
|
|
184
|
+
const toolName = parsed.tool_name;
|
|
185
|
+
const rawFilePath = parsed.tool_input[payload.applicability.matcher.field];
|
|
186
|
+
const classify = input.classifyRuntime ?? notes_path_1.classifyRuntimeTarget;
|
|
187
|
+
const target = await classify(rawFilePath, input.runtimeProjectRoot);
|
|
188
|
+
const verdict = (0, version_evaluation_1.versionBackedVerdict)(payload, target);
|
|
189
|
+
const eligible = (0, deny_admission_1.projectEligibleEnforcement)(verdict.result, payload.enforcementCeiling);
|
|
190
|
+
const ctx = {
|
|
191
|
+
runtimeScopeId: input.runtimeScopeId,
|
|
192
|
+
sessionId: parsed.session_id,
|
|
193
|
+
createdAt: input.createdAt,
|
|
194
|
+
now: input.now,
|
|
195
|
+
rand: input.rand,
|
|
196
|
+
};
|
|
197
|
+
const subject = { toolName, target, version, payload };
|
|
198
|
+
// Not a would-be deny (COMPLIANT, UNKNOWN, or a non-DENY ceiling): OBSERVE and pass through, WITHOUT
|
|
199
|
+
// resolving input authority. This is byte-identical to slice 8's writer.
|
|
200
|
+
if (eligible !== "DENY") {
|
|
201
|
+
const persisted = (0, version_evaluation_1.recordVersionEvaluation)(store, { toolName, target, version }, ctx);
|
|
202
|
+
return {
|
|
203
|
+
response: PASS_THROUGH,
|
|
204
|
+
outcome: {
|
|
205
|
+
kind: "OBSERVED",
|
|
206
|
+
attemptId: persisted.attemptId,
|
|
207
|
+
evaluationId: persisted.evaluationId,
|
|
208
|
+
result: persisted.result,
|
|
209
|
+
verdictReasonCode: persisted.verdictReasonCode,
|
|
210
|
+
ruleVersionId: persisted.ruleVersionId,
|
|
211
|
+
canonicalPayloadHash: persisted.canonicalPayloadHash,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
// A would-be deny. Re-resolve the input authority (P0.58) and the attested path root (P0.63) at THIS
|
|
216
|
+
// deny, then run the pure admission kernel (slice 9).
|
|
217
|
+
const inputAuthority = input.resolveInputAuthority();
|
|
218
|
+
const pathRoot = (0, deny_admission_1.resolveAttestedPathRoot)({
|
|
219
|
+
configuredRelativeForbiddenPath: payload.compliance.config.forbiddenRootRelativePath,
|
|
220
|
+
activeRuntimeProjectRoot: input.runtimeProjectRoot,
|
|
221
|
+
});
|
|
222
|
+
const admission = (0, deny_admission_1.admitEnforcement)({ eligibleEnforcement: eligible, inputAuthority, pathRoot });
|
|
223
|
+
if (admission.effectiveEnforcement !== "DENY") {
|
|
224
|
+
// A gate lowered the deny to NONE: record the honest NONE arm and fail open. The cause distinguishes
|
|
225
|
+
// a non-sole input authority (P0.58) from an unresolved path root (P0.63); both are recorded as the
|
|
226
|
+
// single gate reason RULE_ENFORCEMENT_UNAVAILABLE.
|
|
227
|
+
const ids = recordEnforcementUnavailable(store, subject, verdict, inputAuthority.configHash, ctx);
|
|
228
|
+
const cause = inputAuthority.kind !== "MLA_SOLE_AUTHORITY" ? "INPUT_AUTHORITY" : "PATH_ROOT";
|
|
229
|
+
return {
|
|
230
|
+
response: PASS_THROUGH,
|
|
231
|
+
outcome: { kind: "ENFORCEMENT_UNAVAILABLE", cause, attemptId: ids.attemptId, evaluationId: ids.evaluationId },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
// Admitted DENY. Linearize the durable commit against the LIVE generation (P0.52).
|
|
235
|
+
const deny = recordDenyDecision(store, {
|
|
236
|
+
toolName,
|
|
237
|
+
target,
|
|
238
|
+
version,
|
|
239
|
+
payload,
|
|
240
|
+
result: verdict.result,
|
|
241
|
+
verdictReasonCode: verdict.verdictReasonCode,
|
|
242
|
+
inputAuthorityConfigHash: inputAuthority.configHash,
|
|
243
|
+
}, ctx, { beforeCommit: input.beforeDenyCommit });
|
|
244
|
+
if (!deny.committed) {
|
|
245
|
+
// The LIVE generation churned in the deny window: inadmissible. Record the NONE arm and fail open.
|
|
246
|
+
const ids = recordEnforcementUnavailable(store, subject, verdict, inputAuthority.configHash, ctx);
|
|
247
|
+
return {
|
|
248
|
+
response: PASS_THROUGH,
|
|
249
|
+
outcome: { kind: "ENFORCEMENT_UNAVAILABLE", cause: "GENERATION_CHURN", attemptId: ids.attemptId, evaluationId: ids.evaluationId },
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
// The deny is committed durably at DECISION_RECORDED. Produce the emission, then advance the row to
|
|
253
|
+
// RESPONSE_EMITTED (R1-4). The DECISION_RECORDED commit is durable before this point, so a crash in
|
|
254
|
+
// the post-commit window leaves an honest DECISION_RECORDED, never NO_DECISION; the actual stdout
|
|
255
|
+
// write of this response is the runtime caller's, immediately after the seam returns.
|
|
256
|
+
const response = {
|
|
257
|
+
permissionDecision: "deny",
|
|
258
|
+
reason: buildDenyReason(ruleId, payload, target),
|
|
259
|
+
};
|
|
260
|
+
(0, interception_store_1.advanceDenyEmissionToResponseEmitted)(store, deny.attemptId);
|
|
261
|
+
return {
|
|
262
|
+
response,
|
|
263
|
+
outcome: {
|
|
264
|
+
kind: "DENIED",
|
|
265
|
+
attemptId: deny.attemptId,
|
|
266
|
+
evaluationId: deny.evaluationId,
|
|
267
|
+
ruleVersionId: version.versionId,
|
|
268
|
+
canonicalPayloadHash: version.canonicalPayloadHash,
|
|
269
|
+
inputAuthorityConfigHash: inputAuthority.configHash,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The PROHIBIT forbidden-root family: the one rule shape R1's evaluator and deny-admission kernel can
|
|
275
|
+
* enforce today, and (proposal §2.0) the shape that is conflict-free BY CONSTRUCTION. A rule is in the
|
|
276
|
+
* family when it PROHIBITs (never an effect that could effectively REQUIRE an action, which is the
|
|
277
|
+
* precondition for a conflict), is action-scoped (ambient rules are prompt-time grounding, not action
|
|
278
|
+
* gates), and carries a non-empty forbidden root for the path evaluator to face. Everything else is the
|
|
279
|
+
* R4 frontier the dispatch refuses to reason about.
|
|
280
|
+
*/
|
|
281
|
+
function isProhibitForbiddenRootFamily(payload) {
|
|
282
|
+
return (payload.effect === "PROHIBIT" &&
|
|
283
|
+
payload.applicability.mode === "action" &&
|
|
284
|
+
typeof payload.compliance?.config?.forbiddenRootRelativePath === "string" &&
|
|
285
|
+
payload.compliance.config.forbiddenRootRelativePath.length > 0);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* The rule-driven enforce dispatch (R4, conflict mechanization P0.13). Faces ONE tool attempt against
|
|
289
|
+
* EVERY LIVE rule in the scope, not just the notes pilot, by delegating per rule to the proven
|
|
290
|
+
* single-rule seam ({@link evaluateAndEnforceNotesVersion}) with that rule's id. Each delegated face
|
|
291
|
+
* writes its own attempt+eval arm, so the 1-attempt:N-evaluations schema is realized as N attempts that
|
|
292
|
+
* each record their own rule's verdict (the deliberately conservative reuse of the armed-deny machinery
|
|
293
|
+
* over a risky single-attempt linearization refactor).
|
|
294
|
+
*
|
|
295
|
+
* Two invariants make this safe:
|
|
296
|
+
*
|
|
297
|
+
* 1. R4 conflict-safety guard (three-class partition, P0.13). We can prove the absence of conflicts in
|
|
298
|
+
* two cases, and only those. (a) WITHIN the PROHIBIT forbidden-root family (§2.0: a conflict needs an
|
|
299
|
+
* effect that effectively REQUIRES an action, which PROHIBIT never does, so any number of PROHIBIT
|
|
300
|
+
* forbidden-root rules are mutually compatible) the rule is enforceable and we face it. (b) A
|
|
301
|
+
* provably INERT rule (one whose response ceiling is RECORD_ONLY, per {@link isInertNonEnforcingRule})
|
|
302
|
+
* imposes no effect at all on the attempt; no effect cannot be incompatible with a PROHIBIT deny, so
|
|
303
|
+
* it is non-conflicting by construction and we SKIP it. The moment a LIVE rule is NEITHER (an effect
|
|
304
|
+
* that could require an action, an unrecognized schema, a payload that will not parse) we cannot rule
|
|
305
|
+
* out a conflict, so we fail OPEN for the WHOLE attempt (R4_UNSUPPORTED_RULE_KIND) rather than enforce
|
|
306
|
+
* a deny we cannot reason about. The inert-skip is what lets a CE0 consult-evidence rule coexist in
|
|
307
|
+
* the same scope as the deny pilot without disarming it; the fail-open boundary for the genuinely
|
|
308
|
+
* unrecognized is unchanged.
|
|
309
|
+
*
|
|
310
|
+
* 2. Deterministic single block. Rules are faced in ruleId order (the repo returns them ordered by
|
|
311
|
+
* rule_id). The dispatch STOPS at the first deny: the family is conflict-free, so the lowest-ruleId
|
|
312
|
+
* deny is a deterministic, sufficient single block (the action is blocked once; the remaining rules
|
|
313
|
+
* need not run). Rules faced before the winner have already recorded their honest OBSERVE arms.
|
|
314
|
+
*
|
|
315
|
+
* Returns one of two distinct observe-fallback triggers when no enforceable rule is armed: NO_LIVE_VERSION
|
|
316
|
+
* when the scope is literally empty, or ONLY_INERT_RULES when live rules exist but every one is provably
|
|
317
|
+
* inert (RECORD_ONLY). Both are kept distinct from NOT_APPLICABLE (enforceable rules exist but none selected
|
|
318
|
+
* this call), and from each other so the audit never claims "no live version" when an inert rule is live.
|
|
319
|
+
*/
|
|
320
|
+
async function evaluateAndEnforceLiveRules(store, input) {
|
|
321
|
+
const liveVersions = (0, local_rule_version_repo_1.listLiveLocalRuleVersions)(store, input.runtimeScopeId);
|
|
322
|
+
if (liveVersions.length === 0) {
|
|
323
|
+
return { response: PASS_THROUGH, outcome: { kind: "NO_LIVE_VERSION" } };
|
|
324
|
+
}
|
|
325
|
+
// Invariant 1: partition the LIVE rules into THREE classes (generalized-R4, P0.13). (a) An ENFORCEABLE
|
|
326
|
+
// family rule (PROHIBIT forbidden-root) is collected to be faced. (b) A provably INERT rule (one whose
|
|
327
|
+
// response ceiling is RECORD_ONLY, so it imposes no effect on the attempt and CANNOT conflict with a
|
|
328
|
+
// PROHIBIT deny) is SKIPPED: its presence neither enforces nor poisons the attempt, which is exactly
|
|
329
|
+
// what lets a CE0 consult-evidence rule coexist with the live deny pilot instead of disarming it. (c)
|
|
330
|
+
// Anything else is the R4 frontier we cannot reason about, so we fail OPEN for the whole attempt. A
|
|
331
|
+
// payload that will not even parse is, a fortiori, one we cannot reason about: fail open.
|
|
332
|
+
const enforceable = [];
|
|
333
|
+
for (const version of liveVersions) {
|
|
334
|
+
let payload = null;
|
|
335
|
+
try {
|
|
336
|
+
payload = JSON.parse(version.rulePayload);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
payload = null;
|
|
340
|
+
}
|
|
341
|
+
if (payload && isProhibitForbiddenRootFamily(payload)) {
|
|
342
|
+
enforceable.push(version);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if ((0, inert_rule_families_1.isInertNonEnforcingRule)(payload)) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
response: PASS_THROUGH,
|
|
350
|
+
outcome: { kind: "R4_UNSUPPORTED_RULE_KIND", ruleId: version.ruleId },
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
// Only inert rules were live: no ENFORCEABLE armed rule exists, so the enforce path has nothing to
|
|
354
|
+
// enforce and hands off to the R0 observe substrate exactly as an empty scope would. ONLY_INERT_RULES (not
|
|
355
|
+
// NO_LIVE_VERSION) records that the scope was NOT empty: a live inert rule existed but imposed no effect.
|
|
356
|
+
// The composed seam folds it into the observe fallback identically; the distinct tag keeps the audit honest.
|
|
357
|
+
if (enforceable.length === 0) {
|
|
358
|
+
return { response: PASS_THROUGH, outcome: { kind: "ONLY_INERT_RULES" } };
|
|
359
|
+
}
|
|
360
|
+
// Invariant 2: face each in-family rule in ruleId order; stop at the first deny.
|
|
361
|
+
let firstNonDeny = null;
|
|
362
|
+
for (const version of enforceable) {
|
|
363
|
+
const perRule = await evaluateAndEnforceNotesVersion(store, { ...input, ruleId: version.ruleId });
|
|
364
|
+
switch (perRule.outcome.kind) {
|
|
365
|
+
case "DENIED":
|
|
366
|
+
return perRule;
|
|
367
|
+
case "INFRA":
|
|
368
|
+
// Input-level (malformed hook payload / missing session): identical for every rule, so the
|
|
369
|
+
// first rule's diagnosis is the attempt's diagnosis.
|
|
370
|
+
return perRule;
|
|
371
|
+
case "OBSERVED":
|
|
372
|
+
case "ENFORCEMENT_UNAVAILABLE":
|
|
373
|
+
// Applicable but did not (could not) deny. Remember the first such outcome so the aggregate
|
|
374
|
+
// reports an applicable-but-not-denied attempt rather than a spurious NOT_APPLICABLE.
|
|
375
|
+
if (!firstNonDeny)
|
|
376
|
+
firstNonDeny = perRule;
|
|
377
|
+
break;
|
|
378
|
+
case "NOT_APPLICABLE":
|
|
379
|
+
case "NO_LIVE_VERSION":
|
|
380
|
+
// NOT_APPLICABLE: this rule's matcher did not select the call. NO_LIVE_VERSION: the version was
|
|
381
|
+
// revoked between the list and the face (a benign race). Both are skips for this rule.
|
|
382
|
+
break;
|
|
383
|
+
case "R4_UNSUPPORTED_RULE_KIND":
|
|
384
|
+
// Unreachable (the guard above already rejected any out-of-family rule); fail open defensively.
|
|
385
|
+
return perRule;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// No rule denied. Surface the first applicable rule's outcome if any; otherwise no matcher selected
|
|
389
|
+
// this call and the attempt is NOT_APPLICABLE.
|
|
390
|
+
return firstNonDeny ?? { response: PASS_THROUGH, outcome: { kind: "NOT_APPLICABLE" } };
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* The composed PreToolUse seam the live hook calls (proposal §3.6: observe is the always-on R0
|
|
394
|
+
* substrate, enforce layers on the human-attested LIVE version). It ENFORCES against the LIVE attested
|
|
395
|
+
* version when one exists; otherwise (NO_LIVE_VERSION) it records the R0 observed substrate so a rule
|
|
396
|
+
* that has never been attested still leaves an attestable observed snapshot. This closes the bootstrap
|
|
397
|
+
* gap: enforce-only writes nothing when unarmed, so an empty store could never produce the snapshot an
|
|
398
|
+
* operator attests from. Observe never grants, so the fallback's response stays pass-through; the deny
|
|
399
|
+
* path is reached only through the enforce seam against an attested version, never through observe.
|
|
400
|
+
*/
|
|
401
|
+
async function evaluateEnforceOrObserveNotesRule(store, input) {
|
|
402
|
+
const enforced = await evaluateAndEnforceLiveRules(store, input);
|
|
403
|
+
// Both NO_LIVE_VERSION (empty scope) and ONLY_INERT_RULES (live rules exist but all are inert) mean no
|
|
404
|
+
// enforceable version is armed, so both hand off to the R0 observe substrate. The two tags are kept
|
|
405
|
+
// distinct in the dispatch outcome for audit honesty, but the fallback decision is identical here.
|
|
406
|
+
const k = enforced.outcome.kind;
|
|
407
|
+
if (k !== "NO_LIVE_VERSION" && k !== "ONLY_INERT_RULES") {
|
|
408
|
+
return enforced;
|
|
409
|
+
}
|
|
410
|
+
const observed = await (0, durable_observation_1.observeAndRecordNotesRule)(store, {
|
|
411
|
+
rawStdin: input.rawStdin,
|
|
412
|
+
directives: input.directives,
|
|
413
|
+
runtimeProjectRoot: input.runtimeProjectRoot,
|
|
414
|
+
runtimeScopeId: input.runtimeScopeId,
|
|
415
|
+
createdAt: input.createdAt,
|
|
416
|
+
now: input.now,
|
|
417
|
+
rand: input.rand,
|
|
418
|
+
classifyRuntime: input.classifyRuntime,
|
|
419
|
+
});
|
|
420
|
+
return { response: observed.response, outcome: observed.outcome };
|
|
421
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EvaluationInputHashError = exports.CANONICALIZATION_FAILED = exports.EVALUATION_INPUT_HASH_DOMAIN = void 0;
|
|
4
|
+
exports.buildEvaluationInputPayload = buildEvaluationInputPayload;
|
|
5
|
+
exports.serializeEvaluationInput = serializeEvaluationInput;
|
|
6
|
+
exports.evaluationInputHash = evaluationInputHash;
|
|
7
|
+
const crypto_1 = require("crypto");
|
|
8
|
+
const canonical_json_1 = require("./canonical-json");
|
|
9
|
+
/**
|
|
10
|
+
* The `evaluation-input-v1` canonical hash domain (proposal §10.1 step 2, decision 4).
|
|
11
|
+
*
|
|
12
|
+
* It computes the content identity of the ACTION-SIDE replay basis: the
|
|
13
|
+
* post-canonicalization compliance-replay input the R0 build persists as
|
|
14
|
+
* tool_attempt.evaluation_input_snapshot. This is NOT a rule hash; it is the exact input the
|
|
15
|
+
* four-state evaluator judged, so a later replay can recompute the verdict from the stored
|
|
16
|
+
* snapshot alone, with no re-read of CLAUDE.md and no second filesystem probe. The digest is
|
|
17
|
+
*
|
|
18
|
+
* SHA-256( domainTag || 0x00 || JCS(payload) ) (lowercase hex)
|
|
19
|
+
*
|
|
20
|
+
* where JCS is the repo's existing RFC 8785 canonicalizer (canonical-json.ts) and the domain
|
|
21
|
+
* tag + single 0x00 separator (decision 6) guarantee this digest can NEVER collide with the
|
|
22
|
+
* observed snapshot (`observed-rule-v1`), an attested version (`rule-version-v1`), or any other
|
|
23
|
+
* hashed artifact, even when two normalized bodies are byte-identical. JCS escapes control
|
|
24
|
+
* characters, so the only raw 0x00 byte in the hash input is the separator; the boundary
|
|
25
|
+
* between tag and payload is unambiguous.
|
|
26
|
+
*
|
|
27
|
+
* LOCKED SHAPE (proposal §10.1 step 2): exactly these JSON names, and `target` is the
|
|
28
|
+
* discriminated three-arm union. An in-scope target is a runtime-root-RELATIVE `path` (never an
|
|
29
|
+
* absolute home path); an out-of-scope target carries no path; an uncanonicalizable target is
|
|
30
|
+
* `UNKNOWN` with the single locked `reasonCode: "CANONICALIZATION_FAILED"`. The payload
|
|
31
|
+
* deliberately EXCLUDES file contents, tool output, and any unrestricted absolute home path. No
|
|
32
|
+
* unknown fields (fail-closed); no floats (float-free by construction).
|
|
33
|
+
*
|
|
34
|
+
* Universal-NFC caveat (honest). Unlike `observed-rule-v1`, this payload has NO prose field:
|
|
35
|
+
* `toolName` is an enum, `path` and `forbiddenRootRelativePath` are filesystem-derived, and the
|
|
36
|
+
* three version tags are opaque. The proposal's per-field rule says filesystem-derived and opaque
|
|
37
|
+
* values stay byte-for-byte; the vendored JCS primitive applies NFC to EVERY string. For the
|
|
38
|
+
* all-ASCII notes-location pilot every field is NFC-stable, so universal NFC is byte-identical to
|
|
39
|
+
* the per-field rule and the golden vectors are contract-correct. When a future evaluation input
|
|
40
|
+
* can carry a non-NFC `path` (for instance a target path with combining marks), this domain must
|
|
41
|
+
* switch to a per-field-NFC encoder so those bytes are preserved verbatim. That boundary is
|
|
42
|
+
* recorded in the ledger.
|
|
43
|
+
*/
|
|
44
|
+
exports.EVALUATION_INPUT_HASH_DOMAIN = "evaluation-input-v1";
|
|
45
|
+
/** The locked reasonCode for an uncanonicalizable target. */
|
|
46
|
+
exports.CANONICALIZATION_FAILED = "CANONICALIZATION_FAILED";
|
|
47
|
+
/** Thrown when an input carries a field or value outside the evaluation-input-v1 schema. */
|
|
48
|
+
class EvaluationInputHashError extends Error {
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "EvaluationInputHashError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
exports.EvaluationInputHashError = EvaluationInputHashError;
|
|
55
|
+
// The closed key sets for each object in the payload schema. Unknown fields are an error
|
|
56
|
+
// (decision 6), so a forward-incompatible producer cannot silently mint a hash a consumer
|
|
57
|
+
// would compute differently.
|
|
58
|
+
const TOP_LEVEL_KEYS = new Set([
|
|
59
|
+
"toolName",
|
|
60
|
+
"target",
|
|
61
|
+
"forbiddenRootRelativePath",
|
|
62
|
+
"evaluatorContractVersion",
|
|
63
|
+
"matcherSchemaVersion",
|
|
64
|
+
"pathCanonicalizerVersion",
|
|
65
|
+
]);
|
|
66
|
+
const TARGET_RUNTIME_RELATIVE_KEYS = new Set(["kind", "path"]);
|
|
67
|
+
const TARGET_OUTSIDE_KEYS = new Set(["kind"]);
|
|
68
|
+
const TARGET_UNKNOWN_KEYS = new Set(["kind", "reasonCode"]);
|
|
69
|
+
function rejectUnknownKeys(obj, allowed, context) {
|
|
70
|
+
for (const key of Object.keys(obj)) {
|
|
71
|
+
if (!allowed.has(key)) {
|
|
72
|
+
throw new EvaluationInputHashError(`unknown field '${key}' in ${context} is outside the ${exports.EVALUATION_INPUT_HASH_DOMAIN} schema`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function buildTargetPayload(target) {
|
|
77
|
+
switch (target.kind) {
|
|
78
|
+
case "RUNTIME_RELATIVE":
|
|
79
|
+
rejectUnknownKeys(target, TARGET_RUNTIME_RELATIVE_KEYS, "target(RUNTIME_RELATIVE)");
|
|
80
|
+
return { kind: "RUNTIME_RELATIVE", path: target.path };
|
|
81
|
+
case "OUTSIDE_RUNTIME_SCOPE":
|
|
82
|
+
rejectUnknownKeys(target, TARGET_OUTSIDE_KEYS, "target(OUTSIDE_RUNTIME_SCOPE)");
|
|
83
|
+
return { kind: "OUTSIDE_RUNTIME_SCOPE" };
|
|
84
|
+
case "UNKNOWN":
|
|
85
|
+
rejectUnknownKeys(target, TARGET_UNKNOWN_KEYS, "target(UNKNOWN)");
|
|
86
|
+
if (target.reasonCode !== exports.CANONICALIZATION_FAILED) {
|
|
87
|
+
throw new EvaluationInputHashError(`target(UNKNOWN).reasonCode must be '${exports.CANONICALIZATION_FAILED}', got '${String(target.reasonCode)}'`);
|
|
88
|
+
}
|
|
89
|
+
return { kind: "UNKNOWN", reasonCode: exports.CANONICALIZATION_FAILED };
|
|
90
|
+
default: {
|
|
91
|
+
const unexpected = target;
|
|
92
|
+
throw new EvaluationInputHashError(`unknown target kind '${String(unexpected.kind)}' is outside the ${exports.EVALUATION_INPUT_HASH_DOMAIN} schema`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Build the closed canonical payload for an EvaluationInputV1: the exact object that gets
|
|
98
|
+
* canonicalized and hashed. Rejects unknown fields and an unknown target arm, and locks the
|
|
99
|
+
* UNKNOWN reasonCode. Field NFC is applied by the JCS encoder on the way out; see the
|
|
100
|
+
* universal-NFC caveat in the file header.
|
|
101
|
+
*/
|
|
102
|
+
function buildEvaluationInputPayload(input) {
|
|
103
|
+
rejectUnknownKeys(input, TOP_LEVEL_KEYS, "evaluation input");
|
|
104
|
+
return {
|
|
105
|
+
toolName: input.toolName,
|
|
106
|
+
target: buildTargetPayload(input.target),
|
|
107
|
+
forbiddenRootRelativePath: input.forbiddenRootRelativePath,
|
|
108
|
+
evaluatorContractVersion: input.evaluatorContractVersion,
|
|
109
|
+
matcherSchemaVersion: input.matcherSchemaVersion,
|
|
110
|
+
pathCanonicalizerVersion: input.pathCanonicalizerVersion,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** The exact RFC 8785 canonical JSON string that is hashed (UTF-8). Exposed for golden
|
|
114
|
+
* vectors and debugging; the digest is over these bytes prefixed by the domain. */
|
|
115
|
+
function serializeEvaluationInput(input) {
|
|
116
|
+
return (0, canonical_json_1.canonicalize)(buildEvaluationInputPayload(input));
|
|
117
|
+
}
|
|
118
|
+
/** The evaluation-input-v1 content hash: SHA-256(domainTag || 0x00 || JCS(payload)), lowercase hex. */
|
|
119
|
+
function evaluationInputHash(input) {
|
|
120
|
+
const jcs = serializeEvaluationInput(input);
|
|
121
|
+
const h = (0, crypto_1.createHash)("sha256");
|
|
122
|
+
h.update(exports.EVALUATION_INPUT_HASH_DOMAIN, "utf8");
|
|
123
|
+
h.update(Buffer.from([0x00]));
|
|
124
|
+
h.update(jcs, "utf8");
|
|
125
|
+
return h.digest("hex");
|
|
126
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.selectRule = selectRule;
|
|
4
|
+
exports.verdictForForbiddenRoot = verdictForForbiddenRoot;
|
|
5
|
+
exports.verdictForForbiddenContent = verdictForForbiddenContent;
|
|
6
|
+
exports.verdictForForbiddenCommand = verdictForForbiddenCommand;
|
|
7
|
+
exports.isEnforcementEligible = isEnforcementEligible;
|
|
8
|
+
/**
|
|
9
|
+
* Minimal R0 glob: an optional literal prefix (before the first "*") and an
|
|
10
|
+
* optional literal suffix (after the last "*"). Covers "*.md", "** /*.md",
|
|
11
|
+
* "foo*", "*bar", and "*". Anything between the first and last "*" is ignored,
|
|
12
|
+
* which is sufficient for the configured "*.md" matcher and keeps the surface
|
|
13
|
+
* tiny on purpose.
|
|
14
|
+
*/
|
|
15
|
+
function matchesGlob(value, glob) {
|
|
16
|
+
const firstStar = glob.indexOf("*");
|
|
17
|
+
if (firstStar === -1) {
|
|
18
|
+
return value === glob;
|
|
19
|
+
}
|
|
20
|
+
const lastStar = glob.lastIndexOf("*");
|
|
21
|
+
const prefix = glob.slice(0, firstStar);
|
|
22
|
+
const suffix = glob.slice(lastStar + 1);
|
|
23
|
+
return (value.length >= prefix.length + suffix.length &&
|
|
24
|
+
value.startsWith(prefix) &&
|
|
25
|
+
value.endsWith(suffix));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Pure selection. Ambient rules are prompt-time grounding, not action gates:
|
|
29
|
+
* they never produce a per-call verdict, so at an action point they are
|
|
30
|
+
* NOT_APPLICABLE. An action rule applies when the tool is in its list and,
|
|
31
|
+
* if the matcher carries a glob, the named field holds a string matching it.
|
|
32
|
+
*/
|
|
33
|
+
function selectRule(call, applicability) {
|
|
34
|
+
if (applicability.mode === "ambient") {
|
|
35
|
+
return "NOT_APPLICABLE";
|
|
36
|
+
}
|
|
37
|
+
if (!applicability.tools.includes(call.toolName)) {
|
|
38
|
+
return "NOT_APPLICABLE";
|
|
39
|
+
}
|
|
40
|
+
const { matcher } = applicability;
|
|
41
|
+
if (matcher.glob !== undefined) {
|
|
42
|
+
const value = call.toolInput[matcher.field];
|
|
43
|
+
if (typeof value !== "string" || !matchesGlob(value, matcher.glob)) {
|
|
44
|
+
return "NOT_APPLICABLE";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return "APPLIES";
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Pure verdict for a PROHIBIT forbidden-root rule. Maps the path classification
|
|
51
|
+
* (or the "UNSUPPORTED" sentinel from an evaluator that cannot handle the input)
|
|
52
|
+
* to a four-state verdict. The only enforcement-eligible outcome is VIOLATION;
|
|
53
|
+
* every uncertainty degrades to UNKNOWN.
|
|
54
|
+
*/
|
|
55
|
+
function verdictForForbiddenRoot(classification) {
|
|
56
|
+
switch (classification) {
|
|
57
|
+
case "UNDER_FORBIDDEN_ROOT":
|
|
58
|
+
return { result: "VIOLATION", reasonCode: "FORBIDDEN_PATH_MATCH" };
|
|
59
|
+
case "OUTSIDE_FORBIDDEN_ROOT":
|
|
60
|
+
return { result: "COMPLIANT", reasonCode: "COMPLIANT_OUTSIDE_FORBIDDEN_ROOT" };
|
|
61
|
+
case "INDETERMINATE":
|
|
62
|
+
return { result: "UNKNOWN", reasonCode: "CANONICALIZATION_FAILED" };
|
|
63
|
+
case "UNSUPPORTED":
|
|
64
|
+
return { result: "UNKNOWN", reasonCode: "EVALUATOR_UNSUPPORTED" };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Pure verdict for a PROHIBIT forbidden-content rule (the em-dash-ban class).
|
|
69
|
+
* Unlike the path/command matchers, a content field is fully observable, so a
|
|
70
|
+
* "no needle" result is a genuine COMPLIANT rather than an UNKNOWN: we hold the
|
|
71
|
+
* entire payload, so absence of the forbidden bytes is proven, not merely
|
|
72
|
+
* unobserved. INDETERMINATE (non-string field or empty needle set) degrades to
|
|
73
|
+
* UNKNOWN, which never asks or denies.
|
|
74
|
+
*/
|
|
75
|
+
function verdictForForbiddenContent(classification) {
|
|
76
|
+
switch (classification) {
|
|
77
|
+
case "CONTAINS_FORBIDDEN":
|
|
78
|
+
return { result: "VIOLATION", reasonCode: "FORBIDDEN_CONTENT_MATCH" };
|
|
79
|
+
case "NO_FORBIDDEN":
|
|
80
|
+
return { result: "COMPLIANT", reasonCode: "COMPLIANT_NO_FORBIDDEN_CONTENT" };
|
|
81
|
+
case "INDETERMINATE":
|
|
82
|
+
return { result: "UNKNOWN", reasonCode: "CONTENT_INDETERMINATE" };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Pure verdict for a PROHIBIT forbidden-command rule (the git/prisma class). The
|
|
87
|
+
* inverse of the content verdict: because a shell string is opaque, there is NO
|
|
88
|
+
* COMPLIANT outcome. Only a positive literal token-run match is a verdict
|
|
89
|
+
* (VIOLATION); both NO_MATCH and INDETERMINATE degrade to UNKNOWN, since a
|
|
90
|
+
* non-match cannot prove the command will not perform the operation (an alias, a
|
|
91
|
+
* wrapper script, eval, or $VAR expansion could). The distinct UNKNOWN reason
|
|
92
|
+
* codes keep "tokenized, found nothing" observably separate from "could not
|
|
93
|
+
* evaluate".
|
|
94
|
+
*/
|
|
95
|
+
function verdictForForbiddenCommand(classification) {
|
|
96
|
+
switch (classification) {
|
|
97
|
+
case "MATCHES_FORBIDDEN":
|
|
98
|
+
return { result: "VIOLATION", reasonCode: "FORBIDDEN_COMMAND_MATCH" };
|
|
99
|
+
case "NO_MATCH":
|
|
100
|
+
return { result: "UNKNOWN", reasonCode: "COMMAND_NO_MATCH_OPAQUE" };
|
|
101
|
+
case "INDETERMINATE":
|
|
102
|
+
return { result: "UNKNOWN", reasonCode: "COMMAND_INDETERMINATE" };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Only VIOLATION is potentially enforcement-eligible. */
|
|
106
|
+
function isEnforcementEligible(result) {
|
|
107
|
+
return result === "VIOLATION";
|
|
108
|
+
}
|