deepdebug-local-agent 1.0.23 → 1.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/.claude/context.md +76 -0
- package/.claude/skills/README.md +25 -0
- package/.claude/skills/tdd-debugging/SKILL.md +53 -0
- package/.claude/skills/tdd-debugging/references/anti-patterns.md +43 -0
- package/.claude/skills/tdd-debugging/references/command-reference.md +46 -0
- package/.claude/standards/README.md +51 -0
- package/.claude/standards/clean-code.md +75 -0
- package/.claude/standards/code-review-checklist.md +61 -0
- package/.claude/standards/layered-architecture.md +163 -0
- package/.claude/standards/node.md +45 -0
- package/.claude/standards/security-owasp.md +114 -0
- package/.claude/standards/simplicity.md +42 -0
- package/.claude/standards/solid.md +64 -0
- package/.claude/standards/testing.md +59 -0
- package/.cursor/rules/standards.mdc +17 -0
- package/.github/workflows/build-byoc-image.yml +315 -0
- package/.github/workflows/deploy-gcp.yml +102 -0
- package/.github/workflows/npm-publish.yml +69 -8
- package/AGENTS.md +21 -0
- package/CHANGELOG.md +69 -0
- package/CLAUDE.md +13 -0
- package/CLAUDE_CODE_AGENT_INFINITE.md +5 -3
- package/Dockerfile +4 -0
- package/Dockerfile.full +55 -3
- package/bin/install.js +16 -5
- package/cloudbuild-full.yaml +57 -0
- package/docker-compose.yml +5 -3
- package/docs/OBSERVABILITY.md +164 -0
- package/docs/byoc-agent-image.md +70 -0
- package/docs/graphify-packaging-plan.md +133 -0
- package/docs/graphify-validation-run-2026-10-28.md +243 -0
- package/docs/releasing.md +43 -0
- package/docs/signed-commands.md +163 -0
- package/env.example +30 -0
- package/helm/Chart.yaml +2 -2
- package/helm/templates/deployment.yaml +13 -3
- package/helm/templates/networkpolicy.yaml +22 -0
- package/helm/templates/secret.yaml +1 -0
- package/helm/values.yaml +13 -14
- package/mcp-server.js +4 -1
- package/package.json +9 -4
- package/scripts/upgrade-observability-push.sh +227 -0
- package/src/ai-engine.js +22 -22
- package/src/config/trusted-signing-keys.json +3 -0
- package/src/detectors/build-tool-detector.js +50 -0
- package/src/detectors/multi-project-detector.js +22 -22
- package/src/detectors/service-detector.js +24 -24
- package/src/exec-utils.js +26 -4
- package/src/git/base-git-provider.js +117 -117
- package/src/git/bitbucket-provider.js +2 -2
- package/src/git/git-provider-registry.js +18 -18
- package/src/git/github-provider.js +13 -13
- package/src/git/multi-repo-pr.js +161 -0
- package/src/mcp-http-server.js +32 -16
- package/src/observability/bundle-files.js +418 -0
- package/src/observability/detect.js +151 -0
- package/src/observability/provision.js +212 -0
- package/src/observability/render-cli.js +59 -0
- package/src/observability/routes.js +88 -0
- package/src/patch/patch-engine.js +28 -28
- package/src/patch-manager.js +85 -85
- package/src/patch_bkp.js +14 -14
- package/src/ports.js +4 -4
- package/src/routes/workspace.route.js +47 -16
- package/src/runtimes/base-runtime.js +75 -75
- package/src/runtimes/java/gradle-runtime.js +17 -17
- package/src/runtimes/java/java-integrations.js +25 -25
- package/src/runtimes/java/maven-runtime.js +34 -34
- package/src/runtimes/node/node-integrations.js +1 -1
- package/src/runtimes/node/npm-runtime.js +14 -14
- package/src/runtimes/node/yarn-runtime.js +4 -4
- package/src/runtimes/runtime-registry.js +25 -25
- package/src/server-local.js +4 -4
- package/src/server.js +1366 -333
- package/src/siblings/burst-suppressor.js +89 -0
- package/src/siblings/capabilities.js +273 -0
- package/src/siblings/env-compat.cjs +58 -0
- package/src/siblings/incident-forwarder.js +320 -0
- package/src/siblings/logger.js +15 -0
- package/src/siblings/metrics.js +94 -0
- package/src/utils/agent-auth.js +184 -0
- package/src/utils/approval-token.js +56 -0
- package/src/utils/bind-host.js +29 -0
- package/src/utils/branch-refspec.js +98 -0
- package/src/utils/child-env.js +34 -0
- package/src/utils/environment-diagnostics.js +55 -55
- package/src/utils/exec-policy.js +962 -0
- package/src/utils/exec-utils.js +15 -15
- package/src/utils/file-count.js +46 -0
- package/src/utils/fs-utils.js +10 -10
- package/src/utils/multi-repo-open.js +132 -0
- package/src/utils/open-gate.js +183 -0
- package/src/utils/ranged-read.js +65 -0
- package/src/utils/request-guards.js +77 -0
- package/src/utils/request-signing.js +342 -0
- package/src/utils/root-recovery.js +244 -0
- package/src/utils/safe-exec-args.js +58 -0
- package/src/utils/sandbox.js +182 -0
- package/src/utils/search-outcome.js +56 -0
- package/src/utils/workspace-root.js +80 -0
- package/src/validators/syntax-validator.js +149 -0
- package/src/workspace/detect-port.js +16 -16
- package/src/workspace/file-reader.js +1 -1
- package/src/workspace/process-manager.js +46 -46
- package/src/workspace-manager.js +86 -24
- package/test/adapters/registry.test.js +9 -2
- package/test/agent-auth.test.js +194 -0
- package/test/bind-host.test.js +26 -0
- package/test/branch-refspec.test.js +224 -0
- package/test/burst-suppressor.test.js +60 -0
- package/test/capabilities-access.test.js +47 -0
- package/test/capabilities.test.js +87 -0
- package/test/child-env.test.js +23 -0
- package/test/env-compat.test.js +71 -0
- package/test/exec-policy.test.js +463 -0
- package/test/file-count.test.js +64 -0
- package/test/incident-forwarder.test.js +231 -0
- package/test/multi-repo-open.test.js +180 -0
- package/test/multi-repo-pr.test.js +129 -0
- package/test/observability-detect.test.js +199 -0
- package/test/open-gate.test.js +341 -0
- package/test/ranged-read.test.js +60 -0
- package/test/request-guards.test.js +91 -0
- package/test/request-signing.test.js +284 -0
- package/test/root-recovery-sidecar.test.js +171 -0
- package/test/root-recovery.test.js +274 -0
- package/test/safe-exec-args.test.js +58 -0
- package/test/sandbox.test.js +98 -0
- package/test/search-outcome.test.js +50 -0
- package/test/workspace-root.test.js +64 -0
- package/tools/graphify-validation/README.md +92 -0
- package/tools/graphify-validation/config/questions.json +31 -0
- package/tools/graphify-validation/config/repos.json +9 -0
- package/tools/graphify-validation/config/symbols.json +6 -0
- package/tools/graphify-validation/fixtures/spring/expected.json +34 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AppConfig.java +29 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/AuditLogger.java +15 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/Cache.java +7 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/CheckoutService.java +25 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/InMemoryCache.java +18 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/OrderController.java +22 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaymentGateway.java +5 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/PaypalGateway.java +11 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/RefundService.java +14 -0
- package/tools/graphify-validation/fixtures/spring/src/main/java/com/example/fx/StripeGateway.java +11 -0
- package/tools/graphify-validation/lib/affected_vs_rg.py +81 -0
- package/tools/graphify-validation/lib/audit_hook/sitecustomize.py +22 -0
- package/tools/graphify-validation/lib/graph_metrics.py +96 -0
- package/tools/graphify-validation/lib/gv_common.py +130 -0
- package/tools/graphify-validation/lib/licenses.py +47 -0
- package/tools/graphify-validation/lib/pip_audit_summary.py +18 -0
- package/tools/graphify-validation/lib/recall.py +115 -0
- package/tools/graphify-validation/lib/report.py +226 -0
- package/tools/graphify-validation/lib/run_all.py +237 -0
- package/tools/graphify-validation/lib/run_graphify.py +144 -0
- package/tools/graphify-validation/lib/rusage_helper.py +17 -0
- package/tools/graphify-validation/lib/sample_edges.py +246 -0
- package/tools/graphify-validation/lib/spring_fixture.py +81 -0
- package/tools/graphify-validation/requirements-py311.lock +508 -0
- package/tools/graphify-validation/requirements-tools.in +1 -0
- package/tools/graphify-validation/requirements-tools.lock +371 -0
- package/tools/graphify-validation/requirements.in +1 -0
- package/tools/graphify-validation/requirements.lock +502 -0
- package/tools/graphify-validation/run.sh +43 -0
- package/tools/graphify-validation/tests/test_harness.py +151 -0
- package/cloudbuild-agent-qa.yaml +0 -43
- package/cloudbuild-agent-staging.yaml +0 -40
- package/cloudbuild.yaml.deprecated +0 -42
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter consumer / incident forwarder (sibling module).
|
|
3
|
+
*
|
|
4
|
+
* The legacy log-source adapters (src/adapters/*) emit a frozen 6-key 'error'
|
|
5
|
+
* event { adapter, errorCode, file, message, source, timestamp } but nothing was
|
|
6
|
+
* listening, so ingested errors went nowhere. This module is the missing
|
|
7
|
+
* consumer: server.js attaches `adapter.on('error', e => incidentForwarder
|
|
8
|
+
* .handleAdapterEvent(e, name))` in the existing boot loop, and configures the
|
|
9
|
+
* forwarder (gateway url + credentials) from the app.listen callback.
|
|
10
|
+
*
|
|
11
|
+
* Robustness contract (never silently drop):
|
|
12
|
+
* - malformed event -> validated + rejected, counted (forward_failure
|
|
13
|
+
* reason=malformed) and logged; NOT forwarded.
|
|
14
|
+
* - gateway unreachable -> retried with backoff; on final failure counted
|
|
15
|
+
* (reason=gateway_unreachable) and logged at error
|
|
16
|
+
* with the event; NOT thrown (boot/runtime safe).
|
|
17
|
+
* - backpressure -> bounded in-memory queue with a concurrency cap.
|
|
18
|
+
* When full, the OLDEST event is evicted, counted
|
|
19
|
+
* (dropped) and logged with a warning. Events that
|
|
20
|
+
* arrive before the forwarder is configured are
|
|
21
|
+
* buffered, not lost.
|
|
22
|
+
*
|
|
23
|
+
* The class is exported for tests (fresh instances, injectable fetch/metrics);
|
|
24
|
+
* server.js uses the shared singleton `incidentForwarder`.
|
|
25
|
+
*/
|
|
26
|
+
import { logger as defaultLogger } from './logger.js';
|
|
27
|
+
import { BurstSuppressor } from './burst-suppressor.js';
|
|
28
|
+
|
|
29
|
+
const SIX_KEYS = ['adapter', 'errorCode', 'file', 'message', 'source', 'timestamp'];
|
|
30
|
+
|
|
31
|
+
// Contract errors: the endpoint answered, but rejected us structurally. Retrying
|
|
32
|
+
// cannot help (bad token, forbidden, wrong path) — it only burns budget and hides
|
|
33
|
+
// a misconfiguration. Fail loudly instead. Everything else (network, timeout, 5xx)
|
|
34
|
+
// is transport and stays on the retry/backoff path.
|
|
35
|
+
const CONTRACT_STATUSES = new Set([401, 403, 404]);
|
|
36
|
+
|
|
37
|
+
export class IncidentForwarder {
|
|
38
|
+
constructor(opts = {}) {
|
|
39
|
+
this.configured = false;
|
|
40
|
+
this.gatewayUrl = null;
|
|
41
|
+
this.apiKey = null;
|
|
42
|
+
this.tenantId = null;
|
|
43
|
+
this.incidentPath = opts.incidentPath
|
|
44
|
+
|| process.env.DEEPDEBUG_ADAPTER_INCIDENT_PATH
|
|
45
|
+
|| '/api/v1/internal/incident/create';
|
|
46
|
+
this.maxQueue = opts.maxQueue || 1000;
|
|
47
|
+
this.maxInflight = opts.maxInflight || 4;
|
|
48
|
+
this.maxRetries = opts.maxRetries ?? 2;
|
|
49
|
+
this.timeoutMs = opts.timeoutMs || 8000;
|
|
50
|
+
this.fetchImpl = opts.fetchImpl || globalThis.fetch;
|
|
51
|
+
this.logger = opts.logger || defaultLogger;
|
|
52
|
+
this.metrics = opts.metrics || null;
|
|
53
|
+
|
|
54
|
+
this.queue = [];
|
|
55
|
+
this.inflight = 0;
|
|
56
|
+
// Independent counters so tests/health can assert without the metrics module.
|
|
57
|
+
this.stats = { received: 0, forwarded: 0, failed: 0, dropped: 0, malformed: 0, contract: 0, suppressed: 0 };
|
|
58
|
+
// Queue-admission burst limiter (NOT dedup; see burst-suppressor.js).
|
|
59
|
+
this.suppressor = opts.suppressor || new BurstSuppressor({ ttlMs: opts.suppressTtlMs });
|
|
60
|
+
// Sticky contract-failure state, surfaced on /capabilities.
|
|
61
|
+
this.contractError = null; // { status, body, at }
|
|
62
|
+
this.contractPreflightResult = null; // { ok, status, reason, at }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Enable forwarding. Buffered events (received before config) start draining.
|
|
67
|
+
*/
|
|
68
|
+
configure({ gatewayUrl, apiKey, tenantId, metrics, incidentPath, fetchImpl } = {}) {
|
|
69
|
+
this.gatewayUrl = gatewayUrl || this.gatewayUrl;
|
|
70
|
+
this.apiKey = apiKey || this.apiKey;
|
|
71
|
+
this.tenantId = tenantId || this.tenantId;
|
|
72
|
+
if (metrics) this.metrics = metrics;
|
|
73
|
+
if (incidentPath) this.incidentPath = incidentPath;
|
|
74
|
+
if (fetchImpl) this.fetchImpl = fetchImpl;
|
|
75
|
+
this.configured = Boolean(this.gatewayUrl);
|
|
76
|
+
if (!this.configured) {
|
|
77
|
+
this.logger.warn('[incident-forwarder] no gateway url; events will buffer until configured');
|
|
78
|
+
} else {
|
|
79
|
+
this.logger.info({ url: this.gatewayUrl + this.incidentPath, queued: this.queue.length },
|
|
80
|
+
'[incident-forwarder] configured');
|
|
81
|
+
}
|
|
82
|
+
this._drain();
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Adapter 'error' handler. Validates, enqueues, and kicks the drain loop.
|
|
88
|
+
* Returns the outcome string (useful for tests): received | malformed | dropped.
|
|
89
|
+
*/
|
|
90
|
+
handleAdapterEvent(evt, adapterName) {
|
|
91
|
+
this.stats.received++;
|
|
92
|
+
this.metrics?.incReceived?.(adapterName || (evt && evt.adapter) || 'unknown');
|
|
93
|
+
|
|
94
|
+
const norm = this._validate(evt);
|
|
95
|
+
if (!norm) {
|
|
96
|
+
this.stats.malformed++;
|
|
97
|
+
this.stats.failed++;
|
|
98
|
+
this.metrics?.incFailure?.('malformed');
|
|
99
|
+
this.logger.warn({ adapter: adapterName, evt: this._safe(evt) },
|
|
100
|
+
'[incident-forwarder] malformed adapter event rejected (not forwarded)');
|
|
101
|
+
return 'malformed';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Burst control at queue ADMISSION (before the bounded queue). Recurrence
|
|
105
|
+
// across the TTL window still passes — it is the signal the gateway needs.
|
|
106
|
+
if (this.suppressor && this.suppressor.shouldSuppress(norm)) {
|
|
107
|
+
this.stats.suppressed++;
|
|
108
|
+
this.metrics?.incSuppressed?.(adapterName || norm.adapter || 'unknown');
|
|
109
|
+
return 'suppressed';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let outcome = 'received';
|
|
113
|
+
if (this.queue.length >= this.maxQueue) {
|
|
114
|
+
// Backpressure: never silently drop — evict oldest, count, log.
|
|
115
|
+
const victim = this.queue.shift();
|
|
116
|
+
this.stats.dropped++;
|
|
117
|
+
this.metrics?.incDropped?.();
|
|
118
|
+
this.logger.warn(
|
|
119
|
+
{ adapter: adapterName, queue: this.maxQueue, droppedFingerprint: victim && victim.fingerprint },
|
|
120
|
+
'[incident-forwarder] queue full, evicted oldest event');
|
|
121
|
+
outcome = 'dropped';
|
|
122
|
+
}
|
|
123
|
+
this.queue.push(norm);
|
|
124
|
+
this._drain();
|
|
125
|
+
return outcome;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Wait until the queue is empty and all in-flight sends have settled (tests). */
|
|
129
|
+
async flush() {
|
|
130
|
+
while (this.queue.length > 0 || this.inflight > 0) {
|
|
131
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
_drain() {
|
|
136
|
+
if (!this.configured) return; // buffer until configured
|
|
137
|
+
while (this.inflight < this.maxInflight && this.queue.length > 0) {
|
|
138
|
+
const evt = this.queue.shift();
|
|
139
|
+
this.inflight++;
|
|
140
|
+
this._send(evt, 0).finally(() => {
|
|
141
|
+
this.inflight--;
|
|
142
|
+
this._drain();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_headers() {
|
|
148
|
+
return {
|
|
149
|
+
'Content-Type': 'application/json',
|
|
150
|
+
...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
|
|
151
|
+
...(this.tenantId ? { 'X-Tenant-Id': this.tenantId } : {}),
|
|
152
|
+
'X-Internal-Request': 'true',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async _post(url, payload) {
|
|
157
|
+
const ctrl = new AbortController();
|
|
158
|
+
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
|
159
|
+
try {
|
|
160
|
+
return await this.fetchImpl(url, {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: this._headers(),
|
|
163
|
+
body: JSON.stringify(payload),
|
|
164
|
+
signal: ctrl.signal,
|
|
165
|
+
});
|
|
166
|
+
} finally {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async _readBody(res) {
|
|
172
|
+
try {
|
|
173
|
+
const t = res && typeof res.text === 'function' ? await res.text() : '';
|
|
174
|
+
return (t || '').slice(0, 500);
|
|
175
|
+
} catch {
|
|
176
|
+
return '';
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async _send(evt, attempt) {
|
|
181
|
+
const url = this.gatewayUrl.replace(/\/+$/, '') + this.incidentPath;
|
|
182
|
+
const body = this._toIncident(evt);
|
|
183
|
+
let res;
|
|
184
|
+
try {
|
|
185
|
+
res = await this._post(url, body);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
// Transport (network / timeout / abort): retry.
|
|
188
|
+
return this._retryOrFail(evt, attempt, err.message, 'network');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (res && res.ok) {
|
|
192
|
+
this.stats.forwarded++;
|
|
193
|
+
this.metrics?.incForwarded?.();
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const status = res ? res.status : 0;
|
|
198
|
+
if (CONTRACT_STATUSES.has(status)) {
|
|
199
|
+
// CONTRACT error: do NOT retry. Fail loudly with the response body.
|
|
200
|
+
const respBody = await this._readBody(res);
|
|
201
|
+
this.stats.failed++;
|
|
202
|
+
this.stats.contract++;
|
|
203
|
+
this.contractError = { status, body: respBody, at: new Date().toISOString() };
|
|
204
|
+
this.metrics?.incFailure?.('contract', String(status));
|
|
205
|
+
this.logger.error(
|
|
206
|
+
{ status, respBody, incident: body, url },
|
|
207
|
+
'[incident-forwarder] CONTRACT error from incident endpoint — not retrying (check AGENT_INTERNAL_TOKEN / path)');
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 5xx / 429 / other: transport-class, retry. (429 is rate limiting, not a
|
|
212
|
+
// contract error — it must keep backoff.)
|
|
213
|
+
return this._retryOrFail(evt, attempt, `gateway responded ${status}`, String(status));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async _retryOrFail(evt, attempt, reason, code) {
|
|
217
|
+
if (attempt < this.maxRetries) {
|
|
218
|
+
const backoff = Math.min(2000, 200 * 2 ** attempt);
|
|
219
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
220
|
+
return this._send(evt, attempt + 1);
|
|
221
|
+
}
|
|
222
|
+
this.stats.failed++;
|
|
223
|
+
this.metrics?.incFailure?.('transport', code || 'network');
|
|
224
|
+
this.logger.error({ reason, code, incident: this._toIncident(evt), attempts: attempt + 1 },
|
|
225
|
+
'[incident-forwarder] forward failed after retries (transport)');
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* One-shot boot probe of the incident endpoint. Sends a body WITHOUT tenantId
|
|
231
|
+
* so a correctly-authenticated call returns 400 ("tenantId required") and never
|
|
232
|
+
* creates an incident; 401/403 means the shared token is wrong, 404 the path.
|
|
233
|
+
* Records + logs the result. Never throws, never blocks startup.
|
|
234
|
+
*/
|
|
235
|
+
async contractPreflight() {
|
|
236
|
+
if (!this.configured) {
|
|
237
|
+
this.contractPreflightResult = { ok: false, status: 0, reason: 'not_configured', at: new Date().toISOString() };
|
|
238
|
+
return this.contractPreflightResult;
|
|
239
|
+
}
|
|
240
|
+
const url = this.gatewayUrl.replace(/\/+$/, '') + this.incidentPath;
|
|
241
|
+
let result;
|
|
242
|
+
try {
|
|
243
|
+
const res = await this._post(url, { __preflight: true }); // no tenantId on purpose
|
|
244
|
+
const status = res ? res.status : 0;
|
|
245
|
+
if (status === 401 || status === 403) result = { ok: false, status, reason: 'auth_rejected' };
|
|
246
|
+
else if (status === 404) result = { ok: false, status, reason: 'path_not_found' };
|
|
247
|
+
else if (status === 400) result = { ok: true, status, reason: 'reachable_auth_ok' };
|
|
248
|
+
else if (status >= 200 && status < 300) result = { ok: true, status, reason: 'unexpected_2xx' };
|
|
249
|
+
else result = { ok: false, status, reason: 'transport_or_5xx' };
|
|
250
|
+
} catch (err) {
|
|
251
|
+
result = { ok: false, status: 0, reason: 'unreachable', error: err.message };
|
|
252
|
+
}
|
|
253
|
+
this.contractPreflightResult = { ...result, at: new Date().toISOString() };
|
|
254
|
+
const level = this.contractPreflightResult.ok ? 'info' : 'error';
|
|
255
|
+
this.logger[level]({ ...this.contractPreflightResult, url },
|
|
256
|
+
'[incident-forwarder] contract preflight');
|
|
257
|
+
return this.contractPreflightResult;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Live status for /capabilities surfacing. */
|
|
261
|
+
getStatus() {
|
|
262
|
+
return {
|
|
263
|
+
configured: this.configured,
|
|
264
|
+
stats: { ...this.stats },
|
|
265
|
+
queued: this.queue.length,
|
|
266
|
+
contract_error: this.contractError,
|
|
267
|
+
contract_preflight: this.contractPreflightResult,
|
|
268
|
+
contract_ok: !this.contractError
|
|
269
|
+
&& (this.contractPreflightResult ? this.contractPreflightResult.ok : null),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Validate the frozen 6-key shape; return a normalized copy or null. */
|
|
274
|
+
_validate(evt) {
|
|
275
|
+
if (!evt || typeof evt !== 'object') return null;
|
|
276
|
+
for (const k of SIX_KEYS) {
|
|
277
|
+
if (!(k in evt)) return null;
|
|
278
|
+
}
|
|
279
|
+
if (typeof evt.message !== 'string' || evt.message.trim() === '') return null;
|
|
280
|
+
if (typeof evt.source !== 'string' || evt.source.trim() === '') return null;
|
|
281
|
+
return {
|
|
282
|
+
adapter: String(evt.adapter),
|
|
283
|
+
errorCode: evt.errorCode == null ? '' : String(evt.errorCode),
|
|
284
|
+
file: evt.file == null ? '' : String(evt.file),
|
|
285
|
+
message: String(evt.message),
|
|
286
|
+
source: String(evt.source),
|
|
287
|
+
timestamp: evt.timestamp,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Map the 6-key adapter event to the gateway incident-create payload. */
|
|
292
|
+
_toIncident(evt) {
|
|
293
|
+
return {
|
|
294
|
+
tenantId: this.tenantId || undefined,
|
|
295
|
+
service: evt.source,
|
|
296
|
+
message: evt.message,
|
|
297
|
+
severity: 'error',
|
|
298
|
+
environment: 'production',
|
|
299
|
+
detectedBy: 'local-agent-adapter',
|
|
300
|
+
logSource: 'legacy-adapter',
|
|
301
|
+
adapter: evt.adapter,
|
|
302
|
+
errorCode: evt.errorCode,
|
|
303
|
+
file: evt.file,
|
|
304
|
+
timestamp: evt.timestamp,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
_safe(evt) {
|
|
309
|
+
try {
|
|
310
|
+
return JSON.parse(JSON.stringify(evt));
|
|
311
|
+
} catch {
|
|
312
|
+
return String(evt);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Shared singleton used by server.js wiring. */
|
|
318
|
+
export const incidentForwarder = new IncidentForwarder();
|
|
319
|
+
|
|
320
|
+
export default incidentForwarder;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured logger for the sibling modules (adapter consumer, capability
|
|
3
|
+
* preflight, metrics). server.js itself stays on console.* (frozen); the new
|
|
4
|
+
* sibling plane logs structured JSON via pino so operators get parseable output
|
|
5
|
+
* and honours the manifest-declared LOG_LEVEL (see docker-compose/helm), which
|
|
6
|
+
* the legacy console.* path never read.
|
|
7
|
+
*/
|
|
8
|
+
import pino from 'pino';
|
|
9
|
+
|
|
10
|
+
export const logger = pino({
|
|
11
|
+
level: process.env.LOG_LEVEL || 'info',
|
|
12
|
+
base: { svc: 'local-agent' },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export default logger;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-metrics (sibling module). Exposes GET /metrics on the MAIN app (port
|
|
3
|
+
* 5055) because Helm already scrapes 5055/metrics (helm/values.yaml). Uses a
|
|
4
|
+
* dedicated prom-client Registry so it never collides with anything else.
|
|
5
|
+
*
|
|
6
|
+
* Minimum series (per task):
|
|
7
|
+
* agent_adapter_events_received_total{adapter} counter
|
|
8
|
+
* agent_adapter_events_forwarded_total counter
|
|
9
|
+
* agent_adapter_events_forward_failures_total{reason,code} counter
|
|
10
|
+
* reason is class-based: "contract" | "transport" | "malformed" so a
|
|
11
|
+
* Prometheus rule can alert on each class differently; code carries the
|
|
12
|
+
* HTTP status / "network" for detail.
|
|
13
|
+
* agent_mode{mode} gauge (1 = active)
|
|
14
|
+
* plus agent_adapter_events_dropped_total (backpressure), agent_adapter_events_
|
|
15
|
+
* suppressed_total{adapter} (burst suppression) and default runtime metrics.
|
|
16
|
+
*/
|
|
17
|
+
import client from 'prom-client';
|
|
18
|
+
import { logger } from './logger.js';
|
|
19
|
+
|
|
20
|
+
const MODES = ['FULL_LOCAL', 'SPLIT_PLANE', 'DETECT_ONLY'];
|
|
21
|
+
|
|
22
|
+
export function startMetrics(app, { register } = {}) {
|
|
23
|
+
const registry = register || new client.Registry();
|
|
24
|
+
registry.setDefaultLabels({ service: 'local-agent' });
|
|
25
|
+
try {
|
|
26
|
+
client.collectDefaultMetrics({ register: registry });
|
|
27
|
+
} catch {
|
|
28
|
+
// default metrics already collected on this registry — ignore
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const eventsReceived = new client.Counter({
|
|
32
|
+
name: 'agent_adapter_events_received_total',
|
|
33
|
+
help: 'Adapter error events received by the consumer',
|
|
34
|
+
labelNames: ['adapter'],
|
|
35
|
+
registers: [registry],
|
|
36
|
+
});
|
|
37
|
+
const eventsForwarded = new client.Counter({
|
|
38
|
+
name: 'agent_adapter_events_forwarded_total',
|
|
39
|
+
help: 'Adapter events successfully forwarded to the gateway',
|
|
40
|
+
registers: [registry],
|
|
41
|
+
});
|
|
42
|
+
const forwardFailures = new client.Counter({
|
|
43
|
+
name: 'agent_adapter_events_forward_failures_total',
|
|
44
|
+
help: 'Adapter events that failed to forward, by class (reason) + status (code)',
|
|
45
|
+
labelNames: ['reason', 'code'],
|
|
46
|
+
registers: [registry],
|
|
47
|
+
});
|
|
48
|
+
const eventsDropped = new client.Counter({
|
|
49
|
+
name: 'agent_adapter_events_dropped_total',
|
|
50
|
+
help: 'Adapter events evicted under backpressure',
|
|
51
|
+
registers: [registry],
|
|
52
|
+
});
|
|
53
|
+
const eventsSuppressed = new client.Counter({
|
|
54
|
+
name: 'agent_adapter_events_suppressed_total',
|
|
55
|
+
help: 'Adapter events suppressed by the local burst limiter (not forwarded)',
|
|
56
|
+
labelNames: ['adapter'],
|
|
57
|
+
registers: [registry],
|
|
58
|
+
});
|
|
59
|
+
const modeGauge = new client.Gauge({
|
|
60
|
+
name: 'agent_mode',
|
|
61
|
+
help: 'Derived operating mode (1 = active) — FULL_LOCAL|SPLIT_PLANE|DETECT_ONLY',
|
|
62
|
+
labelNames: ['mode'],
|
|
63
|
+
registers: [registry],
|
|
64
|
+
});
|
|
65
|
+
// Initialise every mode label to 0 so the series exist before the preflight runs.
|
|
66
|
+
for (const m of MODES) modeGauge.set({ mode: m }, 0);
|
|
67
|
+
|
|
68
|
+
if (app && typeof app.get === 'function') {
|
|
69
|
+
app.get('/metrics', async (_req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
res.set('Content-Type', registry.contentType);
|
|
72
|
+
res.end(await registry.metrics());
|
|
73
|
+
} catch (err) {
|
|
74
|
+
res.status(500).end(`# metrics error: ${err.message}`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
logger.info('[metrics] serving GET /metrics on the main app (5055)');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
registry,
|
|
82
|
+
incReceived: (adapter) => eventsReceived.inc({ adapter: adapter || 'unknown' }),
|
|
83
|
+
incForwarded: () => eventsForwarded.inc(),
|
|
84
|
+
incFailure: (reason, code) => forwardFailures.inc({ reason: reason || 'unknown', code: code || 'none' }),
|
|
85
|
+
incDropped: () => eventsDropped.inc(),
|
|
86
|
+
incSuppressed: (adapter) => eventsSuppressed.inc({ adapter: adapter || 'unknown' }),
|
|
87
|
+
setMode: (mode) => {
|
|
88
|
+
for (const m of MODES) modeGauge.set({ mode: m }, m === mode ? 1 : 0);
|
|
89
|
+
if (!MODES.includes(mode)) modeGauge.set({ mode: mode || 'UNKNOWN' }, 1);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default startMetrics;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-auth.js — authentication perimeter for the local agent's HTTP API.
|
|
3
|
+
*
|
|
4
|
+
* WHY. The agent exposes ~100 routes that read/write the workspace and run
|
|
5
|
+
* shell commands (/workspace/exec, /setup/run-command, /mcp/execute-command).
|
|
6
|
+
* It runs INSIDE customer environments, and before this module every one of
|
|
7
|
+
* those routes was unauthenticated on 0.0.0.0 — anyone on the customer LAN
|
|
8
|
+
* (or any web page, via the permissive CORS) had remote code execution.
|
|
9
|
+
*
|
|
10
|
+
* MODES (resolved once at boot from env):
|
|
11
|
+
* token LOCAL_AGENT_TOKEN is set: every non-public route needs
|
|
12
|
+
* `X-Agent-Token: <secret>` (constant-time compare), from any
|
|
13
|
+
* source. The Authorization header is NOT used, because on Cloud
|
|
14
|
+
* Run it carries the Google ID token the gateway mints.
|
|
15
|
+
* token (also) AGENT_SHARED_SECRET is set and LOCAL_AGENT_TOKEN is not:
|
|
16
|
+
* the gateway sets that secret on BYOC ACI agents and its
|
|
17
|
+
* AgentSharedSecretFilter sends it as X-Agent-Token on every
|
|
18
|
+
* call, so it is enforced (security review 2026-10-11: before,
|
|
19
|
+
* it was only forwarded on in-process calls).
|
|
20
|
+
* compat Cloud Run only (K_SERVICE set) when no token is configured:
|
|
21
|
+
* Cloud Run IAM (run.invoker) is the perimeter, the service must
|
|
22
|
+
* NOT be allUsers-invokable. Also reachable by an explicit
|
|
23
|
+
* LOCAL_AGENT_AUTH=compat anywhere (loud opt-out for callers that
|
|
24
|
+
* still do not send X-Agent-Token). A foreign browser Origin is
|
|
25
|
+
* refused in every mode but off.
|
|
26
|
+
* loopback Default outside Cloud Run when no token is configured
|
|
27
|
+
* (docker-compose / installer publish 127.0.0.1 and the gateway
|
|
28
|
+
* reaches the agent through the reverse WS tunnel, whose hop
|
|
29
|
+
* calls are loopback): only loopback socket + loopback Host (DNS
|
|
30
|
+
* rebinding) + no foreign Origin. Previously the default here was
|
|
31
|
+
* compat, i.e. unauthenticated shell exec for anyone who could
|
|
32
|
+
* reach the port (Helm ClusterIP, BYOC VMs). Also LOCAL_AGENT_AUTH=loopback.
|
|
33
|
+
* off LOCAL_AGENT_AUTH=off: explicit, loud opt-out.
|
|
34
|
+
*
|
|
35
|
+
* Public in every mode: GET /health and GET /metrics (probes / scrapes).
|
|
36
|
+
*/
|
|
37
|
+
import crypto from "node:crypto";
|
|
38
|
+
|
|
39
|
+
export const AGENT_TOKEN_HEADER = "x-agent-token";
|
|
40
|
+
|
|
41
|
+
const PUBLIC_PATHS = new Set(["/health", "/metrics"]);
|
|
42
|
+
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
43
|
+
|
|
44
|
+
/** Browser origins allowed to call the agent (dev tooling only). */
|
|
45
|
+
export const DEFAULT_ALLOWED_ORIGINS = Object.freeze([
|
|
46
|
+
"http://localhost:3010",
|
|
47
|
+
"http://localhost:3000",
|
|
48
|
+
"http://localhost:8085",
|
|
49
|
+
"http://127.0.0.1:3010",
|
|
50
|
+
"http://127.0.0.1:3000",
|
|
51
|
+
"http://127.0.0.1:8085",
|
|
52
|
+
"https://deepdebug.ai",
|
|
53
|
+
"https://app.deepdebug.ai",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
export function resolveAuthConfig(env = process.env) {
|
|
57
|
+
const requested = (env.LOCAL_AGENT_AUTH || "").toLowerCase();
|
|
58
|
+
if (requested === "off") return { mode: "off", token: null };
|
|
59
|
+
const enforced = (env.LOCAL_AGENT_TOKEN || "").trim();
|
|
60
|
+
if (enforced) return { mode: "token", token: enforced };
|
|
61
|
+
if (requested === "loopback") return { mode: "loopback", token: null };
|
|
62
|
+
const shared = (env.AGENT_SHARED_SECRET || "").trim();
|
|
63
|
+
const cloudRun = Boolean(env.K_SERVICE);
|
|
64
|
+
if (requested === "compat") return { mode: "compat", token: shared || null, cloudRun, explicit: true };
|
|
65
|
+
if (shared) return { mode: "token", token: shared, source: "AGENT_SHARED_SECRET" };
|
|
66
|
+
if (cloudRun) return { mode: "compat", token: null, cloudRun: true };
|
|
67
|
+
return { mode: "loopback", token: null, implicit: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function allowedOrigins(env = process.env) {
|
|
71
|
+
const extra = (env.LOCAL_AGENT_CORS_ORIGINS || "")
|
|
72
|
+
.split(",").map(s => s.trim()).filter(Boolean);
|
|
73
|
+
return new Set([...DEFAULT_ALLOWED_ORIGINS, ...extra]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isLoopbackAddress(addr) {
|
|
77
|
+
if (!addr || typeof addr !== "string") return false;
|
|
78
|
+
const a = addr.startsWith("::ffff:") ? addr.slice(7) : addr;
|
|
79
|
+
return a === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(a);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isLoopbackHostHeader(host) {
|
|
83
|
+
if (!host || typeof host !== "string") return false;
|
|
84
|
+
let h = host.trim().toLowerCase();
|
|
85
|
+
if (h.startsWith("[")) {
|
|
86
|
+
const end = h.indexOf("]");
|
|
87
|
+
h = end > 0 ? h.slice(0, end + 1) : h;
|
|
88
|
+
} else {
|
|
89
|
+
h = h.split(":")[0];
|
|
90
|
+
}
|
|
91
|
+
return LOOPBACK_HOSTNAMES.has(h);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function tokensEqual(a, b) {
|
|
95
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
96
|
+
const ha = crypto.createHash("sha256").update(a).digest();
|
|
97
|
+
const hb = crypto.createHash("sha256").update(b).digest();
|
|
98
|
+
return crypto.timingSafeEqual(ha, hb) && a.length === b.length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Pure decision function (unit-tested). Returns { ok, status?, reason? }.
|
|
103
|
+
* req-like: { method, path, headers, remoteAddress }
|
|
104
|
+
*/
|
|
105
|
+
export function authorize(reqLike, cfg, origins = allowedOrigins()) {
|
|
106
|
+
const method = (reqLike.method || "GET").toUpperCase();
|
|
107
|
+
const p = reqLike.path || "/";
|
|
108
|
+
if ((method === "GET" || method === "HEAD") && PUBLIC_PATHS.has(p)) return { ok: true };
|
|
109
|
+
if (method === "OPTIONS") return { ok: true }; // CORS preflight; CORS layer decides
|
|
110
|
+
|
|
111
|
+
const headers = reqLike.headers || {};
|
|
112
|
+
if (cfg.mode !== "off" && headers.origin && !origins.has(headers.origin)) {
|
|
113
|
+
// No browser page needs the agent directly; a foreign Origin is a
|
|
114
|
+
// cross-site request (CSRF / drive-by) — refuse before any side effect.
|
|
115
|
+
return { ok: false, status: 403, reason: "browser origin not allowed" };
|
|
116
|
+
}
|
|
117
|
+
switch (cfg.mode) {
|
|
118
|
+
case "off":
|
|
119
|
+
case "compat":
|
|
120
|
+
return { ok: true };
|
|
121
|
+
case "token": {
|
|
122
|
+
const presented = headers[AGENT_TOKEN_HEADER];
|
|
123
|
+
if (tokensEqual(typeof presented === "string" ? presented : "", cfg.token)) return { ok: true };
|
|
124
|
+
return { ok: false, status: 401, reason: "missing or invalid X-Agent-Token" };
|
|
125
|
+
}
|
|
126
|
+
case "loopback":
|
|
127
|
+
default: {
|
|
128
|
+
if (!isLoopbackAddress(reqLike.remoteAddress)) {
|
|
129
|
+
return { ok: false, status: 401, reason: "remote access requires AGENT_SHARED_SECRET/LOCAL_AGENT_TOKEN" };
|
|
130
|
+
}
|
|
131
|
+
if (!isLoopbackHostHeader(headers.host)) {
|
|
132
|
+
return { ok: false, status: 403, reason: "non-loopback Host header rejected" };
|
|
133
|
+
}
|
|
134
|
+
return { ok: true };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Express middleware. Mount BEFORE any route. */
|
|
140
|
+
export function createAuthMiddleware(cfg = resolveAuthConfig(), origins = allowedOrigins()) {
|
|
141
|
+
return function agentAuth(req, res, next) {
|
|
142
|
+
const decision = authorize({
|
|
143
|
+
method: req.method,
|
|
144
|
+
path: req.path,
|
|
145
|
+
headers: req.headers,
|
|
146
|
+
remoteAddress: req.socket?.remoteAddress,
|
|
147
|
+
}, cfg, origins);
|
|
148
|
+
if (decision.ok) return next();
|
|
149
|
+
console.warn(`[agent-auth] ${decision.status} ${req.method} ${req.path} from ${req.socket?.remoteAddress}: ${decision.reason}`);
|
|
150
|
+
return res.status(decision.status).json({ ok: false, error: "unauthorized", reason: decision.reason });
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Headers the agent's own in-process callers (WS tunnel) must send. */
|
|
155
|
+
export function internalAuthHeaders(cfg = resolveAuthConfig()) {
|
|
156
|
+
return cfg.token ? { "X-Agent-Token": cfg.token } : {};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** CORS origin callback: exact allowlist only, no wildcard hosting domains. */
|
|
160
|
+
export function corsOriginCallback(origins = allowedOrigins()) {
|
|
161
|
+
return function (origin, callback) {
|
|
162
|
+
if (!origin) return callback(null, true); // server-to-server
|
|
163
|
+
if (origins.has(origin)) return callback(null, true);
|
|
164
|
+
console.warn(`CORS: blocked origin: ${origin}`);
|
|
165
|
+
return callback(null, false);
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function describeAuthMode(cfg) {
|
|
170
|
+
switch (cfg.mode) {
|
|
171
|
+
case "token": return cfg.source === "AGENT_SHARED_SECRET"
|
|
172
|
+
? "token (X-Agent-Token required; enforcing AGENT_SHARED_SECRET)"
|
|
173
|
+
: "token (X-Agent-Token required)";
|
|
174
|
+
case "compat":
|
|
175
|
+
return "compat — token not enforced: remote callers are unauthenticated "
|
|
176
|
+
+ (cfg.explicit ? "(LOCAL_AGENT_AUTH=compat opt-out; set LOCAL_AGENT_TOKEN instead)"
|
|
177
|
+
: "(Cloud Run: relying on IAM — ensure the service is NOT allUsers-invokable)");
|
|
178
|
+
case "loopback": return cfg.implicit
|
|
179
|
+
? "loopback-only (default: no LOCAL_AGENT_TOKEN/AGENT_SHARED_SECRET; remote callers refused)"
|
|
180
|
+
: "loopback-only (LOCAL_AGENT_AUTH=loopback)";
|
|
181
|
+
case "off": return "OFF — LOCAL_AGENT_AUTH=off, every route is unauthenticated";
|
|
182
|
+
default: return cfg.mode;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* approval-token.js — PolicyGate human-approval tokens (REQUIRE_HUMAN flow).
|
|
3
|
+
*
|
|
4
|
+
* A command outside the allowlist runs only when a human approved THAT exact
|
|
5
|
+
* command in PolicyGate. The gateway mints (HumanApprovalTokens.java):
|
|
6
|
+
*
|
|
7
|
+
* vhapr1.<keyId>.<base64url(payload JSON)>.<base64url(Ed25519 signature)>
|
|
8
|
+
*
|
|
9
|
+
* signed with the same platform key as request signatures, over
|
|
10
|
+
* "VH-HUMAN-APPROVAL-V1\n<keyId>\n<payload segment>". Payload:
|
|
11
|
+
* { typ: "vh-human-approval", tid, wid, sha256, iat, exp, jti, by, decision }
|
|
12
|
+
* where sha256 = hex SHA-256 of the exact command string (or of JSON.stringify
|
|
13
|
+
* (argv) for /workspace/run). Lifetime is capped at 15 minutes and each jti is
|
|
14
|
+
* single-use (shared nonce cache).
|
|
15
|
+
*/
|
|
16
|
+
import crypto from "node:crypto";
|
|
17
|
+
import { sha256Hex } from "./request-signing.js";
|
|
18
|
+
|
|
19
|
+
export const APPROVAL_PREFIX = "vhapr1";
|
|
20
|
+
export const APPROVAL_SIG_PREFIX = "VH-HUMAN-APPROVAL-V1";
|
|
21
|
+
export const MAX_APPROVAL_LIFETIME_SECONDS = 15 * 60;
|
|
22
|
+
|
|
23
|
+
const b64urlDecode = (s) => Buffer.from(String(s).replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Returns { ok: true, approvedBy, decision } or { ok: false, reason }.
|
|
27
|
+
* opts: { subject, keys: Map, identity, requestTenantId, nonces, now }
|
|
28
|
+
*/
|
|
29
|
+
export function verifyApprovalToken(token, { subject, keys, identity = {}, requestTenantId, nonces, now = () => Date.now() } = {}) {
|
|
30
|
+
if (typeof token !== "string" || token.length > 4096) return { ok: false, reason: "malformed approval token" };
|
|
31
|
+
const parts = token.split(".");
|
|
32
|
+
if (parts.length !== 4 || parts[0] !== APPROVAL_PREFIX) return { ok: false, reason: "malformed approval token" };
|
|
33
|
+
const [, keyId, payloadSeg, sigSeg] = parts;
|
|
34
|
+
const key = keys && keys.get(keyId);
|
|
35
|
+
if (!key) return { ok: false, reason: `unknown approval key id '${keyId.slice(0, 64)}'` };
|
|
36
|
+
const data = Buffer.from(`${APPROVAL_SIG_PREFIX}\n${keyId}\n${payloadSeg}`, "utf-8");
|
|
37
|
+
let good = false;
|
|
38
|
+
try { good = crypto.verify(null, data, key, b64urlDecode(sigSeg)); } catch { good = false; }
|
|
39
|
+
if (!good) return { ok: false, reason: "bad approval signature" };
|
|
40
|
+
let p;
|
|
41
|
+
try { p = JSON.parse(b64urlDecode(payloadSeg).toString("utf-8")); } catch { return { ok: false, reason: "malformed approval payload" }; }
|
|
42
|
+
if (p.typ !== "vh-human-approval") return { ok: false, reason: "not a human-approval token" };
|
|
43
|
+
const t = Math.floor(now() / 1000);
|
|
44
|
+
if (!Number.isInteger(p.exp) || !Number.isInteger(p.iat)) return { ok: false, reason: "approval token without iat/exp" };
|
|
45
|
+
if (p.exp - p.iat > MAX_APPROVAL_LIFETIME_SECONDS) return { ok: false, reason: "approval token lifetime too long" };
|
|
46
|
+
if (t > p.exp) return { ok: false, reason: "approval token expired" };
|
|
47
|
+
if (p.iat > t + 60) return { ok: false, reason: "approval token issued in the future" };
|
|
48
|
+
if (typeof p.sha256 !== "string" || p.sha256 !== sha256Hex(Buffer.from(String(subject ?? ""), "utf-8")))
|
|
49
|
+
return { ok: false, reason: "approval token is for a different command" };
|
|
50
|
+
if (identity.tenantId && p.tid !== identity.tenantId) return { ok: false, reason: "approval token tenant mismatch" };
|
|
51
|
+
if (identity.workspaceId && p.wid && p.wid !== identity.workspaceId) return { ok: false, reason: "approval token workspace mismatch" };
|
|
52
|
+
if (requestTenantId && p.tid !== requestTenantId) return { ok: false, reason: "approval token tenant differs from the signed request" };
|
|
53
|
+
if (!p.jti || !/^[A-Za-z0-9_-]{8,128}$/.test(p.jti)) return { ok: false, reason: "approval token without jti" };
|
|
54
|
+
if (nonces && !nonces.checkAndRemember(`apr:${p.jti}`)) return { ok: false, reason: "approval token already used" };
|
|
55
|
+
return { ok: true, approvedBy: p.by || null, decision: p.decision || null };
|
|
56
|
+
}
|