@darkhunt-security/endpoint-codex 0.9.8 → 0.9.10

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "darkhunt-guard",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
4
4
  "hooks": "./hooks/hooks.json",
5
5
  "description": "Darkhunt endpoint plugin for Codex CLI \u2014 full session trace capture and PreToolUse guardrails.",
6
6
  "author": {
@@ -18303,6 +18303,10 @@ function summarize(output) {
18303
18303
  return text;
18304
18304
  return `${text.slice(0, TOOL_SUMMARY_LIMIT)}\u2026 [truncated, ${text.length} chars]`;
18305
18305
  }
18306
+ function attachmentLabel(record) {
18307
+ const size = record.bytes ? `, ${Math.round(record.bytes / 1024)} KB` : "";
18308
+ return `[${record.mediaType ?? "attachment"}${size}]`;
18309
+ }
18306
18310
  var RESUMED_USER_TEXT_LIMIT = 4e3;
18307
18311
  var SessionEmitter = class {
18308
18312
  client;
@@ -18324,8 +18328,12 @@ var SessionEmitter = class {
18324
18328
  /** True while consecutive user records still belong to the same turn. */
18325
18329
  userTurnOpen = false;
18326
18330
  pendingAttachments = [];
18331
+ /** Attachments seen before the trace could be opened knowing the prompt. */
18332
+ deferred = [];
18327
18333
  pendingUsage;
18328
18334
  outputText = [];
18335
+ /** The last thing the assistant said in this pass — the trace's result. */
18336
+ lastAssistantText;
18329
18337
  count = 0;
18330
18338
  constructor(client, options) {
18331
18339
  this.client = client;
@@ -18376,6 +18384,28 @@ var SessionEmitter = class {
18376
18384
  return;
18377
18385
  }
18378
18386
  this.count++;
18387
+ if (record.kind === "attachment")
18388
+ this.pendingAttachments.push(attachmentLabel(record));
18389
+ if (record.kind === "user_message")
18390
+ this.absorbUserTurn(record);
18391
+ if (!this.trace && record.kind === "attachment") {
18392
+ this.deferred.push(record);
18393
+ return;
18394
+ }
18395
+ this.flushDeferred();
18396
+ this.place(record);
18397
+ }
18398
+ /** Open the trace on the turn held back, then replay it. No-op once the trace exists. */
18399
+ flushDeferred() {
18400
+ if (this.deferred.length === 0)
18401
+ return;
18402
+ const held = this.deferred.splice(0);
18403
+ this.ensureTrace(held[0].ts);
18404
+ for (const record of held)
18405
+ this.place(record);
18406
+ }
18407
+ /** Route one record onto its owning trace and emit whatever it is worth. */
18408
+ place(record) {
18379
18409
  const root = this.ensureTrace(record.ts);
18380
18410
  const parentTrace = record.parentUuid ? this.owners.get(record.parentUuid) : void 0;
18381
18411
  let owner = parentTrace ?? root;
@@ -18385,28 +18415,21 @@ var SessionEmitter = class {
18385
18415
  sessionId: this.options.sessionId,
18386
18416
  handoffFrom: [root.handoffToken()],
18387
18417
  startTime: record.ts,
18418
+ // A subagent's first record is the task it was given, and it has already been
18419
+ // absorbed by the time this fires — so the child trace opens with its brief
18420
+ // rather than empty.
18421
+ ...this.lastUserText ? { input: this.lastUserText } : {},
18388
18422
  ...this.options.userId !== void 0 ? { userId: this.options.userId } : {}
18389
18423
  }));
18390
18424
  this.sidechains.push(owner);
18391
18425
  }
18392
18426
  this.owners.set(record.uuid, owner);
18393
18427
  switch (record.kind) {
18394
- case "user_message": {
18428
+ case "user_message":
18395
18429
  this.endGeneration();
18396
- const part = [this.pendingAttachments.join(" "), record.text].filter(Boolean).join(" ");
18397
- this.pendingAttachments = [];
18398
- if (this.userTurnOpen) {
18399
- this.lastUserText = [this.lastUserText, part].filter(Boolean).join(" ");
18400
- } else {
18401
- this.lastUserText = part;
18402
- this.userTurnOpen = true;
18403
- this.pendingToolResults = [];
18404
- }
18405
18430
  break;
18406
- }
18407
18431
  case "attachment": {
18408
- const label = `[${record.mediaType ?? "attachment"}${record.bytes ? `, ${Math.round(record.bytes / 1024)} KB` : ""}]`;
18409
- this.pendingAttachments.push(label);
18432
+ const label = attachmentLabel(record);
18410
18433
  this.seeded(["attachment", record.uuid], () => owner.span("attachment", {
18411
18434
  startTime: record.ts,
18412
18435
  metadata: {
@@ -18463,8 +18486,26 @@ var SessionEmitter = class {
18463
18486
  break;
18464
18487
  }
18465
18488
  }
18489
+ /**
18490
+ * Fold a user record into the turn in flight.
18491
+ *
18492
+ * Split out of the `user_message` case so it can run before the trace is opened —
18493
+ * see the call site in {@link ingest}.
18494
+ */
18495
+ absorbUserTurn(record) {
18496
+ const part = [this.pendingAttachments.join(" "), record.text].filter(Boolean).join(" ");
18497
+ this.pendingAttachments = [];
18498
+ if (this.userTurnOpen) {
18499
+ this.lastUserText = [this.lastUserText, part].filter(Boolean).join(" ");
18500
+ } else {
18501
+ this.lastUserText = part;
18502
+ this.userTurnOpen = true;
18503
+ this.pendingToolResults = [];
18504
+ }
18505
+ }
18466
18506
  /** Ends everything still open and flushes. Safe to call twice. */
18467
18507
  async finish() {
18508
+ this.flushDeferred();
18468
18509
  this.endGeneration();
18469
18510
  for (const span of this.toolSpans.values())
18470
18511
  span.end();
@@ -18475,7 +18516,10 @@ var SessionEmitter = class {
18475
18516
  if (this.trace) {
18476
18517
  this.trace.update({
18477
18518
  metadata: this.meta,
18478
- ...this.title !== void 0 ? { name: this.title } : {}
18519
+ ...this.title !== void 0 ? { name: this.title } : {},
18520
+ // The pass's result. Paired with the `input` set when the trace opened, this is
18521
+ // what stops the consumer inferring the trace's I/O from a descendant.
18522
+ ...this.lastAssistantText !== void 0 ? { output: this.lastAssistantText } : {}
18479
18523
  });
18480
18524
  this.trace.end();
18481
18525
  this.trace = void 0;
@@ -18513,6 +18557,9 @@ var SessionEmitter = class {
18513
18557
  return this.options.parentAgentId ?? this.meta["parentSessionId"];
18514
18558
  }
18515
18559
  defaultTraceName() {
18560
+ const label = this.options.workflow?.label;
18561
+ if (label)
18562
+ return label;
18516
18563
  const agentType = this.agentType();
18517
18564
  if (agentType)
18518
18565
  return `subagent: ${agentType}`;
@@ -18521,6 +18568,27 @@ var SessionEmitter = class {
18521
18568
  }
18522
18569
  return this.options.agentType ? `subagent: ${this.options.agentType}` : `subagent ${this.options.agentId}`;
18523
18570
  }
18571
+ /**
18572
+ * The run this agent belongs to, flattened onto the trace.
18573
+ *
18574
+ * Flat scalars rather than a nested object: metadata is lifted onto the span one
18575
+ * attribute at a time downstream, so a nested value would arrive as a JSON blob that
18576
+ * nothing can group or filter on. `workflow_id` is the one that matters — it is what
18577
+ * lets a consumer collect the agents of one run out of a session that ran several.
18578
+ */
18579
+ workflowMetadata() {
18580
+ const workflow = this.options.workflow;
18581
+ if (!workflow)
18582
+ return {};
18583
+ return {
18584
+ workflow_id: workflow.id,
18585
+ ...workflow.name !== void 0 ? { workflow_name: workflow.name } : {},
18586
+ ...workflow.label !== void 0 ? { workflow_label: workflow.label } : {},
18587
+ ...workflow.phase !== void 0 ? { workflow_phase: workflow.phase } : {},
18588
+ ...workflow.phaseIndex !== void 0 ? { workflow_phase_index: workflow.phaseIndex } : {},
18589
+ ...workflow.attempt !== void 0 ? { workflow_attempt: workflow.attempt } : {}
18590
+ };
18591
+ }
18524
18592
  /** The session traces group under. Set from the path, or from `session_meta`. */
18525
18593
  grouping;
18526
18594
  ensureTrace(ts) {
@@ -18531,6 +18599,10 @@ var SessionEmitter = class {
18531
18599
  name: this.title ?? this.defaultTraceName(),
18532
18600
  sessionId: this.grouping ?? this.options.sessionId,
18533
18601
  startTime: ts,
18602
+ // What the agent was asked. `TraceArgs.input` is construction-only — the SDK's
18603
+ // `update()` carries output alone — which is why `ingest` absorbs the user turn
18604
+ // before it calls this.
18605
+ ...this.lastUserText ? { input: this.lastUserText } : {},
18534
18606
  tags: [this.options.vendor, ...this.options.agentId ? ["subagent"] : []],
18535
18607
  metadata: {
18536
18608
  ...this.meta,
@@ -18541,7 +18613,10 @@ var SessionEmitter = class {
18541
18613
  ...this.agentId() !== void 0 ? { agent_id: this.agentId() } : {},
18542
18614
  ...this.agentType() !== void 0 ? { agent_type: this.agentType() } : {},
18543
18615
  ...this.parentAgentId() !== void 0 ? { parent_agent_id: this.parentAgentId() } : {},
18544
- ...this.options.spawnDepth !== void 0 ? { spawn_depth: this.options.spawnDepth } : {}
18616
+ ...this.options.spawnDepth !== void 0 ? { spawn_depth: this.options.spawnDepth } : {},
18617
+ // snake_case for the same reason as `agent_id` above: trace-hub lifts each
18618
+ // key through unchanged, and these are read alongside it.
18619
+ ...this.workflowMetadata()
18545
18620
  },
18546
18621
  ...this.options.userId !== void 0 ? { userId: this.options.userId } : {},
18547
18622
  ...this.options.userEmail !== void 0 ? { userEmail: this.options.userEmail } : {},
@@ -18596,6 +18671,8 @@ var SessionEmitter = class {
18596
18671
  if (!this.generation)
18597
18672
  return;
18598
18673
  const output = this.outputText.join("\n");
18674
+ if (output)
18675
+ this.lastAssistantText = output;
18599
18676
  this.generation.end({
18600
18677
  ...output ? { outputMessages: [{ role: "assistant", content: output }] } : {},
18601
18678
  ...this.model !== void 0 ? { model: this.model } : {},
@@ -21696,6 +21773,7 @@ async function runForwarder(mapper, options = {}) {
21696
21773
  ...sub?.agentType !== void 0 ? { agentType: sub.agentType } : {},
21697
21774
  ...sub?.parentAgentId !== void 0 ? { parentAgentId: sub.parentAgentId } : {},
21698
21775
  ...sub?.spawnDepth !== void 0 ? { spawnDepth: sub.spawnDepth } : {},
21776
+ ...sub?.workflow !== void 0 ? { workflow: sub.workflow } : {},
21699
21777
  ...ids !== void 0 ? { ids } : {},
21700
21778
  ...userId !== void 0 ? { userId } : {},
21701
21779
  ...carried !== void 0 ? { resumeTurn: carried } : {}
@@ -21824,6 +21902,7 @@ import { join as join9 } from "node:path";
21824
21902
  var CLI_CREDENTIALS_PATH = join9(homedir3(), ".darkhunt", "credentials.json");
21825
21903
 
21826
21904
  // adapters/codex/dist/transcript.js
21905
+ import { readFileSync as readFileSync6 } from "node:fs";
21827
21906
  import { basename, join as join10 } from "node:path";
21828
21907
  import { homedir as homedir4 } from "node:os";
21829
21908
  function str(value) {
@@ -21860,17 +21939,62 @@ function textOf(content) {
21860
21939
  const parts = content.map((b) => b && typeof b === "object" ? str(b["text"]) : void 0).filter((t) => !!t);
21861
21940
  return parts.length > 0 ? parts.join("\n") : void 0;
21862
21941
  }
21942
+ function jwtClaims(token) {
21943
+ const parts = token.split(".");
21944
+ if (parts.length !== 3 || !parts[1])
21945
+ return void 0;
21946
+ try {
21947
+ const json = Buffer.from(parts[1], "base64url").toString("utf8");
21948
+ const claims = JSON.parse(json);
21949
+ return claims && typeof claims === "object" ? claims : void 0;
21950
+ } catch {
21951
+ return void 0;
21952
+ }
21953
+ }
21954
+ function emailFromAuth(raw) {
21955
+ let parsed;
21956
+ try {
21957
+ parsed = JSON.parse(raw);
21958
+ } catch {
21959
+ return void 0;
21960
+ }
21961
+ const tokens = parsed?.tokens;
21962
+ const token = tokens?.id_token;
21963
+ if (typeof token !== "string" || token === "")
21964
+ return void 0;
21965
+ const email = jwtClaims(token)?.["email"];
21966
+ return typeof email === "string" && email !== "" ? email : void 0;
21967
+ }
21863
21968
  var codexTranscript = {
21864
21969
  vendor: "codex",
21865
21970
  sessionRoots() {
21866
21971
  return [join10(homedir4(), ".codex", "sessions")];
21867
21972
  },
21868
- // No `resolveUserId` yet, so Codex traces fall back to the configured `userId`.
21869
- // The identity exists but not as a plain field: `~/.codex/auth.json` holds
21870
- // `tokens.account_id` (a UUID, not a person) and an `email` claim inside the
21871
- // `id_token` JWT. Reading it means decoding an OAuth token and honouring its expiry
21872
- // a stale token would attribute sessions to a *wrong* address, which is worse than
21873
- // leaving them unattributed. Tracked separately.
21973
+ /**
21974
+ * `~/.codex/auth.json` -> the `email` claim of `tokens.id_token`.
21975
+ *
21976
+ * Codex records no identity in the rollout itself and none in plain text anywhere
21977
+ * else: `tokens.account_id` is a UUID, not a person. The address is a claim inside the
21978
+ * OAuth id_token, so reading it means decoding that token — hence {@link jwtClaims}.
21979
+ *
21980
+ * The token's own `exp` is deliberately not enforced. It is an hour long and the file
21981
+ * is only rewritten on sign-in and on refresh, so gating on expiry would leave most
21982
+ * sessions unattributed while answering a question nobody asked: an expired id_token
21983
+ * is evidence that a token needs refreshing, never evidence that a different person is
21984
+ * now signed in. Signing in as someone else rewrites this file, which is what makes
21985
+ * the stale-address worry unfounded — the claim always names the last account to
21986
+ * authenticate on this machine, which is the account that wrote these rollouts.
21987
+ *
21988
+ * `undefined` for API-key auth (`OPENAI_API_KEY` with no tokens), for a signed-out
21989
+ * agent, and for anything unreadable; the caller then falls back to configuration.
21990
+ */
21991
+ resolveUserId() {
21992
+ try {
21993
+ return emailFromAuth(readFileSync6(join10(homedir4(), ".codex", "auth.json"), "utf8"));
21994
+ } catch {
21995
+ return void 0;
21996
+ }
21997
+ },
21874
21998
  sessionIdFor(path) {
21875
21999
  const name = basename(path, ".jsonl");
21876
22000
  const match = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.exec(name);
@@ -18303,6 +18303,10 @@ function summarize(output) {
18303
18303
  return text;
18304
18304
  return `${text.slice(0, TOOL_SUMMARY_LIMIT)}\u2026 [truncated, ${text.length} chars]`;
18305
18305
  }
18306
+ function attachmentLabel(record) {
18307
+ const size = record.bytes ? `, ${Math.round(record.bytes / 1024)} KB` : "";
18308
+ return `[${record.mediaType ?? "attachment"}${size}]`;
18309
+ }
18306
18310
  var RESUMED_USER_TEXT_LIMIT = 4e3;
18307
18311
  var SessionEmitter = class {
18308
18312
  client;
@@ -18324,8 +18328,12 @@ var SessionEmitter = class {
18324
18328
  /** True while consecutive user records still belong to the same turn. */
18325
18329
  userTurnOpen = false;
18326
18330
  pendingAttachments = [];
18331
+ /** Attachments seen before the trace could be opened knowing the prompt. */
18332
+ deferred = [];
18327
18333
  pendingUsage;
18328
18334
  outputText = [];
18335
+ /** The last thing the assistant said in this pass — the trace's result. */
18336
+ lastAssistantText;
18329
18337
  count = 0;
18330
18338
  constructor(client, options) {
18331
18339
  this.client = client;
@@ -18376,6 +18384,28 @@ var SessionEmitter = class {
18376
18384
  return;
18377
18385
  }
18378
18386
  this.count++;
18387
+ if (record.kind === "attachment")
18388
+ this.pendingAttachments.push(attachmentLabel(record));
18389
+ if (record.kind === "user_message")
18390
+ this.absorbUserTurn(record);
18391
+ if (!this.trace && record.kind === "attachment") {
18392
+ this.deferred.push(record);
18393
+ return;
18394
+ }
18395
+ this.flushDeferred();
18396
+ this.place(record);
18397
+ }
18398
+ /** Open the trace on the turn held back, then replay it. No-op once the trace exists. */
18399
+ flushDeferred() {
18400
+ if (this.deferred.length === 0)
18401
+ return;
18402
+ const held = this.deferred.splice(0);
18403
+ this.ensureTrace(held[0].ts);
18404
+ for (const record of held)
18405
+ this.place(record);
18406
+ }
18407
+ /** Route one record onto its owning trace and emit whatever it is worth. */
18408
+ place(record) {
18379
18409
  const root = this.ensureTrace(record.ts);
18380
18410
  const parentTrace = record.parentUuid ? this.owners.get(record.parentUuid) : void 0;
18381
18411
  let owner = parentTrace ?? root;
@@ -18385,28 +18415,21 @@ var SessionEmitter = class {
18385
18415
  sessionId: this.options.sessionId,
18386
18416
  handoffFrom: [root.handoffToken()],
18387
18417
  startTime: record.ts,
18418
+ // A subagent's first record is the task it was given, and it has already been
18419
+ // absorbed by the time this fires — so the child trace opens with its brief
18420
+ // rather than empty.
18421
+ ...this.lastUserText ? { input: this.lastUserText } : {},
18388
18422
  ...this.options.userId !== void 0 ? { userId: this.options.userId } : {}
18389
18423
  }));
18390
18424
  this.sidechains.push(owner);
18391
18425
  }
18392
18426
  this.owners.set(record.uuid, owner);
18393
18427
  switch (record.kind) {
18394
- case "user_message": {
18428
+ case "user_message":
18395
18429
  this.endGeneration();
18396
- const part = [this.pendingAttachments.join(" "), record.text].filter(Boolean).join(" ");
18397
- this.pendingAttachments = [];
18398
- if (this.userTurnOpen) {
18399
- this.lastUserText = [this.lastUserText, part].filter(Boolean).join(" ");
18400
- } else {
18401
- this.lastUserText = part;
18402
- this.userTurnOpen = true;
18403
- this.pendingToolResults = [];
18404
- }
18405
18430
  break;
18406
- }
18407
18431
  case "attachment": {
18408
- const label = `[${record.mediaType ?? "attachment"}${record.bytes ? `, ${Math.round(record.bytes / 1024)} KB` : ""}]`;
18409
- this.pendingAttachments.push(label);
18432
+ const label = attachmentLabel(record);
18410
18433
  this.seeded(["attachment", record.uuid], () => owner.span("attachment", {
18411
18434
  startTime: record.ts,
18412
18435
  metadata: {
@@ -18463,8 +18486,26 @@ var SessionEmitter = class {
18463
18486
  break;
18464
18487
  }
18465
18488
  }
18489
+ /**
18490
+ * Fold a user record into the turn in flight.
18491
+ *
18492
+ * Split out of the `user_message` case so it can run before the trace is opened —
18493
+ * see the call site in {@link ingest}.
18494
+ */
18495
+ absorbUserTurn(record) {
18496
+ const part = [this.pendingAttachments.join(" "), record.text].filter(Boolean).join(" ");
18497
+ this.pendingAttachments = [];
18498
+ if (this.userTurnOpen) {
18499
+ this.lastUserText = [this.lastUserText, part].filter(Boolean).join(" ");
18500
+ } else {
18501
+ this.lastUserText = part;
18502
+ this.userTurnOpen = true;
18503
+ this.pendingToolResults = [];
18504
+ }
18505
+ }
18466
18506
  /** Ends everything still open and flushes. Safe to call twice. */
18467
18507
  async finish() {
18508
+ this.flushDeferred();
18468
18509
  this.endGeneration();
18469
18510
  for (const span of this.toolSpans.values())
18470
18511
  span.end();
@@ -18475,7 +18516,10 @@ var SessionEmitter = class {
18475
18516
  if (this.trace) {
18476
18517
  this.trace.update({
18477
18518
  metadata: this.meta,
18478
- ...this.title !== void 0 ? { name: this.title } : {}
18519
+ ...this.title !== void 0 ? { name: this.title } : {},
18520
+ // The pass's result. Paired with the `input` set when the trace opened, this is
18521
+ // what stops the consumer inferring the trace's I/O from a descendant.
18522
+ ...this.lastAssistantText !== void 0 ? { output: this.lastAssistantText } : {}
18479
18523
  });
18480
18524
  this.trace.end();
18481
18525
  this.trace = void 0;
@@ -18513,6 +18557,9 @@ var SessionEmitter = class {
18513
18557
  return this.options.parentAgentId ?? this.meta["parentSessionId"];
18514
18558
  }
18515
18559
  defaultTraceName() {
18560
+ const label = this.options.workflow?.label;
18561
+ if (label)
18562
+ return label;
18516
18563
  const agentType = this.agentType();
18517
18564
  if (agentType)
18518
18565
  return `subagent: ${agentType}`;
@@ -18521,6 +18568,27 @@ var SessionEmitter = class {
18521
18568
  }
18522
18569
  return this.options.agentType ? `subagent: ${this.options.agentType}` : `subagent ${this.options.agentId}`;
18523
18570
  }
18571
+ /**
18572
+ * The run this agent belongs to, flattened onto the trace.
18573
+ *
18574
+ * Flat scalars rather than a nested object: metadata is lifted onto the span one
18575
+ * attribute at a time downstream, so a nested value would arrive as a JSON blob that
18576
+ * nothing can group or filter on. `workflow_id` is the one that matters — it is what
18577
+ * lets a consumer collect the agents of one run out of a session that ran several.
18578
+ */
18579
+ workflowMetadata() {
18580
+ const workflow = this.options.workflow;
18581
+ if (!workflow)
18582
+ return {};
18583
+ return {
18584
+ workflow_id: workflow.id,
18585
+ ...workflow.name !== void 0 ? { workflow_name: workflow.name } : {},
18586
+ ...workflow.label !== void 0 ? { workflow_label: workflow.label } : {},
18587
+ ...workflow.phase !== void 0 ? { workflow_phase: workflow.phase } : {},
18588
+ ...workflow.phaseIndex !== void 0 ? { workflow_phase_index: workflow.phaseIndex } : {},
18589
+ ...workflow.attempt !== void 0 ? { workflow_attempt: workflow.attempt } : {}
18590
+ };
18591
+ }
18524
18592
  /** The session traces group under. Set from the path, or from `session_meta`. */
18525
18593
  grouping;
18526
18594
  ensureTrace(ts) {
@@ -18531,6 +18599,10 @@ var SessionEmitter = class {
18531
18599
  name: this.title ?? this.defaultTraceName(),
18532
18600
  sessionId: this.grouping ?? this.options.sessionId,
18533
18601
  startTime: ts,
18602
+ // What the agent was asked. `TraceArgs.input` is construction-only — the SDK's
18603
+ // `update()` carries output alone — which is why `ingest` absorbs the user turn
18604
+ // before it calls this.
18605
+ ...this.lastUserText ? { input: this.lastUserText } : {},
18534
18606
  tags: [this.options.vendor, ...this.options.agentId ? ["subagent"] : []],
18535
18607
  metadata: {
18536
18608
  ...this.meta,
@@ -18541,7 +18613,10 @@ var SessionEmitter = class {
18541
18613
  ...this.agentId() !== void 0 ? { agent_id: this.agentId() } : {},
18542
18614
  ...this.agentType() !== void 0 ? { agent_type: this.agentType() } : {},
18543
18615
  ...this.parentAgentId() !== void 0 ? { parent_agent_id: this.parentAgentId() } : {},
18544
- ...this.options.spawnDepth !== void 0 ? { spawn_depth: this.options.spawnDepth } : {}
18616
+ ...this.options.spawnDepth !== void 0 ? { spawn_depth: this.options.spawnDepth } : {},
18617
+ // snake_case for the same reason as `agent_id` above: trace-hub lifts each
18618
+ // key through unchanged, and these are read alongside it.
18619
+ ...this.workflowMetadata()
18545
18620
  },
18546
18621
  ...this.options.userId !== void 0 ? { userId: this.options.userId } : {},
18547
18622
  ...this.options.userEmail !== void 0 ? { userEmail: this.options.userEmail } : {},
@@ -18596,6 +18671,8 @@ var SessionEmitter = class {
18596
18671
  if (!this.generation)
18597
18672
  return;
18598
18673
  const output = this.outputText.join("\n");
18674
+ if (output)
18675
+ this.lastAssistantText = output;
18599
18676
  this.generation.end({
18600
18677
  ...output ? { outputMessages: [{ role: "assistant", content: output }] } : {},
18601
18678
  ...this.model !== void 0 ? { model: this.model } : {},
@@ -21696,6 +21773,7 @@ async function runForwarder(mapper, options = {}) {
21696
21773
  ...sub?.agentType !== void 0 ? { agentType: sub.agentType } : {},
21697
21774
  ...sub?.parentAgentId !== void 0 ? { parentAgentId: sub.parentAgentId } : {},
21698
21775
  ...sub?.spawnDepth !== void 0 ? { spawnDepth: sub.spawnDepth } : {},
21776
+ ...sub?.workflow !== void 0 ? { workflow: sub.workflow } : {},
21699
21777
  ...ids !== void 0 ? { ids } : {},
21700
21778
  ...userId !== void 0 ? { userId } : {},
21701
21779
  ...carried !== void 0 ? { resumeTurn: carried } : {}
@@ -21811,6 +21889,7 @@ import { join as join9 } from "node:path";
21811
21889
  var CLI_CREDENTIALS_PATH = join9(homedir3(), ".darkhunt", "credentials.json");
21812
21890
 
21813
21891
  // adapters/codex/dist/transcript.js
21892
+ import { readFileSync as readFileSync6 } from "node:fs";
21814
21893
  import { basename, join as join10 } from "node:path";
21815
21894
  import { homedir as homedir4 } from "node:os";
21816
21895
  function str(value) {
@@ -21847,17 +21926,62 @@ function textOf(content) {
21847
21926
  const parts = content.map((b) => b && typeof b === "object" ? str(b["text"]) : void 0).filter((t) => !!t);
21848
21927
  return parts.length > 0 ? parts.join("\n") : void 0;
21849
21928
  }
21929
+ function jwtClaims(token) {
21930
+ const parts = token.split(".");
21931
+ if (parts.length !== 3 || !parts[1])
21932
+ return void 0;
21933
+ try {
21934
+ const json = Buffer.from(parts[1], "base64url").toString("utf8");
21935
+ const claims = JSON.parse(json);
21936
+ return claims && typeof claims === "object" ? claims : void 0;
21937
+ } catch {
21938
+ return void 0;
21939
+ }
21940
+ }
21941
+ function emailFromAuth(raw) {
21942
+ let parsed;
21943
+ try {
21944
+ parsed = JSON.parse(raw);
21945
+ } catch {
21946
+ return void 0;
21947
+ }
21948
+ const tokens = parsed?.tokens;
21949
+ const token = tokens?.id_token;
21950
+ if (typeof token !== "string" || token === "")
21951
+ return void 0;
21952
+ const email = jwtClaims(token)?.["email"];
21953
+ return typeof email === "string" && email !== "" ? email : void 0;
21954
+ }
21850
21955
  var codexTranscript = {
21851
21956
  vendor: "codex",
21852
21957
  sessionRoots() {
21853
21958
  return [join10(homedir4(), ".codex", "sessions")];
21854
21959
  },
21855
- // No `resolveUserId` yet, so Codex traces fall back to the configured `userId`.
21856
- // The identity exists but not as a plain field: `~/.codex/auth.json` holds
21857
- // `tokens.account_id` (a UUID, not a person) and an `email` claim inside the
21858
- // `id_token` JWT. Reading it means decoding an OAuth token and honouring its expiry
21859
- // a stale token would attribute sessions to a *wrong* address, which is worse than
21860
- // leaving them unattributed. Tracked separately.
21960
+ /**
21961
+ * `~/.codex/auth.json` -> the `email` claim of `tokens.id_token`.
21962
+ *
21963
+ * Codex records no identity in the rollout itself and none in plain text anywhere
21964
+ * else: `tokens.account_id` is a UUID, not a person. The address is a claim inside the
21965
+ * OAuth id_token, so reading it means decoding that token — hence {@link jwtClaims}.
21966
+ *
21967
+ * The token's own `exp` is deliberately not enforced. It is an hour long and the file
21968
+ * is only rewritten on sign-in and on refresh, so gating on expiry would leave most
21969
+ * sessions unattributed while answering a question nobody asked: an expired id_token
21970
+ * is evidence that a token needs refreshing, never evidence that a different person is
21971
+ * now signed in. Signing in as someone else rewrites this file, which is what makes
21972
+ * the stale-address worry unfounded — the claim always names the last account to
21973
+ * authenticate on this machine, which is the account that wrote these rollouts.
21974
+ *
21975
+ * `undefined` for API-key auth (`OPENAI_API_KEY` with no tokens), for a signed-out
21976
+ * agent, and for anything unreadable; the caller then falls back to configuration.
21977
+ */
21978
+ resolveUserId() {
21979
+ try {
21980
+ return emailFromAuth(readFileSync6(join10(homedir4(), ".codex", "auth.json"), "utf8"));
21981
+ } catch {
21982
+ return void 0;
21983
+ }
21984
+ },
21861
21985
  sessionIdFor(path) {
21862
21986
  const name = basename(path, ".jsonl");
21863
21987
  const match = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.exec(name);
@@ -3,7 +3,7 @@ import { createRequire as __dhCreateRequire } from 'node:module';
3
3
  const require = __dhCreateRequire(import.meta.url);
4
4
 
5
5
  // adapters/codex/bin/status.mjs
6
- import { readFileSync as readFileSync7 } from "node:fs";
6
+ import { readFileSync as readFileSync8 } from "node:fs";
7
7
  import { dirname, join as join12 } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
 
@@ -1228,6 +1228,7 @@ import { join as join9 } from "node:path";
1228
1228
  var CLI_CREDENTIALS_PATH = join9(homedir3(), ".darkhunt", "credentials.json");
1229
1229
 
1230
1230
  // adapters/codex/dist/transcript.js
1231
+ import { readFileSync as readFileSync6 } from "node:fs";
1231
1232
  import { basename, join as join10 } from "node:path";
1232
1233
  import { homedir as homedir4 } from "node:os";
1233
1234
  function str(value) {
@@ -1264,17 +1265,62 @@ function textOf(content) {
1264
1265
  const parts = content.map((b) => b && typeof b === "object" ? str(b["text"]) : void 0).filter((t) => !!t);
1265
1266
  return parts.length > 0 ? parts.join("\n") : void 0;
1266
1267
  }
1268
+ function jwtClaims(token) {
1269
+ const parts = token.split(".");
1270
+ if (parts.length !== 3 || !parts[1])
1271
+ return void 0;
1272
+ try {
1273
+ const json = Buffer.from(parts[1], "base64url").toString("utf8");
1274
+ const claims = JSON.parse(json);
1275
+ return claims && typeof claims === "object" ? claims : void 0;
1276
+ } catch {
1277
+ return void 0;
1278
+ }
1279
+ }
1280
+ function emailFromAuth(raw) {
1281
+ let parsed;
1282
+ try {
1283
+ parsed = JSON.parse(raw);
1284
+ } catch {
1285
+ return void 0;
1286
+ }
1287
+ const tokens = parsed?.tokens;
1288
+ const token = tokens?.id_token;
1289
+ if (typeof token !== "string" || token === "")
1290
+ return void 0;
1291
+ const email = jwtClaims(token)?.["email"];
1292
+ return typeof email === "string" && email !== "" ? email : void 0;
1293
+ }
1267
1294
  var codexTranscript = {
1268
1295
  vendor: "codex",
1269
1296
  sessionRoots() {
1270
1297
  return [join10(homedir4(), ".codex", "sessions")];
1271
1298
  },
1272
- // No `resolveUserId` yet, so Codex traces fall back to the configured `userId`.
1273
- // The identity exists but not as a plain field: `~/.codex/auth.json` holds
1274
- // `tokens.account_id` (a UUID, not a person) and an `email` claim inside the
1275
- // `id_token` JWT. Reading it means decoding an OAuth token and honouring its expiry
1276
- // a stale token would attribute sessions to a *wrong* address, which is worse than
1277
- // leaving them unattributed. Tracked separately.
1299
+ /**
1300
+ * `~/.codex/auth.json` -> the `email` claim of `tokens.id_token`.
1301
+ *
1302
+ * Codex records no identity in the rollout itself and none in plain text anywhere
1303
+ * else: `tokens.account_id` is a UUID, not a person. The address is a claim inside the
1304
+ * OAuth id_token, so reading it means decoding that token — hence {@link jwtClaims}.
1305
+ *
1306
+ * The token's own `exp` is deliberately not enforced. It is an hour long and the file
1307
+ * is only rewritten on sign-in and on refresh, so gating on expiry would leave most
1308
+ * sessions unattributed while answering a question nobody asked: an expired id_token
1309
+ * is evidence that a token needs refreshing, never evidence that a different person is
1310
+ * now signed in. Signing in as someone else rewrites this file, which is what makes
1311
+ * the stale-address worry unfounded — the claim always names the last account to
1312
+ * authenticate on this machine, which is the account that wrote these rollouts.
1313
+ *
1314
+ * `undefined` for API-key auth (`OPENAI_API_KEY` with no tokens), for a signed-out
1315
+ * agent, and for anything unreadable; the caller then falls back to configuration.
1316
+ */
1317
+ resolveUserId() {
1318
+ try {
1319
+ return emailFromAuth(readFileSync6(join10(homedir4(), ".codex", "auth.json"), "utf8"));
1320
+ } catch {
1321
+ return void 0;
1322
+ }
1323
+ },
1278
1324
  sessionIdFor(path) {
1279
1325
  const name = basename(path, ".jsonl");
1280
1326
  const match = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.exec(name);
@@ -1373,7 +1419,7 @@ var codexTranscript = {
1373
1419
  };
1374
1420
 
1375
1421
  // adapters/codex/bin/lib/launchers.mjs
1376
- import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
1422
+ import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "node:fs";
1377
1423
  import { homedir as homedir5 } from "node:os";
1378
1424
  import { join as join11 } from "node:path";
1379
1425
  var LAUNCHERS = {
@@ -1399,7 +1445,7 @@ function launcherState(binDir = BIN_DIR, path = process.env["PATH"] ?? "") {
1399
1445
  }
1400
1446
  let body = "";
1401
1447
  try {
1402
- body = readFileSync6(file, "utf8");
1448
+ body = readFileSync7(file, "utf8");
1403
1449
  } catch {
1404
1450
  }
1405
1451
  if (body !== script(target)) stale.push(name);
@@ -1437,7 +1483,7 @@ try {
1437
1483
  const root = join12(dirname(fileURLToPath(import.meta.url)), "..", "..");
1438
1484
  let installed = "unknown";
1439
1485
  try {
1440
- installed = JSON.parse(readFileSync7(join12(root, "package.json"), "utf8")).version;
1486
+ installed = JSON.parse(readFileSync8(join12(root, "package.json"), "utf8")).version;
1441
1487
  } catch {
1442
1488
  }
1443
1489
  console.log(` plugin ${installed} ${root}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@darkhunt-security/endpoint-codex",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
4
4
  "type": "module",
5
5
  "description": "Darkhunt endpoint adapter for Codex CLI: hook codec, rollout transcript mapper, plugin manifest.",
6
6
  "bin": {
@@ -13,8 +13,8 @@
13
13
  ".codex-plugin"
14
14
  ],
15
15
  "devDependencies": {
16
- "@darkhunt-security/endpoint-contracts": "0.9.1",
17
- "@darkhunt-security/endpoint-core": "0.9.1"
16
+ "@darkhunt-security/endpoint-contracts": "0.9.2",
17
+ "@darkhunt-security/endpoint-core": "0.9.2"
18
18
  },
19
19
  "publishConfig": {
20
20
  "access": "public",