Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 0 additions & 75 deletions .github/workflows/repair-pr54-distinct-membership-groups.yml

This file was deleted.

1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang

### Added

- `tepp_api` contextual-orchestrator HTTPS interpretation interchange: credential-free `POST /v1/interpretation-runs`, table-access/non-https refusal, repository-write/review-agent secret refusal, and explicit denial that orchestrator output is scientific acceptance.
- `membership_core` estimation rows: one document emits every active membership at an event time, recovered weights are scored with computed RMSE, and collapsing a known multiple-membership set into a single independent row is refused (atomistic fallacy).
- `persistence_postgres` typed membership assignment (migration `0006`): `entity_record`, `project_record`, and `text_segment` plus exactly-one observed-unit and target constraints that replace the polymorphic `membership_target_id` stub, with SQL insert/lookup, fail-closed inverted-window and backslash-label refusal, and live proof that one document persists two entity memberships and one project membership.
- Actions workflow fleet auditor (`scripts/actions_workflow_fleet.py`): paginated registry inventory bound to the exact default-branch SHA/tree, classification of present/orphan/disabled/GitHub-dynamic identities, and fail-closed orphan disable that confirms GitHub's official `disabled_manually` state.
Expand Down
1 change: 1 addition & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin
| Hourly NIM product-development operations | [`docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md`](docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md) |
| Actions workflow fleet audit | [`docs/operations/ACTIONS_WORKFLOW_FLEET.md`](docs/operations/ACTIONS_WORKFLOW_FLEET.md) |
| Actions fleet research doctoring | [`docs/research/actions-workflow-fleet.md`](docs/research/actions-workflow-fleet.md) |
| Orchestrator HTTPS interchange doctoring | [`docs/research/orchestrator-http-interchange.md`](docs/research/orchestrator-http-interchange.md) |
| Membership estimation-row doctoring | [`docs/research/membership-estimation-rows.md`](docs/research/membership-estimation-rows.md) |
| Hourly NIM OpenCode doctoring | [`docs/doctoring/hourly-nim-opencode-development.md`](docs/doctoring/hourly-nim-opencode-development.md) |
| Change history | [`CHANGELOG.md`](CHANGELOG.md) |
Expand Down
11 changes: 9 additions & 2 deletions crates/membership_core/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ impl MembershipNetwork {
self.assignments
.iter()
.copied()
.filter(|assignment| assignment.member_id() == member_id && assignment.is_active_at(instant))
.filter(|assignment| {
assignment.member_id() == member_id && assignment.is_active_at(instant)
})
.collect()
}

Expand Down Expand Up @@ -133,7 +135,12 @@ impl EstimationMembershipRow {
/// Copy the scientifically relevant fields from one assignment.
#[must_use]
pub fn from_assignment(assignment: MembershipAssignment) -> Self {
Self { member_id: assignment.member_id(), group_id: assignment.group_id(), role: assignment.role(), weight: assignment.weight().value() }
Self {
member_id: assignment.member_id(),
group_id: assignment.group_id(),
role: assignment.role(),
weight: assignment.weight().value(),
}
}

/// Member identity on this row.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,7 @@ fn event_time(value: &str) -> EventTime {
EventTime::parse_rfc3339(value).expect("event time")
}

