@opnli/atl-devkit 0.1.0
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/README.md +143 -0
- package/package.json +24 -0
- package/src/audit-logger.js +147 -0
- package/src/consent-gate.js +280 -0
- package/src/index.js +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# @opnli/atl-devkit
|
|
2
|
+
|
|
3
|
+
**Add human consent to any AI agent in 3 lines.**
|
|
4
|
+
|
|
5
|
+
The Agent Trust Layer (ATL) DevKit gives developers a drop-in consent
|
|
6
|
+
gate for AI agents. When your agent tries to act on the world — read a
|
|
7
|
+
file, call an API, run a command — the ATL holds the action and asks the
|
|
8
|
+
human: **Allow or Block?**
|
|
9
|
+
|
|
10
|
+
The human's decision is final. The audit trail is tamper-evident.
|
|
11
|
+
There is no bypass.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
const { createConsentGate } = require('@opnli/atl-devkit');
|
|
16
|
+
const gate = createConsentGate({ onConsent: askTheHuman, timeout: 60000 });
|
|
17
|
+
myProxy.onMessage((msg, raw) => {
|
|
18
|
+
const result = gate.inspect(msg, raw, false);
|
|
19
|
+
if (result.action === 'forward') send(raw);
|
|
20
|
+
// 'hold' means consent requested. Wait for gate.resolve(holdId, 'allow'|'deny')
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
## Why This Exists
|
|
24
|
+
|
|
25
|
+
AI agents are getting powerful. They can read your files, search the
|
|
26
|
+
web, execute code, and call APIs — often without asking. The industry
|
|
27
|
+
is moving fast on capability. Nobody is moving on trust.
|
|
28
|
+
|
|
29
|
+
The ATL is the trust layer. It does not slow agents down. It puts the
|
|
30
|
+
human in control of what agents do. **Be the enabler for the timid.
|
|
31
|
+
Never be the barrier to the powerful.**
|
|
32
|
+
|
|
33
|
+
## The Three Realities of Trust
|
|
34
|
+
|
|
35
|
+
Every trust decision involves three realities:
|
|
36
|
+
|
|
37
|
+
1. **Identity** — Know who is acting. (CARD credentials)
|
|
38
|
+
2. **Consent** — Choose what is allowed. (This DevKit)
|
|
39
|
+
3. **Accountability** — Check what happened. (Audit log)
|
|
40
|
+
|
|
41
|
+
The DevKit delivers Consent and Accountability. Identity comes from
|
|
42
|
+
the Opn.li Trust Network (https://opn.li) via CARD credentials.
|
|
43
|
+
|
|
44
|
+
## Two Shield Levels
|
|
45
|
+
|
|
46
|
+
### Green Shield — Consent Before Execution
|
|
47
|
+
|
|
48
|
+
The agent requests permission to act. The action has **NOT happened
|
|
49
|
+
yet**. The human decides whether it happens. This requires the agent
|
|
50
|
+
platform to support pre-execution approval events.
|
|
51
|
+
|
|
52
|
+
Agent: "I want to run: npm install express"
|
|
53
|
+
-> ATL HOLDS the request
|
|
54
|
+
-> Human sees: "Your AI wants to run a shell command: npm install express"
|
|
55
|
+
-> Human clicks Allow -> command executes
|
|
56
|
+
-> Human clicks Block -> command never runs
|
|
57
|
+
|
|
58
|
+
### Yellow Shield — Consent Before Delivery
|
|
59
|
+
|
|
60
|
+
The agent executed an action, but the result has not been delivered to
|
|
61
|
+
the UI. The ATL detects the execution via a sequence gap in the event
|
|
62
|
+
stream and holds the result. The human decides whether to receive it.
|
|
63
|
+
|
|
64
|
+
This works on **any platform** that streams sequential agent events —
|
|
65
|
+
no platform modifications required.
|
|
66
|
+
|
|
67
|
+
Agent executes a tool (seqs 2-4 consumed internally)
|
|
68
|
+
-> ATL detects gap: seq jumped from 1 to 5
|
|
69
|
+
-> ATL HOLDS the result
|
|
70
|
+
-> Human sees: "Your AI executed an action. Allow the result?"
|
|
71
|
+
-> Human clicks Allow -> result delivered
|
|
72
|
+
-> Human clicks Block -> result dropped, agent notified
|
|
73
|
+
|
|
74
|
+
## API
|
|
75
|
+
|
|
76
|
+
### createConsentGate(options)
|
|
77
|
+
|
|
78
|
+
Create a consent gate for an agent message stream.
|
|
79
|
+
|
|
80
|
+
**Options:**
|
|
81
|
+
|
|
82
|
+
| Option | Type | Default | Description |
|
|
83
|
+
|--------|------|---------|-------------|
|
|
84
|
+
| onConsent | function | *required* | Called when consent is needed. Receives { holdId, runId, gapSize, shield, request }. |
|
|
85
|
+
| onAudit | function | null | Called after each decision with { holdId, runId, decision, shield, gapSize, eventCount }. |
|
|
86
|
+
| timeout | number | 60000 | Consent timeout in ms. Default deny after expiry. 0 to disable. |
|
|
87
|
+
| greenShield | boolean | false | Enable Green Shield. When true, Yellow Shield is disabled. |
|
|
88
|
+
|
|
89
|
+
**Returns:** { inspect, resolve, getState }
|
|
90
|
+
|
|
91
|
+
#### gate.inspect(msg, raw, isBinary)
|
|
92
|
+
|
|
93
|
+
Inspect a message from the agent platform. Call for every Gateway-to-Client message.
|
|
94
|
+
|
|
95
|
+
Returns { action } where action is:
|
|
96
|
+
- "forward" — Send to client normally
|
|
97
|
+
- "hold" — Consent requested. Do NOT send. Includes holdId.
|
|
98
|
+
- "drop" — Suppressed. Do NOT send.
|
|
99
|
+
- "buffer" — Added to existing hold. Do NOT send.
|
|
100
|
+
|
|
101
|
+
#### gate.resolve(holdId, decision)
|
|
102
|
+
|
|
103
|
+
Resolve a consent decision. decision is "allow", "deny", or "timeout".
|
|
104
|
+
|
|
105
|
+
Returns { decision, events[] } — on allow, events contains the held
|
|
106
|
+
messages to forward. On deny/timeout, events is empty.
|
|
107
|
+
|
|
108
|
+
### createAuditLogger(logPath)
|
|
109
|
+
|
|
110
|
+
Create a tamper-evident audit logger.
|
|
111
|
+
|
|
112
|
+
**Returns:** { writeEntry, verifyChain }
|
|
113
|
+
|
|
114
|
+
#### logger.writeEntry(opts)
|
|
115
|
+
|
|
116
|
+
Append a consent decision. opts: { holdId, runId, decision, shield, gapSize, eventCount }.
|
|
117
|
+
|
|
118
|
+
#### logger.verifyChain()
|
|
119
|
+
|
|
120
|
+
Verify hash chain integrity. Returns { valid, entries, brokenAt }.
|
|
121
|
+
|
|
122
|
+
## Design Principles
|
|
123
|
+
|
|
124
|
+
- **Non-invasive.** The ATL wraps agent platforms. It does not modify them.
|
|
125
|
+
- **Fail-closed.** No response = no permission. Connection drop = denied.
|
|
126
|
+
- **Platform-agnostic.** Works with any agent that streams sequential events.
|
|
127
|
+
- **Honest.** Yellow Shield is consent before delivery, not before execution. We say so.
|
|
128
|
+
- **Auditable.** Every decision is logged with a SHA-256 hash chain.
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
Apache-2.0 — Openly Personal Networks, Inc. (https://opn.li)
|
|
133
|
+
|
|
134
|
+
## The Agent Economy
|
|
135
|
+
|
|
136
|
+
*My data + Your AI + My control = Living Intelligence*
|
|
137
|
+
|
|
138
|
+
CROCbox is the reference implementation of the ATL. The DevKit is how
|
|
139
|
+
every developer brings trust to their own agents. Together, we are
|
|
140
|
+
building the Agent Economy — where humans control what AI does, not
|
|
141
|
+
the other way around.
|
|
142
|
+
|
|
143
|
+
Learn more at https://opn.li
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opnli/atl-devkit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Add human consent to any AI agent in 3 lines. The Agent Trust Layer DevKit.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"ai-agent",
|
|
8
|
+
"trust",
|
|
9
|
+
"consent",
|
|
10
|
+
"agent-trust-layer",
|
|
11
|
+
"human-in-the-loop",
|
|
12
|
+
"opnli",
|
|
13
|
+
"card"
|
|
14
|
+
],
|
|
15
|
+
"author": "Opn.li — Openly Personal Networks, Inc.",
|
|
16
|
+
"license": "Apache-2.0",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/opnli/agent-trust-layer"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18.0.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @opnli/atl-devkit — Audit Logger
|
|
3
|
+
*
|
|
4
|
+
* Append-only JSONL audit log with SHA-256 hash chain.
|
|
5
|
+
* Every consent decision is recorded with timestamp, action, target,
|
|
6
|
+
* result, reason, and a cryptographic link to the previous entry.
|
|
7
|
+
*
|
|
8
|
+
* Tamper-evident: modifying any entry breaks the hash chain.
|
|
9
|
+
* Honest disclosure: tamper-evident, not tamper-proof (INV-16).
|
|
10
|
+
*
|
|
11
|
+
* Extracted from CROCbox ws-proxy.js — proven in production with
|
|
12
|
+
* 100+ entries verified in live testing.
|
|
13
|
+
*
|
|
14
|
+
* @see OPN_ENG_CROC-E2E-Invariants_13MAR26_v2, INV-8, INV-16
|
|
15
|
+
*/
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create an audit logger that writes to the specified path.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} logPath — Absolute path to the JSONL audit log file.
|
|
26
|
+
* @returns {object} — { writeEntry, verifyChain }
|
|
27
|
+
*/
|
|
28
|
+
function createAuditLogger(logPath) {
|
|
29
|
+
// Ensure the directory exists
|
|
30
|
+
const dir = path.dirname(logPath);
|
|
31
|
+
if (!fs.existsSync(dir)) {
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Append a consent decision to the audit log.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} opts
|
|
39
|
+
* @param {string} opts.holdId — Unique identifier for this consent hold
|
|
40
|
+
* @param {string} opts.runId — Agent run identifier
|
|
41
|
+
* @param {string} opts.decision — 'allow' | 'deny' | 'timeout'
|
|
42
|
+
* @param {string} opts.shield — 'yellow' | 'green'
|
|
43
|
+
* @param {number} opts.gapSize — Seq-gap size (Yellow) or 0 (Green)
|
|
44
|
+
* @param {number} opts.eventCount — Number of held events
|
|
45
|
+
* @param {object} [opts.extra] — Additional fields to include
|
|
46
|
+
* @returns {object} — The written entry (with hash)
|
|
47
|
+
*/
|
|
48
|
+
function writeEntry(opts) {
|
|
49
|
+
const { holdId, runId, decision, shield, gapSize, eventCount, extra } = opts;
|
|
50
|
+
|
|
51
|
+
// Read last hash from file
|
|
52
|
+
let prevHash = 'genesis';
|
|
53
|
+
try {
|
|
54
|
+
const content = fs.readFileSync(logPath, 'utf8').trim();
|
|
55
|
+
if (content.length > 0) {
|
|
56
|
+
const lines = content.split('\n');
|
|
57
|
+
const lastEntry = JSON.parse(lines[lines.length - 1]);
|
|
58
|
+
prevHash = lastEntry.hash || 'genesis';
|
|
59
|
+
}
|
|
60
|
+
} catch (e) { /* file may not exist yet — genesis */ }
|
|
61
|
+
|
|
62
|
+
const reasonMap = {
|
|
63
|
+
'allow': 'user-consent',
|
|
64
|
+
'deny': 'user-deny',
|
|
65
|
+
'timeout': 'user-timeout'
|
|
66
|
+
};
|
|
67
|
+
const resultMap = {
|
|
68
|
+
'allow': 'allowed',
|
|
69
|
+
'deny': 'blocked',
|
|
70
|
+
'timeout': 'blocked'
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const entry = {
|
|
74
|
+
timestamp: new Date().toISOString(),
|
|
75
|
+
action: shield + '-shield',
|
|
76
|
+
target: 'agent-event-stream',
|
|
77
|
+
result: resultMap[decision] || 'blocked',
|
|
78
|
+
reason: reasonMap[decision] || 'unknown',
|
|
79
|
+
detail: 'gap=' + gapSize + ' events-held=' + eventCount + ' holdId=' + holdId,
|
|
80
|
+
decision_id: holdId,
|
|
81
|
+
shield: shield,
|
|
82
|
+
runId: runId,
|
|
83
|
+
prev_hash: prevHash,
|
|
84
|
+
...(extra || {})
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const entryStr = JSON.stringify(entry);
|
|
88
|
+
entry.hash = crypto.createHash('sha256').update(entryStr + prevHash).digest('hex');
|
|
89
|
+
|
|
90
|
+
fs.appendFileSync(logPath, JSON.stringify(entry) + '\n');
|
|
91
|
+
return entry;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Verify the hash chain integrity of the audit log.
|
|
96
|
+
*
|
|
97
|
+
* @returns {object} — { valid: boolean, entries: number, brokenAt: number|null }
|
|
98
|
+
*/
|
|
99
|
+
function verifyChain() {
|
|
100
|
+
let content;
|
|
101
|
+
try {
|
|
102
|
+
content = fs.readFileSync(logPath, 'utf8').trim();
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return { valid: true, entries: 0, brokenAt: null };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (content.length === 0) {
|
|
108
|
+
return { valid: true, entries: 0, brokenAt: null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const lines = content.split('\n');
|
|
112
|
+
let expectedPrevHash = 'genesis';
|
|
113
|
+
|
|
114
|
+
for (let i = 0; i < lines.length; i++) {
|
|
115
|
+
try {
|
|
116
|
+
const entry = JSON.parse(lines[i]);
|
|
117
|
+
|
|
118
|
+
// Check prev_hash links to previous entry
|
|
119
|
+
if (entry.prev_hash !== expectedPrevHash) {
|
|
120
|
+
return { valid: false, entries: lines.length, brokenAt: i };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Recompute hash: strip hash field, serialize, hash with prev_hash
|
|
124
|
+
const storedHash = entry.hash;
|
|
125
|
+
const stripped = { ...entry };
|
|
126
|
+
delete stripped.hash;
|
|
127
|
+
const recomputed = crypto.createHash('sha256')
|
|
128
|
+
.update(JSON.stringify(stripped) + expectedPrevHash)
|
|
129
|
+
.digest('hex');
|
|
130
|
+
|
|
131
|
+
if (storedHash !== recomputed) {
|
|
132
|
+
return { valid: false, entries: lines.length, brokenAt: i };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
expectedPrevHash = storedHash;
|
|
136
|
+
} catch (e) {
|
|
137
|
+
return { valid: false, entries: lines.length, brokenAt: i };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { valid: true, entries: lines.length, brokenAt: null };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { writeEntry, verifyChain };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { createAuditLogger };
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @opnli/atl-devkit — Consent Gate
|
|
3
|
+
*
|
|
4
|
+
* The core of the Agent Trust Layer. Inspects a stream of messages
|
|
5
|
+
* between an AI agent and its execution environment. When the agent
|
|
6
|
+
* acts on the world, the gate HOLDS the result and asks the human.
|
|
7
|
+
*
|
|
8
|
+
* Two shield levels:
|
|
9
|
+
*
|
|
10
|
+
* GREEN SHIELD (Consent Before Execution):
|
|
11
|
+
* The gate intercepts a pre-execution approval request. The action
|
|
12
|
+
* has NOT happened yet. The human decides whether it happens.
|
|
13
|
+
* Requires the agent platform to broadcast approval events.
|
|
14
|
+
*
|
|
15
|
+
* YELLOW SHIELD (Consent Before Delivery):
|
|
16
|
+
* The gate detects that an action executed via a sequence gap in
|
|
17
|
+
* the agent event stream. The action HAS happened, but the result
|
|
18
|
+
* has not been delivered. The human decides whether to receive it.
|
|
19
|
+
* Works on ANY platform that streams sequential agent events.
|
|
20
|
+
*
|
|
21
|
+
* The gate is fail-closed: if the human doesn't respond within the
|
|
22
|
+
* timeout, the answer is NO. If the connection drops, the answer is
|
|
23
|
+
* NO. There is no bypass.
|
|
24
|
+
*
|
|
25
|
+
* Three-line integration:
|
|
26
|
+
*
|
|
27
|
+
* const { createConsentGate } = require('@opnli/atl-devkit');
|
|
28
|
+
* const gate = createConsentGate({ onConsent: showMyUI, timeout: 60000 });
|
|
29
|
+
* myProxy.onMessage(gate.inspect);
|
|
30
|
+
*
|
|
31
|
+
* @see OPN_ENG_CROC-E2E-Invariants_13MAR26_v2, INV-5, INV-7
|
|
32
|
+
*/
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
const crypto = require('crypto');
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create a consent gate for an agent message stream.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} options
|
|
41
|
+
* @param {function} options.onConsent — Called when consent is needed.
|
|
42
|
+
* Receives: { holdId, runId, gapSize, heldEventCount, detectedAt, shield }
|
|
43
|
+
* The integrator must call gate.resolve(holdId, 'allow'|'deny') when
|
|
44
|
+
* the human decides.
|
|
45
|
+
* @param {function} [options.onAudit] — Called with audit entry after each
|
|
46
|
+
* decision. Receives: { holdId, runId, decision, shield, gapSize, eventCount }
|
|
47
|
+
* If not provided, decisions are not logged (bring your own logger).
|
|
48
|
+
* @param {number} [options.timeout=60000] — Consent timeout in ms.
|
|
49
|
+
* Default deny after this period. Set to 0 to disable timeout.
|
|
50
|
+
* @param {boolean} [options.greenShield=false] — Enable Green Shield
|
|
51
|
+
* interception of exec.approval.requested events. When true, Yellow
|
|
52
|
+
* Shield seq-gap detection is disabled (Green supersedes Yellow).
|
|
53
|
+
* @returns {object} — { inspect, resolve, getState }
|
|
54
|
+
*/
|
|
55
|
+
function createConsentGate(options) {
|
|
56
|
+
const {
|
|
57
|
+
onConsent,
|
|
58
|
+
onAudit,
|
|
59
|
+
timeout = 60000,
|
|
60
|
+
greenShield = false
|
|
61
|
+
} = options;
|
|
62
|
+
|
|
63
|
+
if (typeof onConsent !== 'function') {
|
|
64
|
+
throw new Error('createConsentGate requires an onConsent callback');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Per-runId sequence tracking (Yellow Shield) ────────────
|
|
68
|
+
// Key: runId, Value: { lastSeq, state, holdId }
|
|
69
|
+
// state: 'streaming' | 'holding' | 'allowed' | 'denied'
|
|
70
|
+
const runState = new Map();
|
|
71
|
+
|
|
72
|
+
// ── Held events buffer ─────────────────────────────────────
|
|
73
|
+
// Key: holdId, Value: { runId, events[], gapSize, detectedAt,
|
|
74
|
+
// shield, state: 'pending'|'allow'|'deny'|'timeout' }
|
|
75
|
+
const heldEvents = new Map();
|
|
76
|
+
|
|
77
|
+
// ── Pending Green Shield approvals ─────────────────────────
|
|
78
|
+
// Key: approvalId, Value: { id, request, holdId, createdAtMs, expiresAtMs }
|
|
79
|
+
const pendingApprovals = new Map();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a consent decision.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} holdId — The hold to resolve
|
|
85
|
+
* @param {string} decision — 'allow' | 'deny' | 'timeout'
|
|
86
|
+
* @returns {object|null} — { decision, events[] } on allow, null otherwise.
|
|
87
|
+
* The caller is responsible for forwarding the returned events.
|
|
88
|
+
*/
|
|
89
|
+
function resolve(holdId, decision) {
|
|
90
|
+
const held = heldEvents.get(holdId);
|
|
91
|
+
if (!held) return null;
|
|
92
|
+
if (held.state !== 'pending') return null;
|
|
93
|
+
|
|
94
|
+
held.state = decision;
|
|
95
|
+
|
|
96
|
+
// Audit callback
|
|
97
|
+
if (typeof onAudit === 'function') {
|
|
98
|
+
onAudit({
|
|
99
|
+
holdId: holdId,
|
|
100
|
+
runId: held.runId,
|
|
101
|
+
decision: decision,
|
|
102
|
+
shield: held.shield,
|
|
103
|
+
gapSize: held.gapSize,
|
|
104
|
+
eventCount: held.events.length
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (decision === 'allow') {
|
|
109
|
+
const run = runState.get(held.runId);
|
|
110
|
+
if (run) run.state = 'allowed';
|
|
111
|
+
const events = held.events.slice();
|
|
112
|
+
heldEvents.delete(holdId);
|
|
113
|
+
return { decision: 'allow', events: events };
|
|
114
|
+
} else {
|
|
115
|
+
const run = runState.get(held.runId);
|
|
116
|
+
if (run) run.state = 'denied';
|
|
117
|
+
heldEvents.delete(holdId);
|
|
118
|
+
return { decision: decision, events: [] };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Start the consent timeout timer for a hold.
|
|
124
|
+
* @private
|
|
125
|
+
*/
|
|
126
|
+
function startTimeout(holdId) {
|
|
127
|
+
if (timeout <= 0) return;
|
|
128
|
+
setTimeout(function() {
|
|
129
|
+
const held = heldEvents.get(holdId);
|
|
130
|
+
if (held && held.state === 'pending') {
|
|
131
|
+
resolve(holdId, 'timeout');
|
|
132
|
+
}
|
|
133
|
+
}, timeout);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Inspect a message from the agent platform.
|
|
138
|
+
*
|
|
139
|
+
* Call this for every message in the Gateway→Client direction.
|
|
140
|
+
* Returns an action telling the caller what to do.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} msg — Parsed JSON message from the agent platform
|
|
143
|
+
* @param {Buffer|string} raw — Raw message data for forwarding
|
|
144
|
+
* @param {boolean} isBinary — Whether the message is binary
|
|
145
|
+
* @returns {object} — { action: 'forward'|'hold'|'drop'|'buffer', ... }
|
|
146
|
+
* 'forward': Send to client normally. msg included.
|
|
147
|
+
* 'hold': Consent requested. Do NOT send to client. holdId included.
|
|
148
|
+
* 'drop': Message suppressed (denied run). Do NOT send to client.
|
|
149
|
+
* 'buffer': Added to existing hold. Do NOT send to client.
|
|
150
|
+
*/
|
|
151
|
+
function inspect(msg, raw, isBinary) {
|
|
152
|
+
// ── GREEN SHIELD: Intercept exec.approval.requested ──────
|
|
153
|
+
if (msg.type === 'event' && msg.event === 'exec.approval.requested') {
|
|
154
|
+
const approval = msg.payload;
|
|
155
|
+
if (approval && approval.id) {
|
|
156
|
+
const holdId = 'gs-' + crypto.randomUUID().substring(0, 12);
|
|
157
|
+
|
|
158
|
+
pendingApprovals.set(approval.id, {
|
|
159
|
+
id: approval.id,
|
|
160
|
+
request: approval.request || {},
|
|
161
|
+
holdId: holdId,
|
|
162
|
+
createdAtMs: approval.createdAtMs || Date.now(),
|
|
163
|
+
expiresAtMs: approval.expiresAtMs || (Date.now() + 60000)
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
heldEvents.set(holdId, {
|
|
167
|
+
runId: approval.id,
|
|
168
|
+
events: [],
|
|
169
|
+
gapSize: 0,
|
|
170
|
+
detectedAt: Date.now(),
|
|
171
|
+
shield: 'green',
|
|
172
|
+
state: 'pending',
|
|
173
|
+
approvalId: approval.id
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
onConsent({
|
|
177
|
+
holdId: holdId,
|
|
178
|
+
runId: approval.id,
|
|
179
|
+
gapSize: 0,
|
|
180
|
+
heldEventCount: 0,
|
|
181
|
+
detectedAt: Date.now(),
|
|
182
|
+
shield: 'green',
|
|
183
|
+
request: approval.request || {}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
startTimeout(holdId);
|
|
187
|
+
return { action: 'hold', holdId: holdId };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Intercept resolved events (don't forward to UI)
|
|
192
|
+
if (msg.type === 'event' && msg.event === 'exec.approval.resolved') {
|
|
193
|
+
return { action: 'drop' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── YELLOW SHIELD: Seq-gap detection on agent events ─────
|
|
197
|
+
if (!greenShield && msg.type === 'event' && msg.event === 'agent') {
|
|
198
|
+
const payload = msg.payload || {};
|
|
199
|
+
const runId = payload.runId;
|
|
200
|
+
const seq = payload.seq;
|
|
201
|
+
const stream = payload.stream;
|
|
202
|
+
|
|
203
|
+
if (runId && typeof seq === 'number') {
|
|
204
|
+
if (!runState.has(runId)) {
|
|
205
|
+
runState.set(runId, { lastSeq: 0, state: 'streaming', holdId: null });
|
|
206
|
+
}
|
|
207
|
+
const run = runState.get(runId);
|
|
208
|
+
|
|
209
|
+
// If holding, buffer this event
|
|
210
|
+
if (run.state === 'holding' && run.holdId) {
|
|
211
|
+
const held = heldEvents.get(run.holdId);
|
|
212
|
+
if (held && held.state === 'pending') {
|
|
213
|
+
held.events.push({ data: raw, isBinary: isBinary });
|
|
214
|
+
run.lastSeq = seq;
|
|
215
|
+
return { action: 'buffer', holdId: run.holdId };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// If denied, drop all further events for this run
|
|
220
|
+
if (run.state === 'denied') {
|
|
221
|
+
run.lastSeq = seq;
|
|
222
|
+
return { action: 'drop' };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Seq-gap detection on assistant stream
|
|
226
|
+
if (stream === 'assistant' && run.lastSeq > 0 && seq > run.lastSeq + 1) {
|
|
227
|
+
const gapSize = seq - run.lastSeq - 1;
|
|
228
|
+
const holdId = 'ysh-' + crypto.randomUUID().substring(0, 12);
|
|
229
|
+
|
|
230
|
+
run.state = 'holding';
|
|
231
|
+
run.holdId = holdId;
|
|
232
|
+
run.lastSeq = seq;
|
|
233
|
+
|
|
234
|
+
heldEvents.set(holdId, {
|
|
235
|
+
runId: runId,
|
|
236
|
+
events: [{ data: raw, isBinary: isBinary }],
|
|
237
|
+
gapSize: gapSize,
|
|
238
|
+
detectedAt: Date.now(),
|
|
239
|
+
shield: 'yellow',
|
|
240
|
+
state: 'pending'
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
onConsent({
|
|
244
|
+
holdId: holdId,
|
|
245
|
+
runId: runId,
|
|
246
|
+
gapSize: gapSize,
|
|
247
|
+
heldEventCount: 1,
|
|
248
|
+
detectedAt: Date.now(),
|
|
249
|
+
shield: 'yellow'
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
startTimeout(holdId);
|
|
253
|
+
return { action: 'hold', holdId: holdId };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Normal event — track and forward
|
|
257
|
+
run.lastSeq = seq;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Default: forward ─────────────────────────────────────
|
|
262
|
+
return { action: 'forward' };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Get current state for diagnostics.
|
|
267
|
+
* @returns {object} — { activeHolds, trackedRuns, pendingApprovals }
|
|
268
|
+
*/
|
|
269
|
+
function getState() {
|
|
270
|
+
return {
|
|
271
|
+
activeHolds: heldEvents.size,
|
|
272
|
+
trackedRuns: runState.size,
|
|
273
|
+
pendingApprovals: pendingApprovals.size
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { inspect, resolve, getState };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
module.exports = { createConsentGate };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @opnli/atl-devkit — The Agent Trust Layer DevKit
|
|
3
|
+
*
|
|
4
|
+
* Add human consent to any AI agent in 3 lines.
|
|
5
|
+
*
|
|
6
|
+
* const { createConsentGate } = require('@opnli/atl-devkit');
|
|
7
|
+
* const gate = createConsentGate({ onConsent: showMyUI, timeout: 60000 });
|
|
8
|
+
* myProxy.onMessage(gate.inspect);
|
|
9
|
+
*
|
|
10
|
+
* The Agent Trust Layer sits between an AI agent and the world it acts
|
|
11
|
+
* upon. When the agent tries to act — read a file, call an API, run a
|
|
12
|
+
* command — the ATL holds the action and asks the human: Allow or Block?
|
|
13
|
+
*
|
|
14
|
+
* The human's decision is final. The audit trail is tamper-evident.
|
|
15
|
+
* There is no bypass. This is trust infrastructure for the Agent Economy.
|
|
16
|
+
*
|
|
17
|
+
* Two shield levels:
|
|
18
|
+
*
|
|
19
|
+
* GREEN SHIELD — Consent Before Execution. The action has NOT happened
|
|
20
|
+
* yet. The human decides whether it happens. Requires agent platform
|
|
21
|
+
* support for pre-execution approval events.
|
|
22
|
+
*
|
|
23
|
+
* YELLOW SHIELD — Consent Before Delivery. The action HAS happened,
|
|
24
|
+
* but the result has not been delivered. The human decides whether to
|
|
25
|
+
* receive it. Works on ANY platform that streams sequential events.
|
|
26
|
+
*
|
|
27
|
+
* Three Realities of Trust:
|
|
28
|
+
* 1. Identity — Know who is acting (CARD credentials)
|
|
29
|
+
* 2. Consent — Choose what is allowed (this DevKit)
|
|
30
|
+
* 3. Accountability — Check what happened (audit log)
|
|
31
|
+
*
|
|
32
|
+
* My data + Your AI + My control = Living Intelligence
|
|
33
|
+
*
|
|
34
|
+
* @see https://github.com/opnli/agent-trust-layer
|
|
35
|
+
* @see https://opn.li
|
|
36
|
+
*
|
|
37
|
+
* Copyright (c) 2026 Openly Personal Networks, Inc.
|
|
38
|
+
* Licensed under Apache-2.0
|
|
39
|
+
*/
|
|
40
|
+
'use strict';
|
|
41
|
+
|
|
42
|
+
const { createConsentGate } = require('./consent-gate');
|
|
43
|
+
const { createAuditLogger } = require('./audit-logger');
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
createConsentGate,
|
|
47
|
+
createAuditLogger
|
|
48
|
+
};
|