@bradheitmann/odin-sentinel 0.4.9 → 0.4.11
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/AGENTS.md +4 -0
- package/README.md +13 -13
- package/dist/src/mcp/server.js +2 -2
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/protocol/index.d.ts +2 -2
- package/dist/src/protocol/index.js +1 -1
- package/dist/src/protocol/index.js.map +1 -1
- package/dist/src/protocol/schemas.d.ts +28 -0
- package/dist/src/protocol/schemas.js +20 -3
- package/dist/src/protocol/schemas.js.map +1 -1
- package/dist/src/protocol/service.d.ts +69 -1
- package/dist/src/protocol/service.js +361 -9
- package/dist/src/protocol/service.js.map +1 -1
- package/dist/src/protocol/version.d.ts +2 -2
- package/dist/src/protocol/version.js +2 -2
- package/dist/src/protocol/version.js.map +1 -1
- package/docs/guides/quick-start.md +22 -7
- package/docs/guides/quickstart-prompts.md +3 -3
- package/docs/reference/client-compatibility.md +36 -0
- package/docs/reference/distribution.md +5 -5
- package/docs/reference/public-surface-audit.md +1 -1
- package/package.json +2 -2
- package/protocol/SCP.md +19 -2
- package/protocol/bootstrap-skill.md +1 -1
- package/protocol/closeout.yaml +1 -1
- package/protocol/delegation.yaml +1 -1
- package/protocol/model-profiles.yaml +1 -1
- package/protocol/roles.yaml +1 -1
- package/protocol/topology.yaml +1 -1
- package/scripts/protocol/install-activation-hooks.mjs +61 -23
- package/scripts/protocol/verify-governed-context.mjs +358 -0
|
@@ -72,6 +72,216 @@ const MODEL_STATUSES = [
|
|
|
72
72
|
"STREAMING_PROTOCOL_MISMATCH",
|
|
73
73
|
"MODEL_UNREACHABLE"
|
|
74
74
|
];
|
|
75
|
+
export const GOVERNED_CONTEXT_PROOF_SCHEMA = "odin.governed_context_proof.v1";
|
|
76
|
+
const GOVERNED_CONTEXT_SOURCE_TYPES = ["native_skill", "static_control_file", "mcp_bootstrap"];
|
|
77
|
+
// User-clear four-state model surfaced by every governed-readiness surface (probe, gate, onboarding).
|
|
78
|
+
const GOVERNED_READINESS_MODEL = {
|
|
79
|
+
states: {
|
|
80
|
+
GOVERNED_READY: "Verified control layer + proven protocol uptake at adequate assurance + required hooks/validators + no unwaivable blocker.",
|
|
81
|
+
FIXABLE_BLOCKED: "Supported harness missing skill/static/MCP control proof, uptake receipt, hook, auth, or liveness — fixable, not yet governed.",
|
|
82
|
+
NON_GOVERNED_ONE_SHOT_ONLY: "Bounded non-authoritative use only: no PM/ODIN/QA acceptance, closure, or team coordination.",
|
|
83
|
+
UNSUPPORTED: "Harness cannot enforce SCP governed context and must not hold a governed role."
|
|
84
|
+
},
|
|
85
|
+
rule: "MCP configured alone, or a skill file on disk alone, never yields GOVERNED_READY. PM/ODIN roles require the highest assurance the harness supports.",
|
|
86
|
+
verifier: "scripts/protocol/verify-governed-context.mjs"
|
|
87
|
+
};
|
|
88
|
+
// Harness category registry: how each harness can carry an enforced SCP control layer. Unknown
|
|
89
|
+
// harnesses default to mcp_only (still fail-closed: no proof ⇒ not governed-ready).
|
|
90
|
+
const HARNESS_CATEGORY_REGISTRY = {
|
|
91
|
+
codex: "native_skill",
|
|
92
|
+
"claude code": "native_skill",
|
|
93
|
+
droid: "mcp_only",
|
|
94
|
+
goose: "mcp_only",
|
|
95
|
+
openhands: "mcp_only",
|
|
96
|
+
kilocode: "mcp_only",
|
|
97
|
+
cursor: "static_control_file",
|
|
98
|
+
zed: "static_control_file",
|
|
99
|
+
aider: "static_control_file",
|
|
100
|
+
crush: "static_control_file",
|
|
101
|
+
pi: "unsupported",
|
|
102
|
+
nanocoder: "unsupported"
|
|
103
|
+
};
|
|
104
|
+
export function harnessCategory(harness) {
|
|
105
|
+
return HARNESS_CATEGORY_REGISTRY[harness.trim().toLowerCase()] ?? "mcp_only";
|
|
106
|
+
}
|
|
107
|
+
const ASSURANCE_RANK = { none: 0, mcp_bootstrap: 1, static_control_file: 2, native_skill: 3 };
|
|
108
|
+
function maxAssuranceForCategory(category) {
|
|
109
|
+
if (category === "native_skill")
|
|
110
|
+
return "native_skill";
|
|
111
|
+
if (category === "static_control_file")
|
|
112
|
+
return "static_control_file";
|
|
113
|
+
if (category === "mcp_only")
|
|
114
|
+
return "mcp_bootstrap";
|
|
115
|
+
return "none";
|
|
116
|
+
}
|
|
117
|
+
function sourceTypeToAssurance(sourceType) {
|
|
118
|
+
if (sourceType === "native_skill")
|
|
119
|
+
return "native_skill";
|
|
120
|
+
if (sourceType === "static_control_file")
|
|
121
|
+
return "static_control_file";
|
|
122
|
+
if (sourceType === "mcp_bootstrap")
|
|
123
|
+
return "mcp_bootstrap";
|
|
124
|
+
return "none";
|
|
125
|
+
}
|
|
126
|
+
function isHighAuthorityRole(role) {
|
|
127
|
+
if (!role)
|
|
128
|
+
return false;
|
|
129
|
+
const n = normalizeRoleName(role);
|
|
130
|
+
return n === "EXEC_PM" || n === "TEAM_PM" || n === "EXEC_ODIN" || n === "TEAM_ODIN";
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Pure shape validation of a governed-context proof (no disk access). The authoritative on-disk
|
|
134
|
+
* checksum gate is scripts/protocol/verify-governed-context.mjs; this validates the structure,
|
|
135
|
+
* the stable source marker, the uptake-receipt marker linkage, and zero-secret content so the
|
|
136
|
+
* MCP-visible readiness surfaces can require proof. A self-reported boolean with no stable
|
|
137
|
+
* source marker can never validate here (slice MUST NOT).
|
|
138
|
+
*/
|
|
139
|
+
export function validateGovernedContextProof(proof) {
|
|
140
|
+
const reasons = [];
|
|
141
|
+
if (!proof || typeof proof !== "object" || Array.isArray(proof)) {
|
|
142
|
+
return { valid: false, proofAssurance: null, reasons: ["governed-context proof is not an object"] };
|
|
143
|
+
}
|
|
144
|
+
const record = asRecord(proof);
|
|
145
|
+
const serialized = JSON.stringify(record);
|
|
146
|
+
if (redactSecretLikeText(serialized) !== serialized)
|
|
147
|
+
reasons.push("governed-context proof contains a secret-looking value");
|
|
148
|
+
if (record.schema !== GOVERNED_CONTEXT_PROOF_SCHEMA)
|
|
149
|
+
reasons.push(`schema must be ${GOVERNED_CONTEXT_PROOF_SCHEMA}`);
|
|
150
|
+
for (const field of ["role", "harness", "source_type", "generated_at"]) {
|
|
151
|
+
if (typeof record[field] !== "string" || record[field].trim() === "")
|
|
152
|
+
reasons.push(`missing ${field}`);
|
|
153
|
+
}
|
|
154
|
+
const sourceType = record.source_type;
|
|
155
|
+
if (typeof sourceType === "string" && !GOVERNED_CONTEXT_SOURCE_TYPES.includes(sourceType)) {
|
|
156
|
+
reasons.push("invalid source_type");
|
|
157
|
+
}
|
|
158
|
+
const controlSource = asRecord(record.control_source);
|
|
159
|
+
const marker = typeof controlSource.marker === "string" ? controlSource.marker.trim() : "";
|
|
160
|
+
if (marker === "")
|
|
161
|
+
reasons.push("control_source.marker is required (a stable source marker, not a bare boolean)");
|
|
162
|
+
if (typeof controlSource.path === "string" && controlSource.path.trim() !== "" && (typeof controlSource.sha256 !== "string" || controlSource.sha256.trim() === "")) {
|
|
163
|
+
reasons.push("control_source.sha256 is required when control_source.path is present");
|
|
164
|
+
}
|
|
165
|
+
if (!record.uptake_receipt || typeof record.uptake_receipt !== "object" || Array.isArray(record.uptake_receipt)) {
|
|
166
|
+
reasons.push("missing uptake_receipt");
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const uptake = asRecord(record.uptake_receipt);
|
|
170
|
+
if (uptake.observed !== true)
|
|
171
|
+
reasons.push("uptake_receipt.observed must be true");
|
|
172
|
+
const evidence = typeof uptake.evidence_marker === "string" ? uptake.evidence_marker.trim() : "";
|
|
173
|
+
if (evidence === "")
|
|
174
|
+
reasons.push("uptake_receipt.evidence_marker is required (self-asserted uptake without a stable marker is rejected)");
|
|
175
|
+
else if (marker !== "" && evidence !== marker)
|
|
176
|
+
reasons.push("uptake_receipt.evidence_marker does not match control_source.marker");
|
|
177
|
+
if (typeof uptake.method !== "string" || uptake.method.trim() === "")
|
|
178
|
+
reasons.push("uptake_receipt.method is required");
|
|
179
|
+
}
|
|
180
|
+
return { valid: reasons.length === 0, proofAssurance: reasons.length === 0 ? sourceTypeToAssurance(sourceType) : null, reasons };
|
|
181
|
+
}
|
|
182
|
+
const UNSAFE_DELIVERY_STATES = new Set(["INPUT_BAR_ONLY", "PANE_BLOCKED_ON_PERMISSION"]);
|
|
183
|
+
const UNSAFE_LIVENESS_STATES = new Set(["NO_VISIBLE_PROCESSING", "IDLE_STALLED", "NO_ACK", "STALE_IDLE"]);
|
|
184
|
+
function governedReadinessNextAction(state, requiredAssurance, blockers) {
|
|
185
|
+
if (state === "NON_GOVERNED_ONE_SHOT_ONLY") {
|
|
186
|
+
return "This harness cannot reach the assurance this role requires; use it only for bounded one-shot work, or assign the role to a higher-assurance harness.";
|
|
187
|
+
}
|
|
188
|
+
const top = blockers[0] ?? "no verified governed-context uptake proof";
|
|
189
|
+
if (top.startsWith("no verified governed-context")) {
|
|
190
|
+
if (requiredAssurance === "native_skill")
|
|
191
|
+
return "Install the native sentinel-coordination-protocol skill, load it, then capture a governed-context uptake proof (verify with scripts/protocol/verify-governed-context.mjs).";
|
|
192
|
+
if (requiredAssurance === "static_control_file")
|
|
193
|
+
return "Install/validate the static control file, load it, then capture a governed-context uptake proof (verify with scripts/protocol/verify-governed-context.mjs).";
|
|
194
|
+
return "Load the MCP bootstrap resource, then capture a governed-context uptake proof (verify with scripts/protocol/verify-governed-context.mjs).";
|
|
195
|
+
}
|
|
196
|
+
if (top.startsWith("assurance "))
|
|
197
|
+
return "Provide a higher-assurance governed-context proof; a lower-assurance path does not qualify for this harness/role (PM and ODIN need the highest available).";
|
|
198
|
+
if (top.startsWith("authentication") || top.startsWith("auth blocked"))
|
|
199
|
+
return "Sign in or provision the harness outside chat (never paste secrets), then re-probe.";
|
|
200
|
+
if (top.startsWith("delivery") || top.startsWith("liveness") || top.startsWith("blocked on a permission") || top.startsWith("stale idle"))
|
|
201
|
+
return "Resolve the live blocker: submit with Enter and confirm processing, approve the permission prompt, or restart the stalled occupant; then re-probe.";
|
|
202
|
+
if (top.startsWith("required hooks/validators"))
|
|
203
|
+
return "Deploy and confirm the activation hook + governed-context verifier (scripts/protocol/install-activation-hooks.mjs, scripts/protocol/verify-governed-context.mjs), then re-probe.";
|
|
204
|
+
return "Resolve the listed blocker, then re-run the governed-readiness probe.";
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Single source of truth for the four-state governed-readiness taxonomy, reused by the harness
|
|
208
|
+
* probe matrix, the readiness gate, and onboarding so they never emit conflicting statuses.
|
|
209
|
+
*/
|
|
210
|
+
export function classifyGovernedReadiness(input) {
|
|
211
|
+
const category = harnessCategory(input.harness);
|
|
212
|
+
const highAuthority = isHighAuthorityRole(input.requestedRole);
|
|
213
|
+
const proofCheck = validateGovernedContextProof(input.governedContextProof);
|
|
214
|
+
const uptakeVerified = proofCheck.valid;
|
|
215
|
+
const proofAssurance = proofCheck.proofAssurance;
|
|
216
|
+
const baseRequired = maxAssuranceForCategory(category);
|
|
217
|
+
// PM/ODIN must clear at least static_control_file assurance; MCP-only never silently qualifies.
|
|
218
|
+
const requiredAssurance = highAuthority
|
|
219
|
+
? (ASSURANCE_RANK[baseRequired] >= ASSURANCE_RANK.static_control_file ? baseRequired : "static_control_file")
|
|
220
|
+
: baseRequired;
|
|
221
|
+
if (category === "unsupported") {
|
|
222
|
+
return {
|
|
223
|
+
state: "UNSUPPORTED",
|
|
224
|
+
category,
|
|
225
|
+
requiredAssurance,
|
|
226
|
+
proofAssurance,
|
|
227
|
+
uptakeVerified: false,
|
|
228
|
+
blockers: ["harness cannot enforce an SCP governed-context control layer"],
|
|
229
|
+
nextSafeAction: "Use this harness only for bounded, non-governed one-shot help; do not assign it a governed role."
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const blockers = [];
|
|
233
|
+
if (input.installed === false)
|
|
234
|
+
blockers.push("not installed or not provisioned");
|
|
235
|
+
if (input.authenticated === false)
|
|
236
|
+
blockers.push("authentication blocker (sign in or provision outside chat)");
|
|
237
|
+
if (Array.isArray(input.authBlockers) && input.authBlockers.length > 0)
|
|
238
|
+
blockers.push(`auth blocked: ${input.authBlockers.join(", ")}`);
|
|
239
|
+
// Affirmative requirement: hooks/validators must be confirmed available. Unknown or omitted
|
|
240
|
+
// hook availability foreclosed an optional-failure path, so it blocks governed readiness.
|
|
241
|
+
if (input.hooksAvailable !== true)
|
|
242
|
+
blockers.push("required hooks/validators not confirmed available (the governed-context verifier and activation hook must be affirmatively present)");
|
|
243
|
+
if (input.permissionBlocked === true)
|
|
244
|
+
blockers.push("blocked on a permission prompt");
|
|
245
|
+
if (input.idleStalled === true)
|
|
246
|
+
blockers.push("stale idle: no visible progress");
|
|
247
|
+
if (typeof input.deliveryState === "string" && UNSAFE_DELIVERY_STATES.has(input.deliveryState))
|
|
248
|
+
blockers.push("delivery not confirmed (input-bar-only or permission-blocked is not delivery)");
|
|
249
|
+
if (typeof input.livenessState === "string" && UNSAFE_LIVENESS_STATES.has(input.livenessState))
|
|
250
|
+
blockers.push("liveness not confirmed (no visible processing, missing ack, or stale idle)");
|
|
251
|
+
if (Array.isArray(input.otherBlockers))
|
|
252
|
+
for (const b of input.otherBlockers)
|
|
253
|
+
blockers.push(b);
|
|
254
|
+
if (!uptakeVerified) {
|
|
255
|
+
blockers.push("no verified governed-context uptake proof (MCP config or a skill file on disk is not uptake)");
|
|
256
|
+
}
|
|
257
|
+
else if (proofAssurance && ASSURANCE_RANK[proofAssurance] < ASSURANCE_RANK[requiredAssurance]) {
|
|
258
|
+
blockers.push(`assurance ${proofAssurance} is below the ${requiredAssurance} required for this harness/role`);
|
|
259
|
+
}
|
|
260
|
+
if (blockers.length === 0 && uptakeVerified) {
|
|
261
|
+
return {
|
|
262
|
+
state: "GOVERNED_READY",
|
|
263
|
+
category,
|
|
264
|
+
requiredAssurance,
|
|
265
|
+
proofAssurance,
|
|
266
|
+
uptakeVerified,
|
|
267
|
+
blockers: [],
|
|
268
|
+
nextSafeAction: "Governed authority verified. Launch the role; keep the governed-context proof fresh and re-verify on resume."
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
// Supported but blocked. If the harness can never reach the required assurance for this role,
|
|
272
|
+
// it is one-shot only for that role; otherwise the blockers are fixable.
|
|
273
|
+
const reachable = ASSURANCE_RANK[maxAssuranceForCategory(category)] >= ASSURANCE_RANK[requiredAssurance];
|
|
274
|
+
const state = reachable ? "FIXABLE_BLOCKED" : "NON_GOVERNED_ONE_SHOT_ONLY";
|
|
275
|
+
return {
|
|
276
|
+
state,
|
|
277
|
+
category,
|
|
278
|
+
requiredAssurance,
|
|
279
|
+
proofAssurance,
|
|
280
|
+
uptakeVerified,
|
|
281
|
+
blockers,
|
|
282
|
+
nextSafeAction: governedReadinessNextAction(state, requiredAssurance, blockers)
|
|
283
|
+
};
|
|
284
|
+
}
|
|
75
285
|
function roleKind(roleSlot) {
|
|
76
286
|
return normalizeRoleName(roleSlot);
|
|
77
287
|
}
|
|
@@ -95,6 +305,35 @@ function scpContextSources(slot) {
|
|
|
95
305
|
}
|
|
96
306
|
return Array.from(sources);
|
|
97
307
|
}
|
|
308
|
+
// Map a VERIFIED governed-context proof's assurance to the equivalent legacy SCP context-source
|
|
309
|
+
// label. Returns null when the proof does not validate (fail-closed: no valid proof, no source).
|
|
310
|
+
function proofDerivedContextSource(proof) {
|
|
311
|
+
const check = validateGovernedContextProof(proof);
|
|
312
|
+
if (!check.valid)
|
|
313
|
+
return null;
|
|
314
|
+
if (check.proofAssurance === "native_skill")
|
|
315
|
+
return "native sentinel-coordination-protocol skill";
|
|
316
|
+
if (check.proofAssurance === "static_control_file")
|
|
317
|
+
return "full injected SCP protocol text";
|
|
318
|
+
if (check.proofAssurance === "mcp_bootstrap")
|
|
319
|
+
return "odin-sentinel MCP bootstrap resource";
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
// Reconcile legacy context-source fields with a governed-context proof. A valid proof SUPPLIES the
|
|
323
|
+
// context source when legacy fields are absent (so a proof-only governed slot is not downgraded
|
|
324
|
+
// merely for missing stale fields), but it can never override a contradictory legacy declaration:
|
|
325
|
+
// incongruent legacy/proof sources fail closed as a conflict — never the more permissive source.
|
|
326
|
+
function resolveContextSources(slot) {
|
|
327
|
+
const legacy = scpContextSources(slot);
|
|
328
|
+
const proofSource = proofDerivedContextSource(slot.governedContextProof);
|
|
329
|
+
if (proofSource === null)
|
|
330
|
+
return { sources: legacy, conflict: false };
|
|
331
|
+
if (legacy.length === 0)
|
|
332
|
+
return { sources: [proofSource], conflict: false };
|
|
333
|
+
if (legacy.includes(proofSource))
|
|
334
|
+
return { sources: legacy, conflict: false };
|
|
335
|
+
return { sources: legacy, conflict: true };
|
|
336
|
+
}
|
|
98
337
|
function defaultSafeOutcomes() {
|
|
99
338
|
return [
|
|
100
339
|
"choose a different harness",
|
|
@@ -111,7 +350,7 @@ function classifyProbeText(harness, text) {
|
|
|
111
350
|
const classes = [];
|
|
112
351
|
if (/permission|allow|approve|deny|press y|waiting for confirmation/.test(safeText))
|
|
113
352
|
classes.push("BLOCKED_BY_PERMISSION");
|
|
114
|
-
if (/login
|
|
353
|
+
if (/login|\bsign[\s-]?in\b|\bnot signed[\s-]?in\b|authenticate|kilo auth login|\/connect/.test(safeText))
|
|
115
354
|
classes.push("BLOCKED_BY_LOGIN");
|
|
116
355
|
if (/api key|apikey|credential|inference credential|provider config|openhands/.test(safeText))
|
|
117
356
|
classes.push("BLOCKED_BY_API_KEY");
|
|
@@ -450,6 +689,33 @@ export function getActivationGates() {
|
|
|
450
689
|
generated_at: "2026-01-01T00:00:00Z",
|
|
451
690
|
files: [{ path: "protocol/SCP.md", bytes: 4175, lines: 87, sha256: "<sha256-digest>" }]
|
|
452
691
|
}
|
|
692
|
+
},
|
|
693
|
+
governedContextProof: {
|
|
694
|
+
requirement: "Governed authority is fail-closed: MCP being configured or an SCP skill existing on disk is NOT enough. Before a persistent governed role acts, prove the control layer was loaded and taken up — a stable source marker actually observed — at an assurance level adequate for the role.",
|
|
695
|
+
fourStateModel: GOVERNED_READINESS_MODEL,
|
|
696
|
+
requiredFields: ["schema", "role", "harness", "source_type", "control_source.marker", "uptake_receipt", "generated_at"],
|
|
697
|
+
sourceTypes: GOVERNED_CONTEXT_SOURCE_TYPES,
|
|
698
|
+
rejects: [
|
|
699
|
+
"self-reported boolean with no stable source marker",
|
|
700
|
+
"MCP configured alone / skill file on disk alone",
|
|
701
|
+
"checksum mismatch when control_source.path is present",
|
|
702
|
+
"missing or stale uptake receipt",
|
|
703
|
+
"secret-looking values"
|
|
704
|
+
],
|
|
705
|
+
verifierScript: "scripts/protocol/verify-governed-context.mjs",
|
|
706
|
+
installerScript: "scripts/protocol/install-activation-hooks.mjs",
|
|
707
|
+
verifyCommand: "node scripts/protocol/verify-governed-context.mjs <proof.json>",
|
|
708
|
+
recordCommand: "node scripts/protocol/verify-governed-context.mjs --record --source <control-file> --marker <stable-marker> > proof.json",
|
|
709
|
+
surfacedBy: ["odin.get_harness_probe_matrix", "odin.evaluate_readiness_gate", "odin.get_onboarding_plan"],
|
|
710
|
+
example: {
|
|
711
|
+
schema: GOVERNED_CONTEXT_PROOF_SCHEMA,
|
|
712
|
+
role: "B/DEV-1",
|
|
713
|
+
harness: "Claude Code",
|
|
714
|
+
source_type: "native_skill",
|
|
715
|
+
control_source: { path: "~/.claude/skills/sentinel-coordination-protocol/SKILL.md", marker: "SCP_PUBLIC_VERSION: 0.4.x", sha256: "<sha256-digest>" },
|
|
716
|
+
uptake_receipt: { method: "quoted_marker", evidence_marker: "SCP_PUBLIC_VERSION: 0.4.x", observed: true, observed_at: "2026-01-01T00:00:00Z" },
|
|
717
|
+
generated_at: "2026-01-01T00:00:00Z"
|
|
718
|
+
}
|
|
453
719
|
}
|
|
454
720
|
};
|
|
455
721
|
}
|
|
@@ -753,7 +1019,9 @@ export function evaluateReadinessGate(input) {
|
|
|
753
1019
|
const harness = slot.harness ?? "unknown";
|
|
754
1020
|
const classifications = [];
|
|
755
1021
|
const notes = [];
|
|
756
|
-
|
|
1022
|
+
// A valid governed-context proof can supply the legacy context-source when legacy fields are
|
|
1023
|
+
// absent; a legacy/proof contradiction fails closed (CONTEXT_SOURCE_CONFLICT), never permissive.
|
|
1024
|
+
const { sources, conflict: contextSourceConflict } = resolveContextSources(slot);
|
|
757
1025
|
let status = "PASS";
|
|
758
1026
|
if (!execPmAuthorized)
|
|
759
1027
|
classifications.push("EXEC_PM_AUTHORIZATION_REQUIRED");
|
|
@@ -777,6 +1045,10 @@ export function evaluateReadinessGate(input) {
|
|
|
777
1045
|
classifications.push("SCP_SKILL_MISSING");
|
|
778
1046
|
if (sources.length === 0 && isGovernedRole(slot.roleSlot))
|
|
779
1047
|
classifications.push("NON_GOVERNED_ONE_SHOT_ONLY");
|
|
1048
|
+
if (contextSourceConflict) {
|
|
1049
|
+
classifications.push("CONTEXT_SOURCE_CONFLICT");
|
|
1050
|
+
notes.push("Legacy SCP context-source field contradicts the governed-context proof source; failing closed. Remove the stale legacy field or supply a congruent proof — do not rely on the more permissive source.");
|
|
1051
|
+
}
|
|
780
1052
|
if (slot.firstRunPermissionStatus === "PROMPT_WAITING" || slot.firstRunPermissionStatus === "DENIED")
|
|
781
1053
|
classifications.push("BLOCKED_BY_PERMISSION");
|
|
782
1054
|
if (slot.authStatus === "AUTH_LOGIN_REQUIRED")
|
|
@@ -812,7 +1084,8 @@ export function evaluateReadinessGate(input) {
|
|
|
812
1084
|
"STREAMING_PROTOCOL_MISMATCH",
|
|
813
1085
|
"MODEL_UNREACHABLE",
|
|
814
1086
|
"ROLE_COMPATIBILITY_FAILED",
|
|
815
|
-
"UNSUITABLE_FOR_ODIN_ROLE"
|
|
1087
|
+
"UNSUITABLE_FOR_ODIN_ROLE",
|
|
1088
|
+
"CONTEXT_SOURCE_CONFLICT"
|
|
816
1089
|
]);
|
|
817
1090
|
const hasUnwaivableLaunchBlocker = uniqueClassifications.some((item) => unwaivableLaunchBlockers.has(item));
|
|
818
1091
|
const approvedWaiver = execPmAuthorized && slot.waiver === "WAIVED_BY_EXEC_PM" && !hasUnwaivableLaunchBlocker;
|
|
@@ -836,36 +1109,70 @@ export function evaluateReadinessGate(input) {
|
|
|
836
1109
|
const readyOccupant = ["BOOTSTRAPPED_IDLE", "ACTIVE_WATCH"].includes(occupantState) || vacantSlotExplicitlyOptional;
|
|
837
1110
|
const substitutionActivationReady = vacantSlotExplicitlyOptional;
|
|
838
1111
|
const launchAllowed = status === "PASS" || status === "WAIVED_BY_EXEC_PM" || status === "SUBSTITUTION_APPROVED_BY_EXEC_PM";
|
|
839
|
-
const
|
|
1112
|
+
const activationAllowedBase = status === "PASS"
|
|
840
1113
|
? readyOccupant
|
|
841
1114
|
: status === "WAIVED_BY_EXEC_PM"
|
|
842
1115
|
? readyOccupant && failureClassifications.length === 0
|
|
843
1116
|
: status === "SUBSTITUTION_APPROVED_BY_EXEC_PM"
|
|
844
1117
|
? substitutionActivationReady
|
|
845
1118
|
: false;
|
|
1119
|
+
// Fail-closed governed readiness for this slot, using the shared four-state taxonomy.
|
|
1120
|
+
const slotAuthenticated = slot.authStatus === "AUTH_READY"
|
|
1121
|
+
? true
|
|
1122
|
+
: ["BLOCKED_BY_API_KEY", "AUTH_PROVIDER_BLOCKED", "BLOCKED_BY_LOGIN", "BLOCKED_BY_AUTH"].some((item) => uniqueClassifications.includes(item))
|
|
1123
|
+
? false
|
|
1124
|
+
: "unknown";
|
|
1125
|
+
const governed = classifyGovernedReadiness({
|
|
1126
|
+
harness,
|
|
1127
|
+
authenticated: slotAuthenticated,
|
|
1128
|
+
requestedRole: slot.role ?? slot.roleSlot,
|
|
1129
|
+
governedContextProof: slot.governedContextProof,
|
|
1130
|
+
hooksAvailable: slot.hooksAvailable,
|
|
1131
|
+
deliveryState: slot.deliveryState,
|
|
1132
|
+
livenessState: slot.livenessState,
|
|
1133
|
+
permissionBlocked: uniqueClassifications.includes("BLOCKED_BY_PERMISSION") || slot.permissionBlocked === true,
|
|
1134
|
+
idleStalled: slot.idleStalled,
|
|
1135
|
+
otherBlockers: failureClassifications
|
|
1136
|
+
});
|
|
1137
|
+
// Hard fail-closed gate: a governed-role occupant may not ACTIVATE (begin governed work)
|
|
1138
|
+
// unless governedReadiness is GOVERNED_READY. Launch/provisioning may still proceed. This
|
|
1139
|
+
// closes the legacy readyOccupant / failureClassifications path where an MCP-configured or
|
|
1140
|
+
// skill-on-disk slot with no verified protocol uptake could reach TEAM_ACTIVATION_ALLOWED.
|
|
1141
|
+
const governedOccupant = isGovernedRole(slot.roleSlot) && !vacantSlot;
|
|
1142
|
+
const activationAllowed = governedOccupant ? activationAllowedBase && governed.state === "GOVERNED_READY" : activationAllowedBase;
|
|
1143
|
+
if (governedOccupant && activationAllowedBase && governed.state !== "GOVERNED_READY") {
|
|
1144
|
+
notes.push("Governed activation hard-blocked: occupant is not GOVERNED_READY (verified protocol uptake required). Launch/provisioning may proceed, but the occupant must not begin governed work until a governed-context proof verifies.");
|
|
1145
|
+
}
|
|
846
1146
|
return {
|
|
847
1147
|
roleSlot: slot.roleSlot,
|
|
848
1148
|
harness,
|
|
849
1149
|
status,
|
|
850
1150
|
launchAllowed,
|
|
851
1151
|
activationAllowed,
|
|
1152
|
+
governedActivationBlocked: governedOccupant && governed.state !== "GOVERNED_READY",
|
|
852
1153
|
classifications: uniqueClassifications,
|
|
853
1154
|
safeOutcomes: status === "PASS" ? [] : defaultSafeOutcomes(),
|
|
854
1155
|
scpContextSources: sources,
|
|
855
1156
|
mcpVersion: slot.mcpVersion,
|
|
856
1157
|
deferredMcpHydration: slot.canHydrateDeferredMcpToolsAtBoot === true ? "AT_BOOT" : slot.canHydrateDeferredMcpToolsAfterSecondTurn === true ? "AFTER_SECOND_TURN" : "UNPROVEN",
|
|
857
1158
|
nativeSkillInvocation: slot.nativeSkillInvocation === true,
|
|
1159
|
+
governedReadiness: governed.state,
|
|
1160
|
+
governedReadinessNextAction: governed.nextSafeAction,
|
|
858
1161
|
notes
|
|
859
1162
|
};
|
|
860
1163
|
});
|
|
861
1164
|
const overallStatus = hasSlots && rows.every((row) => row.launchAllowed) && cmuxAvailable && execPmAuthorized ? "PASS" : "FAIL";
|
|
862
1165
|
const activationStatus = hasSlots && rows.every((row) => row.activationAllowed) && cmuxAvailable && execPmAuthorized ? "TEAM_ACTIVATION_ALLOWED" : "TEAM_ACTIVATION_BLOCKED";
|
|
1166
|
+
const governedReadinessStatus = hasSlots && rows.every((row) => row.governedReadiness === "GOVERNED_READY") ? "ALL_GOVERNED_READY" : "GOVERNED_READINESS_INCOMPLETE";
|
|
863
1167
|
return {
|
|
864
1168
|
version: VERSION,
|
|
865
1169
|
minimumMcpVersion: minimum,
|
|
866
1170
|
phases: GOVERNED_LAUNCH_PHASES,
|
|
867
1171
|
overallStatus,
|
|
868
1172
|
activationStatus,
|
|
1173
|
+
governedReadinessStatus,
|
|
1174
|
+
governedActivationBlockedCount: rows.filter((row) => row.governedActivationBlocked).length,
|
|
1175
|
+
governedReadinessModel: GOVERNED_READINESS_MODEL,
|
|
869
1176
|
execPmAuthorized,
|
|
870
1177
|
cmuxAvailable,
|
|
871
1178
|
userPrompt: "Are all intended harnesses provisioned with accounts, plans, API keys, or local inference credentials so they will not malfunction when spun up?",
|
|
@@ -999,7 +1306,25 @@ export function getHarnessProbeMatrix(input = {}) {
|
|
|
999
1306
|
"unknown";
|
|
1000
1307
|
const advisoryClassifications = new Set(["USER_INPUT_REQUIRED", "NON_GOVERNED_ONE_SHOT_ONLY"]);
|
|
1001
1308
|
const blockingClassifications = [...classifications].filter((item) => !advisoryClassifications.has(item));
|
|
1002
|
-
|
|
1309
|
+
// Fail-closed governed readiness: presence (mcp_configured / skill on disk) is necessary
|
|
1310
|
+
// context but never sufficient. GOVERNED_READY requires a verified governed-context uptake
|
|
1311
|
+
// proof at adequate assurance, plus no blocking classification, auth, or liveness issue.
|
|
1312
|
+
const authBlockerList = ["BLOCKED_BY_API_KEY", "AUTH_PROVIDER_BLOCKED", "BLOCKED_BY_LOGIN", "BLOCKED_BY_AUTH"].filter((item) => classifications.has(item));
|
|
1313
|
+
const governed = classifyGovernedReadiness({
|
|
1314
|
+
harness,
|
|
1315
|
+
installed: installedBinary,
|
|
1316
|
+
authenticated,
|
|
1317
|
+
requestedRole: observation?.requestedRole,
|
|
1318
|
+
governedContextProof: observation?.governedContextProof,
|
|
1319
|
+
hooksAvailable: observation?.hooksAvailable,
|
|
1320
|
+
deliveryState: observation?.deliveryState,
|
|
1321
|
+
livenessState: observation?.livenessState,
|
|
1322
|
+
permissionBlocked: classifications.has("BLOCKED_BY_PERMISSION") || observation?.permissionBlocked === true,
|
|
1323
|
+
idleStalled: observation?.idleStalled,
|
|
1324
|
+
authBlockers: authBlockerList,
|
|
1325
|
+
otherBlockers: blockingClassifications.filter((item) => !authBlockerList.includes(item))
|
|
1326
|
+
});
|
|
1327
|
+
const governedRoleReady = governed.state === "GOVERNED_READY";
|
|
1003
1328
|
return {
|
|
1004
1329
|
harness,
|
|
1005
1330
|
installed: installedBinary,
|
|
@@ -1011,12 +1336,22 @@ export function getHarnessProbeMatrix(input = {}) {
|
|
|
1011
1336
|
nativeSkillInvocation: ["Codex", "Claude Code"].includes(harness),
|
|
1012
1337
|
sentinelCoordinationProtocolSkill: "install before governed launch when the harness supports native skills",
|
|
1013
1338
|
autoLevel: key === "droid" ? (observation?.autoLevel ?? "unknown") : observation?.autoLevel,
|
|
1339
|
+
governedReadiness: governed.state,
|
|
1340
|
+
governedContext: {
|
|
1341
|
+
category: governed.category,
|
|
1342
|
+
requiredAssurance: governed.requiredAssurance,
|
|
1343
|
+
proofAssurance: governed.proofAssurance,
|
|
1344
|
+
uptakeVerified: governed.uptakeVerified,
|
|
1345
|
+
blockers: governed.blockers
|
|
1346
|
+
},
|
|
1347
|
+
nextSafeAction: governed.nextSafeAction,
|
|
1014
1348
|
readiness: {
|
|
1015
1349
|
installed_binary: installedBinary,
|
|
1016
1350
|
authenticated,
|
|
1017
1351
|
mcp_configured: mcpConfigured,
|
|
1018
1352
|
mcp_management_available: mcpManagementAvailable ?? "unknown",
|
|
1019
1353
|
mcp_tool_hydration: canHydrateAtBoot ? "AT_BOOT" : "AFTER_SECOND_TURN",
|
|
1354
|
+
governed_context_uptake_verified: governed.uptakeVerified,
|
|
1020
1355
|
governed_role_ready: governedRoleReady
|
|
1021
1356
|
},
|
|
1022
1357
|
safeNextActions: classifications.size === 0 ? [] : defaultSafeOutcomes(),
|
|
@@ -1028,6 +1363,9 @@ export function getHarnessProbeMatrix(input = {}) {
|
|
|
1028
1363
|
userPrompt: "Are all intended harnesses provisioned with accounts, plans, API keys, or local inference credentials so they will not malfunction when spun up?",
|
|
1029
1364
|
secretProviderStatuses: providerStatuses,
|
|
1030
1365
|
supportedProviders: ["Doppler", "1Password CLI (op)", "environment variable names", "direnv", "mise", "dotenv-style file presence", "GitHub auth", "local provider config files"],
|
|
1366
|
+
governedReadinessModel: GOVERNED_READINESS_MODEL,
|
|
1367
|
+
governedContextNote: "Presence is not authority. MCP being configured or an SCP skill existing on disk does not make a harness GOVERNED_READY; protocol uptake must be verified. Each row's governedReadiness is one of GOVERNED_READY, FIXABLE_BLOCKED, NON_GOVERNED_ONE_SHOT_ONLY, or UNSUPPORTED.",
|
|
1368
|
+
governedContextVerifier: "scripts/protocol/verify-governed-context.mjs",
|
|
1031
1369
|
rows
|
|
1032
1370
|
};
|
|
1033
1371
|
}
|
|
@@ -1038,7 +1376,7 @@ const ONBOARDING_NO_SECRETS_NOTICE = "Do not paste API keys, tokens, OAuth value
|
|
|
1038
1376
|
function onboardingGuidedSteps() {
|
|
1039
1377
|
return [
|
|
1040
1378
|
"Confirm Node.js >= 22.13.0 and the installed @bradheitmann/odin-sentinel package version.",
|
|
1041
|
-
"Prefer the pinned pnpm command (pnpm dlx --package @bradheitmann/odin-sentinel@0.4.
|
|
1379
|
+
"Prefer the pinned pnpm command (pnpm dlx --package @bradheitmann/odin-sentinel@0.4.11 odin-sentinel-mcp); npm global install and npx are supported when pinned to the same release.",
|
|
1042
1380
|
"Add the odin-sentinel-mcp stdio command to each selected harness MCP config and restart the harness.",
|
|
1043
1381
|
"Provide SCP context: install the native sentinel-coordination-protocol skill where supported, otherwise inject full protocol text via odin.get_bootstrap_skill, or export a snapshot via odin.export_protocol_snapshot for non-MCP clients.",
|
|
1044
1382
|
"Deploy the activation hooks with `node scripts/protocol/install-activation-hooks.mjs` so the full-instruction-read precheck runs before governed edits.",
|
|
@@ -1077,10 +1415,14 @@ export function getOnboardingPlan(input = {}) {
|
|
|
1077
1415
|
const classifications = Array.isArray(row.classifications) ? row.classifications : [];
|
|
1078
1416
|
const blockers = classifications.filter((item) => !ONBOARDING_ADVISORY_CLASSIFICATIONS.has(item));
|
|
1079
1417
|
const readiness = asRecord(row.readiness);
|
|
1418
|
+
const governedReadiness = typeof row.governedReadiness === "string" ? row.governedReadiness : "FIXABLE_BLOCKED";
|
|
1080
1419
|
return {
|
|
1081
1420
|
harness: row.harness,
|
|
1082
1421
|
installed: row.installed === true,
|
|
1083
|
-
|
|
1422
|
+
governedReadiness,
|
|
1423
|
+
governedRoleReady: governedReadiness === "GOVERNED_READY",
|
|
1424
|
+
governedContext: row.governedContext,
|
|
1425
|
+
governedNextSafeAction: row.nextSafeAction,
|
|
1084
1426
|
classifications,
|
|
1085
1427
|
blockers,
|
|
1086
1428
|
readiness,
|
|
@@ -1093,11 +1435,16 @@ export function getOnboardingPlan(input = {}) {
|
|
|
1093
1435
|
});
|
|
1094
1436
|
const blockerSummary = readinessRows
|
|
1095
1437
|
.filter((row) => row.blockers.length > 0)
|
|
1096
|
-
.map((row) => ({ harness: row.harness, blockers: row.blockers, governedRoleReady: row.governedRoleReady }));
|
|
1438
|
+
.map((row) => ({ harness: row.harness, blockers: row.blockers, governedReadiness: row.governedReadiness, governedRoleReady: row.governedRoleReady }));
|
|
1097
1439
|
const classifications = [...new Set(readinessRows.flatMap((row) => row.classifications))].sort();
|
|
1098
1440
|
const governedReadyHarnesses = readinessRows.filter((row) => row.governedRoleReady).map((row) => row.harness);
|
|
1441
|
+
const governedReadinessByHarness = readinessRows.map((row) => ({ harness: row.harness, governedReadiness: row.governedReadiness, nextSafeAction: row.governedNextSafeAction }));
|
|
1099
1442
|
const computerUseAvailable = input.computerUseAvailable === true;
|
|
1100
|
-
|
|
1443
|
+
// Accept the prose alias "assisted_computer_use" as canonical "assisted". The alias is a
|
|
1444
|
+
// compatibility spelling only; it never bypasses computerUseAvailable (assistedEligible below
|
|
1445
|
+
// still gates assisted on it), so an alias request with computer use off stays guided.
|
|
1446
|
+
const preferredRaw = input.preferredSetupMode ?? "unset";
|
|
1447
|
+
const preferred = preferredRaw === "assisted_computer_use" ? "assisted" : preferredRaw;
|
|
1101
1448
|
// Assisted computer-use setup is offered only when a computer-use-capable harness is available.
|
|
1102
1449
|
const assistedEligible = computerUseAvailable;
|
|
1103
1450
|
let recommendedMode;
|
|
@@ -1174,6 +1521,8 @@ export function getOnboardingPlan(input = {}) {
|
|
|
1174
1521
|
blockerSummary,
|
|
1175
1522
|
unresolvedBlockerCount: blockerSummary.length,
|
|
1176
1523
|
governedReadyHarnesses,
|
|
1524
|
+
governedReadinessByHarness,
|
|
1525
|
+
governedReadinessModel: GOVERNED_READINESS_MODEL,
|
|
1177
1526
|
ledgerPath,
|
|
1178
1527
|
ledgerNote: "This onboarding plan only reports where ODIN-owned artifacts will be tracked. It does not create, write, validate, or delete the install ledger or any harness config file; ledger-aware install behavior is a separate step.",
|
|
1179
1528
|
nextUserAction
|
|
@@ -1244,10 +1593,13 @@ export function createProtocolService(repository = createFileProtocolRepository(
|
|
|
1244
1593
|
getActiveWatchPacket,
|
|
1245
1594
|
getHarnessProbeMatrix,
|
|
1246
1595
|
getOnboardingPlan,
|
|
1596
|
+
classifyGovernedReadiness,
|
|
1597
|
+
harnessCategory,
|
|
1247
1598
|
getDelegationPacket,
|
|
1248
1599
|
getActivationGates,
|
|
1249
1600
|
validateCmuxDeliveryProof: (proof) => validateCmuxDeliveryProof(proof),
|
|
1250
1601
|
validateInstructionReadProof: (proof) => validateInstructionReadProof(proof),
|
|
1602
|
+
validateGovernedContextProof: (proof) => validateGovernedContextProof(proof),
|
|
1251
1603
|
validateDelegationPacket: (packet) => validateDelegationPacket(packet, repository),
|
|
1252
1604
|
validateBootReceipt: (receipt) => validateBootReceipt(receipt, repository),
|
|
1253
1605
|
validateTeamManifest: (manifest) => validateTeamManifest(manifest, repository),
|