diff --git a/contracts/examples/applied-fix.valid.json b/contracts/examples/applied-fix.valid.json new file mode 100644 index 0000000..b3ff703 --- /dev/null +++ b/contracts/examples/applied-fix.valid.json @@ -0,0 +1,17 @@ +{ + "id": "urn:srcos:workstation-action-boundary:applied-fix-shell-20260718", + "specVersion": "0.1.0", + "artifact_kind": "applied_fix", + "requested_action": "fix shell", + "mutation_planned": true, + "mutation_performed": true, + "performed_by": "sourceos-cli/mdheller", + "policy_decision_refs": ["urn:srcos:policy-decision:fix-shell-admission-20260718"], + "before_refs": ["urn:srcos:evidence:shell-config-before-20260718"], + "after_refs": ["urn:srcos:evidence:shell-config-after-20260718"], + "evidence_refs": ["urn:srcos:evidence:doctor-report-20260718"], + "reversible": true, + "rollback_refs": ["urn:srcos:rollback:fix-shell-rollback-20260718"], + "sensitive_data_excluded": true, + "downstream_consumers": ["agentplane", "sociosphere", "prophet-cli"] +} diff --git a/contracts/examples/doctor-report.INVALID-mutation-claimed.json b/contracts/examples/doctor-report.INVALID-mutation-claimed.json new file mode 100644 index 0000000..132b364 --- /dev/null +++ b/contracts/examples/doctor-report.INVALID-mutation-claimed.json @@ -0,0 +1,11 @@ +{ + "_invalid": "doctor_report must not claim mutation_performed=true", + "id": "urn:srcos:workstation-action-boundary:bad-doctor-20260718", + "specVersion": "0.1.0", + "artifact_kind": "doctor_report", + "requested_action": "doctor workstation-v0", + "mutation_planned": false, + "mutation_performed": true, + "evidence_refs": ["urn:srcos:evidence:doctor-sh-output-20260718"], + "sensitive_data_excluded": true +} diff --git a/contracts/examples/doctor-report.valid.json b/contracts/examples/doctor-report.valid.json new file mode 100644 index 0000000..436b489 --- /dev/null +++ b/contracts/examples/doctor-report.valid.json @@ -0,0 +1,11 @@ +{ + "id": "urn:srcos:workstation-action-boundary:doctor-report-workstation-v0-20260718", + "specVersion": "0.1.0", + "artifact_kind": "doctor_report", + "requested_action": "doctor workstation-v0", + "mutation_planned": false, + "mutation_performed": false, + "evidence_refs": ["urn:srcos:evidence:doctor-sh-output-20260718"], + "sensitive_data_excluded": true, + "downstream_consumers": ["agentplane", "sociosphere"] +} diff --git a/contracts/examples/fix-plan.INVALID-no-policy-refs.json b/contracts/examples/fix-plan.INVALID-no-policy-refs.json new file mode 100644 index 0000000..eb73c7d --- /dev/null +++ b/contracts/examples/fix-plan.INVALID-no-policy-refs.json @@ -0,0 +1,15 @@ +{ + "_invalid": "applied_fix without policy_decision_refs — forbidden", + "id": "urn:srcos:workstation-action-boundary:bad-fix-no-policy-20260718", + "specVersion": "0.1.0", + "artifact_kind": "applied_fix", + "requested_action": "fix shell", + "mutation_planned": true, + "mutation_performed": true, + "performed_by": "some-agent", + "before_refs": ["urn:srcos:evidence:before"], + "after_refs": ["urn:srcos:evidence:after"], + "policy_decision_refs": [], + "evidence_refs": [], + "sensitive_data_excluded": true +} diff --git a/contracts/validate-workstation-boundary.py b/contracts/validate-workstation-boundary.py new file mode 100644 index 0000000..cb62f46 --- /dev/null +++ b/contracts/validate-workstation-boundary.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Validate WorkstationActionBoundary records against the lifecycle-boundary contract. + +Usage: + python3 contracts/validate-workstation-boundary.py [ ...] + +Exits 0 if all records pass. Exits 1 if any fail. +""" + +import json +import sys + +MUTATION_FREE_KINDS = {"doctor_report", "fix_plan"} +MUTATION_REQUIRED_KINDS = {"applied_fix"} +POLICY_REQUIRED_KINDS = {"applied_fix", "admission_decision"} + + +def validate(path): + errors = [] + try: + with open(path) as f: + r = json.load(f) + except Exception as e: + return [f"parse error: {e}"] + + kind = r.get("artifact_kind", "") + mutation_performed = r.get("mutation_performed", False) + mutation_planned = r.get("mutation_planned", False) + + # doctor/report/fix-plan must never claim mutation_performed + if kind in MUTATION_FREE_KINDS and mutation_performed: + errors.append( + f"FAIL [{kind}]: mutation_performed=true is forbidden for {kind}. " + "A doctor/report/fix-plan artifact must not imply a mutation was executed." + ) + + # applied_fix must claim mutation_performed + if kind in MUTATION_REQUIRED_KINDS and not mutation_performed: + errors.append( + f"FAIL [{kind}]: mutation_performed=false for applied_fix. " + "An applied_fix record must confirm the mutation occurred." + ) + + # mutation requires policy refs + if mutation_performed and not r.get("policy_decision_refs"): + errors.append( + "FAIL: mutation_performed=true but policy_decision_refs is empty. " + "Every mutation requires an explicit policy/admission decision ref." + ) + + # mutation requires before/after evidence + if mutation_performed and not r.get("before_refs"): + errors.append("FAIL: mutation_performed=true but before_refs is empty.") + if mutation_performed and not r.get("after_refs"): + errors.append("FAIL: mutation_performed=true but after_refs is empty.") + + # mutation requires performed_by + if mutation_performed and not r.get("performed_by"): + errors.append("FAIL: mutation_performed=true but performed_by is missing.") + + # fix_plan without policy refs is fine — it's a plan, not a decision + # but fix_plan claiming mutation_planned=false is suspicious + if kind == "fix_plan" and not mutation_planned: + errors.append( + "WARN [fix_plan]: mutation_planned=false on a fix_plan. " + "A fix plan that proposes no mutation is likely a doctor_report." + ) + + # sensitive data must be excluded + if r.get("sensitive_data_excluded") is not True: + errors.append( + "FAIL: sensitive_data_excluded must be true. " + "Raw credentials, prompt content, and private keys must not appear in boundary records." + ) + + return errors + + +def main(): + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} [...]") + sys.exit(2) + + overall_pass = True + for path in sys.argv[1:]: + errors = validate(path) + if errors: + overall_pass = False + print(f"\n{path}: FAIL") + for e in errors: + print(f" {e}") + else: + print(f"{path}: OK") + + sys.exit(0 if overall_pass else 1) + + +if __name__ == "__main__": + main() diff --git a/contracts/workstation-action-boundary.schema.json b/contracts/workstation-action-boundary.schema.json new file mode 100644 index 0000000..7dffb58 --- /dev/null +++ b/contracts/workstation-action-boundary.schema.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:srcos:source-os:schema:workstation-action-boundary:v0.1.0", + "title": "WorkstationActionBoundary", + "description": "Records the boundary between observation, planning, policy, and mutation in SourceOS workstation flows. Prevents doctor/report artifacts from implying mutations were performed, and prevents fix plans from implying policy admission.", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "specVersion", + "artifact_kind", + "requested_action", + "mutation_planned", + "mutation_performed", + "evidence_refs" + ], + "properties": { + "id": { + "type": "string", + "description": "URN of this boundary record.", + "pattern": "^urn:srcos:workstation-action-boundary:.+" + }, + "specVersion": { + "type": "string", + "const": "0.1.0" + }, + "artifact_kind": { + "type": "string", + "description": "Which stage of the workstation flow produced this record.", + "enum": [ + "doctor_report", + "fix_plan", + "admission_decision", + "applied_fix", + "post_fix_report" + ] + }, + "evidence_refs": { + "type": "array", + "description": "URNs of evidence artifacts this record is based on (observations, probes, receipts).", + "items": { "type": "string" } + }, + "policy_decision_refs": { + "type": "array", + "description": "URNs of policy/admission decisions that authorized any mutation. Required when mutation_performed is true.", + "items": { "type": "string" } + }, + "requested_action": { + "type": "string", + "description": "The action that was requested (e.g., fix-shell, apply-profile, re-index)." + }, + "mutation_planned": { + "type": "boolean", + "description": "Whether a mutation was proposed in this artifact (true for fix_plan, admission_decision, applied_fix)." + }, + "mutation_performed": { + "type": "boolean", + "description": "Whether a mutation was actually executed. Must be false for doctor_report and fix_plan." + }, + "performed_by": { + "type": "string", + "description": "Identity of the actor that performed the mutation. Required when mutation_performed is true." + }, + "before_refs": { + "type": "array", + "description": "URNs of evidence capturing system state before mutation. Required when mutation_performed is true.", + "items": { "type": "string" } + }, + "after_refs": { + "type": "array", + "description": "URNs of evidence capturing system state after mutation. Required when mutation_performed is true.", + "items": { "type": "string" } + }, + "reversible": { + "type": "boolean", + "description": "Whether this mutation can be rolled back." + }, + "rollback_refs": { + "type": "array", + "description": "URNs of rollback artifacts or procedures if reversible is true.", + "items": { "type": "string" } + }, + "downstream_consumers": { + "type": "array", + "description": "Repos/services that consume this artifact (e.g., agentplane, sociosphere, prophet-cli). These are consumers, not implicit authorities.", + "items": { "type": "string" } + }, + "sensitive_data_excluded": { + "type": "boolean", + "description": "Confirms that raw sensitive local data (credentials, raw prompt content, private keys) is not included in this record.", + "const": true + } + }, + "if": { + "properties": { "mutation_performed": { "const": true } } + }, + "then": { + "required": ["policy_decision_refs", "performed_by", "before_refs", "after_refs"] + } +} diff --git a/workstation/bin/sourceos b/workstation/bin/sourceos index 267ee47..cd16609 100755 --- a/workstation/bin/sourceos +++ b/workstation/bin/sourceos @@ -15,6 +15,8 @@ Usage: sourceos profile list sourceos profile apply sourceos doctor [] + sourceos index status [--json] + sourceos index explain --file Notes: - This CLI is the implementation surface. Design/RFCs live in SociOS-Linux/enhancements. @@ -50,6 +52,50 @@ doctor() { exec "$doc" } +index_status() { + local json_mode=0 + [[ "${1:-}" == "--json" ]] && json_mode=1 + + local queue_depth=0 + local generation="stub-v0" + local failed_extractors=0 + local paused_lanes=0 + local policy_denied_count=0 + local status="stub" + local note="sourceos-indexd is not yet running. This is the stub surface (issue #162)." + + if [[ "$json_mode" -eq 1 ]]; then + printf '{"status":"%s","generation":"%s","queue_depth":%d,"failed_extractors":%d,"paused_lanes":%d,"policy_denied_count":%d,"note":"%s"}\n' \ + "$status" "$generation" "$queue_depth" "$failed_extractors" "$paused_lanes" "$policy_denied_count" "$note" + else + printf "SourceOS Index Status\n" + printf " status: %s\n" "$status" + printf " generation: %s\n" "$generation" + printf " queue_depth: %d\n" "$queue_depth" + printf " failed_extractors: %d\n" "$failed_extractors" + printf " paused_lanes: %d\n" "$paused_lanes" + printf " policy_denied_count: %d\n" "$policy_denied_count" + printf " note: %s\n" "$note" + fi +} + +index_explain() { + local file="" + while [[ $# -gt 0 ]]; do + case "$1" in + --file) shift; file="${1:-}"; shift ;; + *) err "unknown option: $1"; exit 2 ;; + esac + done + [[ -n "$file" ]] || { err "--file required"; exit 2; } + + local abs_path + abs_path="$(realpath "$file" 2>/dev/null || echo "$file")" + + printf '{"file":"%s","last_observed_event":"never","path_policy":"unknown","extractor_identity":"stub","receipt_status":"absent","generation_membership":"none","semantic_indexing_enabled":false,"cloud_exposure_enabled":false,"note":"sourceos-indexd stub (issue #162). Full explain requires indexd daemon."}\n' \ + "$abs_path" +} + main() { local cmd=${1:-} case "$cmd" in @@ -78,6 +124,25 @@ main() { shift doctor "${1:-workstation-v0}" ;; + index) + shift + local sub=${1:-} + case "$sub" in + status) + shift + index_status "${@}" + ;; + explain) + shift + index_explain "${@}" + ;; + *) + err "unknown index subcommand: $sub" + usage + exit 2 + ;; + esac + ;; *) err "unknown command: $cmd" usage