Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@ jobs:
test -f docs/ops/persistent-storage.md

- name: Compile Python SDK
run: python -m py_compile sdk/python/client.py
run: python -m py_compile sdk/python/client.py sdk/python/agt.py

- name: Test Python SDK
run: python -m unittest discover -s sdk/python -p 'test_*.py' -v

- name: Check PCTR/AGT cross-language parity
run: node scripts/check-agt-parity.mjs

- name: Build TypeScript SDK
run: npm run build:sdk
Expand Down
3 changes: 2 additions & 1 deletion docs/ecosystem-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ Use TTP as the behavioral evidence layer in a closed loop:
Key patterns:
- OPA/Rego bridge (`input.ttp` claims)
- SPIFFE/SVID identity compatibility
- Canonical score adapter: `agt_trust_score = round(ttp_score * 1000)`
- Trust score mapping: AGT's `TrustScore` is **0-1 with tiers** (untrusted 0.0 / provisional 0.30 / trusted 0.60 / verified 0.85), so a TTP score maps across unscaled. The 0-1000 integer scale is only for downstream components that ask for it.
- Execution rings: map consequence severity onto AGT's `ExecutionRing` rather than building a parallel privilege model
- AgentMesh trust attestation bridge

See full details in [integration-guide.md#part-6-agt-native-integration-recommended-priority](integration-guide.md#part-6-agt-native-integration-recommended-priority).
Expand Down
39 changes: 34 additions & 5 deletions docs/integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,18 +404,47 @@ Benefits:
- Immediate compatibility with SPIFFE-based workload identity.
- TTP augments SPIFFE identity with behavioral trust.

### 6.4 Canonical Score Adapter: TTP -> AGT
### 6.4 Trust Score Mapping: TTP -> AGT

When downstream AGT components expect a 0-1000 trust scale, use the canonical mapping:
**AGT scores trust on 0-1, not 0-1000.** Upstream
[`microsoft/agent-governance-toolkit`](https://github.com/microsoft/agent-governance-toolkit)
defines `TrustScore` as `{ overall, dimensions, tier }` with `overall` in `[0.0, 1.0]`,
banded into tiers (`agent-governance-typescript/src/trust.ts`):

| Tier | Threshold |
| --- | --- |
| `Untrusted` | 0.0 |
| `Provisional` | 0.30 |
| `Trusted` | 0.60 |
| `Verified` | 0.85 |

A TTP score is already `[0.0, 1.0]`, so it maps across **unscaled**:

```text
agt_trust_score = round(ttp_score * 1000)
trust_score = { overall: ttp_score, dimensions: {...}, tier: tier_for(ttp_score) }
```

Reference adapter behavior:
- Input: `ttp_score` in `[0.0, 1.0]`.
- Output: integer `agt_trust_score` in `[0, 1000]`.
- Preserve original `ttp_score` in logs/telemetry for auditability.
- Output: an AGT `TrustScore` whose `overall` is the same number and whose `tier` comes
from the thresholds above.
- Preserve the original `ttp_score` and the issuing evidence in logs/telemetry for
auditability.

> **Earlier versions of this guide specified `agt_trust_score = round(ttp_score * 1000)`.
> That is wrong against upstream AGT** — sending `918` where AGT expects `0.918` puts
> every agent off the top of the scale and reads as `Verified`. The 0-1000 integer scale
> applies only to downstream components that explicitly ask for it; it is not the
> AGT-native path. Implementations: `toAgtTrustScore()` / `toAgtScore()` in
> `packages/pctr/src/agt.mjs`, `to_agt_trust_score()` / `to_agt_score()` in
> `sdk/python/agt.py`.

### 6.4.1 Execution Rings

AGT's `ExecutionRing` (Ring0 most privileged through Ring3) is the privilege construct to
map into rather than duplicate. PCTR proposes a required ring from the consequence an
action can cause — `CRITICAL -> Ring0`, `HIGH -> Ring1`, `MEDIUM -> Ring2`, `LOW -> Ring3`
— and AGT's own `actionRings` configuration stays authoritative.

### 6.5 AgentMesh / Peer Trust Attestation Bridge

Expand Down
5 changes: 5 additions & 0 deletions packages/pctr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,11 @@ ran.

Worked end to end: `npm run demo:pctr-agt`.

**Python.** AGT is Python-first, so the bridge exists there too — `sdk/python/agt.py`,
same functions, same behaviour. `scripts/check-agt-parity.mjs` runs both implementations
over one corpus in CI and fails the build on any divergence, so the bindings cannot drift
apart.

## Relationship to TTP

PCTR answers *which path through multiple agents is trustworthy enough to reach this
Expand Down
115 changes: 115 additions & 0 deletions scripts/check-agt-parity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env node
// The PCTR <-> AGT bridge exists twice: packages/pctr/src/agt.mjs for JavaScript and
// sdk/python/agt.py for Python, because AGT is Python-first. Two implementations agree
// only as long as something checks, so this runs both over the same inputs and fails on
// any divergence. Run: node scripts/check-agt-parity.mjs
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { classifyAction } from '../packages/pctr/src/consequences.mjs';
import { trustTier, toAgtScore, ringForSeverity, domainFor, normalizeAgtEvent } from '../packages/pctr/src/agt.mjs';

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');

// One shared corpus, exercised by both implementations.
const ACTIONS = [
['payments.status', {}], ['payments.transfer', {}], ['payments.transfer', { amount: 18000 }],
['customers.delete', {}], ['records.update', { recordsAffected: 5000 }], ['send_invoice_email', {}],
['reports.read', {}], ['cluster.deploy', {}], ['vault.read', {}], ['nothing.special', {}]
];
const SCORES = [0, 0.1, 0.29, 0.3, 0.6, 0.849, 0.85, 0.9178, 1];
const SEVERITIES = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', '?'];
const CONSEQUENCES = ['MONEY_MOVED', 'DATA_DELETED', 'SECRET_EXPOSED', 'SOMETHING_NEW'];
const EVENTS = [
['policy-allow', { allowed: true, action: 'allow', agentId: 'f', operation: 'reports.read', approvers: [], rateLimited: false }],
['policy-warn', { allowed: true, action: 'warn', agentId: 'f', operation: 'reports.read', approvers: [], rateLimited: false }],
['policy-deny', { allowed: false, action: 'deny', agentId: 'f', operation: 'payments.transfer', approvers: [], rateLimited: false }],
['policy-approval', { allowed: true, action: 'require_approval', agentId: 'f', operation: 'payments.transfer', approvers: ['ops'], rateLimited: false }],
['audit-review', { timestamp: 't', agentId: 'f', action: 'customers.delete', decision: 'review', hash: 'h1', previousHash: 'h0' }],
['audit-allow', { timestamp: 't', agentId: 'f', action: 'reports.read', decision: 'allow', hash: 'h1', previousHash: 'h0' }],
['cascade-quarantine', { eventId: 'e1', timestamp: 't', sourceAgentId: 'f', affectedAgentIds: ['a'], action: 'agent_quarantined', reason: 'b', blastRadius: 3 }],
['cascade-health', { eventId: 'e2', timestamp: 't', sourceAgentId: 'f', affectedAgentIds: [], action: 'health_propagated' }],
['ring-violation', { agentId: 'f', action: 'prod.deploy', agentRing: 2, requiredRing: 0, message: 'm' }],
['trust-verify', { verified: false, agentId: 'f', trustScore: { overall: 0.42, dimensions: {}, tier: 'Provisional' } }],
['invocation', { type: 'action.invocation', agentId: 'f', action: 'payments.transfer', parameters: { amount: 18000 } }],
['registration', { type: 'agent.registered', agentId: 'f', trustScore: { overall: 0.97, dimensions: {}, tier: 'Verified' } }],
['delegation', { type: 'task.delegated', agentId: 'f', to: 'g' }],
['junk', { type: 'telemetry.heartbeat', agentId: 'f' }],
['malformed', { nothing: 'useful' }]
];

const key = (score) => (Number.isInteger(score) ? String(score) : String(score));

function javascript() {
const out = { classify: {}, tiers: {}, scores: {}, rings: {}, domains: {}, events: {} };
for (const [action, params] of ACTIONS) {
const c = classifyAction(action, params);
out.classify[`${action}|${JSON.stringify(params)}`] = [c.consequence, c.severity, c.reversible];
}
for (const score of SCORES) { out.tiers[key(score)] = trustTier(score); out.scores[key(score)] = toAgtScore(score); }
for (const severity of SEVERITIES) out.rings[severity] = ringForSeverity(severity);
for (const consequence of CONSEQUENCES) out.domains[consequence] = domainFor(consequence);
for (const [name, event] of EVENTS) {
const r = normalizeAgtEvent(event);
out.events[name] = r ? [r.event, r.subject ?? null] : null;
}
return out;
}

const PY = `
import json, sys
sys.path.insert(0, ${JSON.stringify(path.join(root, 'sdk', 'python'))})
from agt import classify_action, trust_tier, to_agt_score, ring_for_severity, domain_for, normalize_agt_event

actions = json.loads(${JSON.stringify(JSON.stringify(ACTIONS))})
scores = json.loads(${JSON.stringify(JSON.stringify(SCORES))})
severities = json.loads(${JSON.stringify(JSON.stringify(SEVERITIES))})
consequences = json.loads(${JSON.stringify(JSON.stringify(CONSEQUENCES))})
events = json.loads(${JSON.stringify(JSON.stringify(EVENTS))})

out = {'classify': {}, 'tiers': {}, 'scores': {}, 'rings': {}, 'domains': {}, 'events': {}}
for action, params in actions:
c = classify_action(action, params)
out['classify'][action + '|' + json.dumps(params, separators=(',', ':'))] = [c['consequence'], c['severity'], c['reversible']]
for score in scores:
k = str(int(score)) if float(score).is_integer() else str(score)
out['tiers'][k] = trust_tier(score)
out['scores'][k] = to_agt_score(score)
for severity in severities:
out['rings'][severity] = ring_for_severity(severity)
for consequence in consequences:
out['domains'][consequence] = domain_for(consequence)
for name, event in events:
r = normalize_agt_event(event)
out['events'][name] = [r['event'], r.get('subject')] if r else None
print(json.dumps(out))
`;

function python() {
const stdout = execFileSync('python3', ['-c', PY], { encoding: 'utf8', cwd: root });
return JSON.parse(stdout);
}

const js = javascript();
const py = python();

const differences = [];
let checks = 0;
for (const section of Object.keys(js)) {
for (const [name, value] of Object.entries(js[section])) {
checks++;
const other = py[section]?.[name];
if (JSON.stringify(value) !== JSON.stringify(other)) {
differences.push(` ${section}.${name}\n js: ${JSON.stringify(value)}\n python: ${JSON.stringify(other)}`);
}
}
}

if (differences.length) {
console.error(`PCTR/AGT bridge parity FAILED — ${differences.length} of ${checks} checks differ:\n`);
console.error(differences.join('\n'));
console.error('\npackages/pctr/src/agt.mjs and sdk/python/agt.py must agree. Fix both.');
process.exit(1);
}
console.log(`PCTR/AGT bridge parity OK — JavaScript and Python agree across ${checks} checks.`);
30 changes: 30 additions & 0 deletions sdk/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,33 @@ print(resp.decision.value, resp.mode.value, resp.receipt.receiptId)
- Enforce both `decision` and `mode`.
- Treat missing receipt as deny.
- Persist receipt integrity values for audit traceability.

## AGT bridge (`agt.py`)

PCTR <-> [Microsoft AGT](https://github.com/microsoft/agent-governance-toolkit) for
Python, mirroring `packages/pctr/src/agt.mjs`. AGT is Python-first, so this is where
most real integrations sit.

```python
from agt import agt_claims, normalize_agt_event, to_trust_evidence, to_agt_trust_score

# 1. AGT's runtime events -> canonical PCTR events (None when unrecognised)
event = normalize_agt_event(agt_policy_decision)

# 2. What PCTR hands AGT's policy engine, under input.ttp
claims = agt_claims(ttp_score=0.9178, action="payments.transfer", issuer_count=2,
agents=["spiffe://blocksifr.com/ns/prod/sa/finance"])

# 3. Evidence back, so AGT decides better next time
evidence = to_trust_evidence(receipt)
```

**AGT trust is 0-1**, banded untrusted 0.0 / provisional 0.30 / trusted 0.60 /
verified 0.85 — `to_agt_trust_score()` returns AGT's `TrustScore {overall, dimensions,
tier}`. The 0-1000 integer scale (`to_agt_score()`) is only for downstream consumers that
ask for it.

The two implementations are held in step by `scripts/check-agt-parity.mjs`, which runs
both over the same corpus in CI and fails on any divergence.

Tests: `python3 -m unittest discover -s sdk/python -p 'test_*.py'`
26 changes: 26 additions & 0 deletions sdk/python/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
from .agt import (
AGT_TIER_THRESHOLDS,
TrustScore,
agt_claims,
classify_action,
domain_for,
normalize_agt_event,
parse_spiffe_id,
ring_for_severity,
to_agt_trust_score,
to_mesh_attestation,
to_trust_evidence,
trust_tier,
)
from .client import (
AuthorityGrant,
AuthorizeRequest,
Expand All @@ -11,6 +25,7 @@
)

__all__ = [
"AGT_TIER_THRESHOLDS",
"AuthorityGrant",
"AuthorizeRequest",
"AuthorizeResponse",
Expand All @@ -19,5 +34,16 @@
"Principal",
"Receipt",
"Resource",
"TrustScore",
"agt_claims",
"authorize",
"classify_action",
"domain_for",
"normalize_agt_event",
"parse_spiffe_id",
"ring_for_severity",
"to_agt_trust_score",
"to_mesh_attestation",
"to_trust_evidence",
"trust_tier",
]
Loading
Loading