@aiwg/cli 2026.8.3 → 2026.8.5

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.
@@ -39,6 +39,7 @@ import { existsSync } from 'node:fs';
39
39
  import { dirname, join, resolve as resolvePath } from 'node:path';
40
40
  import { fileURLToPath } from 'node:url';
41
41
  import { buildHitlResponseMessage, extractHitlEnvelope, validateResponseStructurally, } from './hitl.js';
42
+ import { digestDecisionContext } from '../audit/operator-decision.js';
42
43
  /** Default audit log — writes to stderr as JSONL. Replace with a file/HTTP sink in production. */
43
44
  export class StderrHitlAuditLog {
44
45
  append(entry) {
@@ -189,6 +190,21 @@ export async function driveOnePrompt(opts) {
189
190
  const maxRetries = Math.max(1, opts.maxRetries ?? 3);
190
191
  const messageIdFactory = opts.messageIdFactory ?? defaultMessageIdFactory;
191
192
  const startedAt = Date.now();
193
+ const operator = adapterOperator(opts.adapter);
194
+ if (!isResponderAllowed(opts.envelope.allowed_responders, operator)) {
195
+ await auditLog.append({
196
+ decided_at: new Date().toISOString(),
197
+ operator,
198
+ channel: opts.adapter.name,
199
+ prompt_id: opts.envelope.prompt_id,
200
+ ...(opts.taskId !== undefined ? { task_id: opts.taskId } : {}),
201
+ ...(opts.contextId !== undefined ? { context_id: opts.contextId } : {}),
202
+ outcome: 'unauthorized',
203
+ error: 'operator is not authorized by allowed_responders',
204
+ duration_ms: Date.now() - startedAt,
205
+ });
206
+ return;
207
+ }
192
208
  // Set up the deadline AbortController if the envelope declares one.
193
209
  const controller = new AbortController();
194
210
  const deadlineMs = parseDeadline(opts.envelope.deadline);
@@ -296,7 +312,7 @@ export async function driveOnePrompt(opts) {
296
312
  ...(opts.taskId !== undefined ? { task_id: opts.taskId } : {}),
297
313
  ...(opts.contextId !== undefined ? { context_id: opts.contextId } : {}),
298
314
  outcome: 'responded',
299
- response,
315
+ response_digest: digestDecisionContext(response),
300
316
  duration_ms: Date.now() - startedAt,
301
317
  });
302
318
  return;
@@ -358,6 +374,17 @@ function adapterOperator(adapter) {
358
374
  const id = adapter.operatorId;
359
375
  return typeof id === 'string' && id.length > 0 ? id : adapter.name;
360
376
  }
377
+ /**
378
+ * Enforce the v1 coarse responder policy before collecting or forwarding a
379
+ * response. Consensus policies require an aggregate routing adapter and are
380
+ * therefore not satisfied by a single-principal adapter.
381
+ */
382
+ export function isResponderAllowed(policies, responderId) {
383
+ const effective = policies?.length ? policies : ['any'];
384
+ if (effective.includes('any'))
385
+ return true;
386
+ return effective.some(policy => policy.startsWith('specific:') && policy.slice('specific:'.length) === responderId);
387
+ }
361
388
  function defaultMessageIdFactory() {
362
389
  // Prefer crypto.randomUUID; fall back to timestamp+random for older node.
363
390
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -14,8 +14,8 @@
14
14
  // 2. Extract the envelope
15
15
  // 3. Route to a HitlDeliveryAdapter (CLI / Slack / web)
16
16
  // 4. Validate the operator's response against `response_schema`
17
- // 5. POST a reply Message with `metadata.hitl_response_for: <prompt_id>`
18
- // and the response payload at `metadata.<URI>`
17
+ // 5. POST a reply Message with the canonical
18
+ // `metadata.hitl_response_for: { prompt_id, payload }` envelope
19
19
  //
20
20
  // This module handles steps 1–4. Step 5 lives in the A2AClient consumer
21
21
  // that drives the task lifecycle.
@@ -24,6 +24,9 @@
24
24
  import { A2A_HITL_PROMPT_V1 } from './client.js';
25
25
  /** Required envelope keys per spec §Prompt envelope. */
26
26
  const REQUIRED_ENVELOPE_KEYS = ['prompt_id', 'prompt', 'response_schema'];
27
+ const PROMPT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
28
+ const RESPONDER_RE = /^(any|specific:\S+|consensus:[1-9][0-9]*)$/;
29
+ const MAX_RESPONSE_SCHEMA_BYTES = 64 * 1024;
27
30
  /**
28
31
  * Pull the HITL envelope out of a Task / TaskStatus / Message metadata
29
32
  * blob. Returns `null` when the structure is not `input-required` or
@@ -73,11 +76,30 @@ export function extractHitlEnvelope(source) {
73
76
  return { ok: false, reason: `envelope missing required key: ${key}` };
74
77
  }
75
78
  }
76
- if (typeof env['prompt_id'] !== 'string' || env['prompt_id'].length === 0) {
77
- return { ok: false, reason: 'prompt_id must be a non-empty string' };
79
+ const allowedKeys = new Set([...REQUIRED_ENVELOPE_KEYS, 'deadline', 'allowed_responders']);
80
+ const unexpected = Object.keys(env).find(key => !allowedKeys.has(key));
81
+ if (unexpected)
82
+ return { ok: false, reason: `envelope contains unsupported key: ${unexpected}` };
83
+ if (typeof env['prompt_id'] !== 'string' || !PROMPT_ID_RE.test(env['prompt_id'])) {
84
+ return { ok: false, reason: 'prompt_id must be an RFC 4122 UUID' };
78
85
  }
79
- if (typeof env['prompt'] !== 'string') {
80
- return { ok: false, reason: 'prompt must be a string' };
86
+ if (typeof env['prompt'] !== 'string' || env['prompt'].length === 0) {
87
+ return { ok: false, reason: 'prompt must be a non-empty string' };
88
+ }
89
+ if (typeof env['response_schema'] !== 'object' || env['response_schema'] === null || Array.isArray(env['response_schema'])) {
90
+ return { ok: false, reason: 'response_schema must be an object' };
91
+ }
92
+ if (env['response_schema']['type'] !== 'object') {
93
+ return { ok: false, reason: 'response_schema must declare top-level type object' };
94
+ }
95
+ if (Buffer.byteLength(JSON.stringify(env['response_schema']), 'utf8') > MAX_RESPONSE_SCHEMA_BYTES) {
96
+ return { ok: false, reason: 'response_schema exceeds 64 KiB' };
97
+ }
98
+ if (env['deadline'] !== undefined && (typeof env['deadline'] !== 'string' || !Number.isFinite(Date.parse(env['deadline'])))) {
99
+ return { ok: false, reason: 'deadline must be an RFC 3339 timestamp' };
100
+ }
101
+ if (env['allowed_responders'] !== undefined && (!Array.isArray(env['allowed_responders']) || env['allowed_responders'].some(value => typeof value !== 'string' || !RESPONDER_RE.test(value)))) {
102
+ return { ok: false, reason: 'allowed_responders contains an invalid responder policy' };
81
103
  }
82
104
  return { ok: true, envelope: env };
83
105
  }
@@ -93,8 +115,10 @@ export function buildHitlResponseMessage(opts) {
93
115
  },
94
116
  ],
95
117
  metadata: {
96
- hitl_response_for: opts.promptId,
97
- [A2A_HITL_PROMPT_V1]: { response: opts.response },
118
+ hitl_response_for: {
119
+ prompt_id: opts.promptId,
120
+ payload: opts.response,
121
+ },
98
122
  },
99
123
  };
100
124
  if (opts.taskId !== undefined)
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Versioned, tamper-evident operator decision records for orchestration.
3
+ *
4
+ * Records contain correlation and digests, never raw prompts or credentials.
5
+ * The JSONL hash chain detects modification/deletion/reordering inside an
6
+ * exported segment; external checkpointing anchors segment heads.
7
+ *
8
+ * @implements #1567
9
+ */
10
+ import { createHash, randomUUID } from 'node:crypto';
11
+ import { appendFile, chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
12
+ import { dirname } from 'node:path';
13
+ export const OPERATOR_DECISION_SCHEMA = 'operator-decision.aiwg.io/v1';
14
+ const secretKey = /token|secret|password|credential|authorization|cookie|csrf|api[_-]?key/i;
15
+ const secretValue = /(?:bearer\s+\S+|\bsk-[a-z0-9_-]+|\bgh[pousr]_[a-z0-9_]+)/i;
16
+ export function digestDecisionContext(context) {
17
+ const safe = redact(context).value;
18
+ return `sha256:${createHash('sha256').update(canonicalJson(safe)).digest('hex')}`;
19
+ }
20
+ export function createDecisionRecord(input, previousHash) {
21
+ validateInput(input);
22
+ const actor = redact(input.actor);
23
+ const correlation = redact(input.correlation);
24
+ const runtime = input.runtime ? redact(input.runtime) : undefined;
25
+ const reason = redact(input.reason);
26
+ const detected = [...actor.paths, ...correlation.paths, ...(runtime?.paths ?? []), ...reason.paths];
27
+ const unsigned = {
28
+ schema_version: OPERATOR_DECISION_SCHEMA,
29
+ event_id: input.event_id ?? randomUUID(),
30
+ timestamp: input.timestamp ?? new Date().toISOString(),
31
+ kind: input.kind,
32
+ outcome: input.outcome,
33
+ actor: actor.value,
34
+ reason: reason.value,
35
+ context_digest: digestDecisionContext(input.context),
36
+ classification: input.classification,
37
+ correlation: correlation.value,
38
+ ...(runtime ? { runtime: runtime.value } : {}),
39
+ ...(input.policy_ref ? { policy_ref: input.policy_ref } : {}),
40
+ redacted_fields: [...new Set([...(input.redacted_fields ?? []), ...detected])].sort(),
41
+ previous_hash: previousHash,
42
+ };
43
+ return {
44
+ ...unsigned,
45
+ record_hash: hashRecord(unsigned),
46
+ };
47
+ }
48
+ export function verifyDecisionChain(records) {
49
+ let previous = null;
50
+ for (let index = 0; index < records.length; index += 1) {
51
+ const record = records[index];
52
+ if (record.previous_hash !== previous)
53
+ return { ok: false, index, reason: 'previous hash mismatch' };
54
+ const { record_hash, ...unsigned } = record;
55
+ if (hashRecord(unsigned) !== record_hash)
56
+ return { ok: false, index, reason: 'record hash mismatch' };
57
+ previous = record_hash;
58
+ }
59
+ return { ok: true };
60
+ }
61
+ export function toOpenTelemetryLog(record) {
62
+ return {
63
+ timeUnixNano: String(BigInt(Date.parse(record.timestamp)) * 1000000n),
64
+ severityText: record.outcome === 'denied' ? 'WARN' : 'INFO',
65
+ body: { stringValue: `${record.kind}:${record.outcome}` },
66
+ attributes: Object.entries({
67
+ 'aiwg.audit.schema': record.schema_version,
68
+ 'aiwg.audit.event_id': record.event_id,
69
+ 'aiwg.audit.record_hash': record.record_hash,
70
+ 'aiwg.decision.actor_id': record.actor.id,
71
+ 'aiwg.decision.context_digest': record.context_digest,
72
+ 'aiwg.mission.id': record.correlation.mission_id,
73
+ 'aiwg.flow.id': record.correlation.flow_id,
74
+ 'aiwg.provider.id': record.correlation.provider_id,
75
+ 'aiwg.sandbox.task_id': record.correlation.sandbox_task_id,
76
+ 'aiwg.prompt.id': record.correlation.prompt_id,
77
+ }).filter(([, value]) => value !== undefined).map(([key, value]) => ({
78
+ key,
79
+ value: { stringValue: String(value) },
80
+ })),
81
+ };
82
+ }
83
+ export class JsonlOperatorDecisionStore {
84
+ path;
85
+ constructor(path) {
86
+ this.path = path;
87
+ }
88
+ async append(input) {
89
+ const records = await this.read();
90
+ const verification = verifyDecisionChain(records);
91
+ if (!verification.ok)
92
+ throw new Error(`operator decision audit chain is invalid at ${verification.index}: ${verification.reason}`);
93
+ const record = createDecisionRecord(input, records.at(-1)?.record_hash ?? null);
94
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
95
+ await appendFile(this.path, `${JSON.stringify(record)}\n`, { mode: 0o600 });
96
+ await chmod(this.path, 0o600);
97
+ return record;
98
+ }
99
+ async read() {
100
+ try {
101
+ const raw = await readFile(this.path, 'utf8');
102
+ return raw.split(/\n+/).filter(Boolean).map(line => JSON.parse(line));
103
+ }
104
+ catch (error) {
105
+ if (error.code === 'ENOENT')
106
+ return [];
107
+ throw error;
108
+ }
109
+ }
110
+ async prune(policy, now = Date.now()) {
111
+ const prior = await this.read();
112
+ const retainedInputs = prior.filter(record => {
113
+ const days = policy.maxAgeDays[record.classification];
114
+ return days === undefined || Date.parse(record.timestamp) + days * 86_400_000 > now;
115
+ });
116
+ let previous = null;
117
+ const retained = retainedInputs.map(record => {
118
+ const { record_hash: _hash, previous_hash: _previous, ...rest } = record;
119
+ const next = { ...rest, previous_hash: previous };
120
+ const rebuilt = { ...next, record_hash: hashRecord(next) };
121
+ previous = rebuilt.record_hash;
122
+ return rebuilt;
123
+ });
124
+ const temporary = `${this.path}.tmp`;
125
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
126
+ await writeFile(temporary, retained.map(record => JSON.stringify(record)).join('\n') + (retained.length ? '\n' : ''), { mode: 0o600 });
127
+ await rename(temporary, this.path);
128
+ return { retained: retained.length, deleted: prior.length - retained.length, head_hash: previous };
129
+ }
130
+ }
131
+ function validateInput(input) {
132
+ if (!input.actor.id || !input.actor.authentication)
133
+ throw new Error('actor identity and authentication are required');
134
+ if (!input.reason.trim())
135
+ throw new Error('a non-empty operator reason is required');
136
+ if (!Object.values(input.correlation).some(Boolean))
137
+ throw new Error('at least one correlation identifier is required');
138
+ if (input.timestamp && !Number.isFinite(Date.parse(input.timestamp)))
139
+ throw new Error('timestamp must be valid ISO time');
140
+ }
141
+ function hashRecord(value) {
142
+ return `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}`;
143
+ }
144
+ function canonicalJson(value) {
145
+ if (Array.isArray(value))
146
+ return `[${value.map(canonicalJson).join(',')}]`;
147
+ if (value && typeof value === 'object') {
148
+ return `{${Object.entries(value)
149
+ .filter(([, item]) => item !== undefined)
150
+ .sort(([a], [b]) => a.localeCompare(b))
151
+ .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
152
+ }
153
+ return JSON.stringify(value);
154
+ }
155
+ function redact(value, path = '$') {
156
+ if (Array.isArray(value)) {
157
+ const rows = value.map((item, index) => redact(item, `${path}[${index}]`));
158
+ return { value: rows.map(row => row.value), paths: rows.flatMap(row => row.paths) };
159
+ }
160
+ if (value && typeof value === 'object') {
161
+ const output = {};
162
+ const paths = [];
163
+ for (const [key, item] of Object.entries(value)) {
164
+ if (secretKey.test(key)) {
165
+ output[key] = '[redacted]';
166
+ paths.push(`${path}.${key}`);
167
+ }
168
+ else {
169
+ const child = redact(item, `${path}.${key}`);
170
+ output[key] = child.value;
171
+ paths.push(...child.paths);
172
+ }
173
+ }
174
+ return { value: output, paths };
175
+ }
176
+ if (typeof value === 'string' && secretValue.test(value))
177
+ return { value: '[redacted]', paths: [path] };
178
+ return { value, paths: [] };
179
+ }
180
+ //# sourceMappingURL=operator-decision.js.map