fn assignment(
member: MemberId,
group: GroupId,
role: MembershipRole,
) -> MembershipAssignment {
fn assignment(member: MemberId, group: GroupId, role: MembershipRole) -> MembershipAssignment {
MembershipAssignment::new(
member,
group,
Expand Down
15 changes: 15 additions & 0 deletions crates/tepp_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod authorization;
mod envelope;
mod error;
mod export;
mod orchestrator_http;
mod wire;

/// Analysis-run contract version constant.
Expand Down Expand Up @@ -46,3 +47,17 @@ pub use authorization::ExportAuthorizationRequest;
pub use authorization::authorize_export;
/// Fail closed when an export decision is denied.
pub use authorization::require_export_allowed;
/// Maximum orchestrator JSON body size.
pub use orchestrator_http::MAX_ORCHESTRATOR_BODY_BYTES;
/// Maximum orchestrator idempotency-key size.
pub use orchestrator_http::MAX_ORCHESTRATOR_IDEMPOTENCY_KEY_BYTES;
/// Versioned interpretation-run path.
pub use orchestrator_http::ORCHESTRATOR_INTERPRETATION_PATH;
/// Credential-free orchestrator HTTPS exchange.
pub use orchestrator_http::OrchestratorHttpExchange;
/// Build an interpretation request for contextual-orchestrator.
pub use orchestrator_http::orchestrator_interpretation_exchange;
/// Orchestrator output is never scientific acceptance.
pub use orchestrator_http::refuse_orchestrator_as_scientific_acceptance;
/// Refuse repository-write or review-agent secret names.
pub use orchestrator_http::refuse_repository_write_secret;
233 changes: 233 additions & 0 deletions crates/tepp_api/src/orchestrator_http.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
//! Versioned HTTPS interchange for the contextual-orchestrator interpretation port.

use crate::ApiError;
use crate::wire::require_nonempty;
use serde_json::Value;

/// Versioned interpretation-run path on the orchestrator origin.
pub const ORCHESTRATOR_INTERPRETATION_PATH: &str = "/v1/interpretation-runs";
/// Maximum UTF-8 bytes accepted in one orchestrator JSON body.
pub const MAX_ORCHESTRATOR_BODY_BYTES: usize = 1_048_576;
/// Maximum UTF-8 bytes accepted in one idempotency-key header value.
pub const MAX_ORCHESTRATOR_IDEMPOTENCY_KEY_BYTES: usize = 256;

/// Fail-closed HTTPS POST that TEPP may send to contextual-orchestrator.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OrchestratorHttpExchange {
method: String,
target_url: String,
headers: Vec<(String, String)>,
body: String,
}

impl OrchestratorHttpExchange {
/// HTTP method; always `POST`.
#[must_use]
pub fn method(&self) -> &str {
&self.method
}

/// Absolute `https` target URL.
#[must_use]
pub fn target_url(&self) -> &str {
&self.target_url
}

/// Request headers; never includes credentials or review-agent tokens.
#[must_use]
pub fn headers(&self) -> &[(String, String)] {
&self.headers
}

/// JSON body; never includes repository-write secret names.
#[must_use]
pub fn body(&self) -> &str {
&self.body
}
}

/// Build a credential-free interpretation request for contextual-orchestrator.
///
/// The origin is a DNS host only. Table-access hosts and non-`https` schemes
/// fail closed. The body must be a bounded JSON object and cannot carry known
/// repository-write or review-agent secret names. The orchestrator does not
/// become scientific authority.
///
/// # Errors
///
/// Returns [`ApiError::InvalidWirePayload`] for an empty, unsafe, or oversized
/// idempotency key, malformed/non-object JSON, or an empty body. Returns
/// [`ApiError::LimitExceeded`] for body or idempotency-key size limits. Returns
/// [`ApiError::AuthorizationDenied`] for a hostile host or forbidden secret-name
/// channel.
pub fn orchestrator_interpretation_exchange(
origin_host: &str,
idempotency_key: &str,
body: &str,
) -> Result<OrchestratorHttpExchange, ApiError> {
require_safe_idempotency_key(idempotency_key)?;
require_bounded_json_object(body)?;
require_safe_https_host(origin_host)?;
Ok(OrchestratorHttpExchange {
method: "POST".into(),
target_url: format!("https://{origin_host}{ORCHESTRATOR_INTERPRETATION_PATH}"),
headers: vec![
("content-type".into(), "application/json".into()),
("tepp-consumer".into(), "contextual-orchestrator".into()),
("tepp-contract-version".into(), "1".into()),
("idempotency-key".into(), idempotency_key.into()),
],
body: body.into(),
})
}

/// Orchestrator output never replaces deterministic/statistical acceptance.
///
/// # Errors
///
/// Always returns [`ApiError::AuthorizationDenied`].
pub fn refuse_orchestrator_as_scientific_acceptance() -> Result<(), ApiError> {
Err(ApiError::AuthorizationDenied)
}

/// Refuse repository-write or review-agent secret names on this port.
///
/// `NVIDIA_NIM_API_KEY` is the only allowed model-credential name.
///
/// # Errors
///
/// Returns [`ApiError::InvalidWirePayload`] for an empty name and
/// [`ApiError::AuthorizationDenied`] for every name except the NVIDIA NIM key.
pub fn refuse_repository_write_secret(secret_name: &str) -> Result<(), ApiError> {
require_nonempty(secret_name)?;
let folded = normalize_secret_name(secret_name);
if folded == "nvidianimapikey" {
Ok(())
} else {
Err(ApiError::AuthorizationDenied)
}
}

fn require_safe_idempotency_key(value: &str) -> Result<(), ApiError> {
require_nonempty(value)?;
if value.len() > MAX_ORCHESTRATOR_IDEMPOTENCY_KEY_BYTES {
return Err(ApiError::LimitExceeded);
}
if value.chars().any(char::is_whitespace) || value.chars().any(char::is_control) {
return Err(ApiError::InvalidWirePayload);
}
Ok(())
}

fn require_bounded_json_object(body: &str) -> Result<(), ApiError> {
require_nonempty(body)?;
if body.len() > MAX_ORCHESTRATOR_BODY_BYTES {
return Err(ApiError::LimitExceeded);
}
let value: Value = serde_json::from_str(body).map_err(|_| ApiError::InvalidWirePayload)?;
if !value.is_object() {
return Err(ApiError::InvalidWirePayload);
}

let mut pending = vec![&value];
while let Some(current) = pending.pop() {
match current {
Value::Object(entries) => {
for (key, nested) in entries {
if is_forbidden_secret_name(key) {
return Err(ApiError::AuthorizationDenied);
}
pending.push(nested);
}
}
Value::Array(entries) => pending.extend(entries),
Value::String(text) => {
if is_forbidden_secret_name(text) {
return Err(ApiError::AuthorizationDenied);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) => {}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
}
}
Ok(())
}

fn normalize_secret_name(value: &str) -> String {
value
.chars()
.filter(char::is_ascii_alphanumeric)
.flat_map(char::to_lowercase)
.collect()
}

fn is_forbidden_secret_name(value: &str) -> bool {
let folded = normalize_secret_name(value);
if folded == "nvidianimapikey" {
return false;
}
folded == "githubtoken"
|| folded == "copilotgithubtoken"
|| folded == "reviewagentgithubtoken"
|| folded == "opencodegithubtoken"
|| (folded.contains("copilot") && folded.contains("token"))
|| (folded.contains("reviewagent") && folded.contains("token"))
|| (folded.starts_with("github") && folded.ends_with("token"))
}

fn require_safe_https_host(host: &str) -> Result<(), ApiError> {
if host.is_empty()
|| host.len() > 253
|| host.chars().any(|ch| {
ch.is_control()
|| ch.is_whitespace()
|| matches!(ch, '@' | '/' | '?' | '#' | '\'' | ';' | '\\')
})
{
return Err(ApiError::AuthorizationDenied);
}
let lowered = host.to_ascii_lowercase();
if ["postgres", "jdbc", "sql", "tables"]
.iter()
.any(|needle| lowered.contains(needle))
{
return Err(ApiError::AuthorizationDenied);
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::{
OrchestratorHttpExchange, refuse_repository_write_secret, require_safe_https_host,
};
use crate::ApiError;

#[test]
fn unknown_secret_names_and_accessors_are_covered() {
assert_eq!(
refuse_repository_write_secret("AWS_SECRET"),
Err(ApiError::AuthorizationDenied)
);
assert_eq!(
refuse_repository_write_secret(""),
Err(ApiError::InvalidWirePayload)
);
assert_eq!(require_safe_https_host("ok.example"), Ok(()));
let exchange = OrchestratorHttpExchange {
method: "POST".into(),
target_url: "https://ok.example/v1/interpretation-runs".into(),
headers: Vec::new(),
body: "{}".into(),
};
assert_eq!(exchange.method(), "POST");
assert!(exchange.headers().is_empty());
}

#[test]
fn bounded_json_walks_arrays_and_scalar_values() {
assert_eq!(
super::require_bounded_json_object(r#"{"items":[null,true,1,{"name":"ok"}]}"#),
Ok(())
);
}
}
Loading
Loading