@darkhunt-security/endpoint-codex 0.9.0 → 0.9.3

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.0",
3
+ "version": "0.9.3",
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": {
package/README.md CHANGED
@@ -6,9 +6,17 @@ Capture is on by default. **Enforcement ships off** and is enabled per rule.
6
6
 
7
7
  ## Install
8
8
 
9
+ Codex manages plugins from the CLI, not from a slash command — `/plugin …` is Claude
10
+ Code's. Run these in a terminal:
11
+
12
+ ```
13
+ codex plugin marketplace add darkhunt-security/darkhunt
14
+ codex plugin add darkhunt-guard@darkhunt
15
+ ```
16
+
17
+ Then, in Codex:
18
+
9
19
  ```
10
- /plugin marketplace add darkhunt-security/darkhunt-telemetry-ts
11
- /plugin install darkhunt-guard@darkhunt
12
20
  /darkhunt-guard:setup
13
21
  ```
14
22
 
@@ -21,6 +29,33 @@ anything ships.
21
29
  Capture starts on your **next** Codex CLI session — hooks are loaded when a session
22
30
  starts.
23
31
 
32
+ ### If you configured this machine from the dashboard instead
33
+
34
+ The dashboard renders a ready-made `~/.darkhunt/endpoint-config.json` to paste into your
35
+ own terminal. That is the whole setup on Claude Code; on Codex it is half of it. Codex
36
+ resolves a hook `command` as an absolute path or a PATH name, and the install directory
37
+ carries the plugin version, so this plugin's hooks are PATH launchers that something has
38
+ to write. Enrolment writes them; a pasted config cannot. Run this once after pasting it:
39
+
40
+ ```
41
+ "$(ls -d ~/.codex/plugins/cache/*/darkhunt-guard/*/dist/bin | sort -V | tail -1)"/install-hooks.mjs
42
+ ```
43
+
44
+ `/darkhunt-guard:status` reports the launchers, so you never have to take this on trust —
45
+ a machine missing them looks perfectly configured everywhere else and captures nothing.
46
+
47
+ ## Upgrade
48
+
49
+ ```
50
+ codex plugin add darkhunt-guard@darkhunt
51
+ ```
52
+
53
+ Re-adding re-resolves the published version. The new one loads on your **next** Codex
54
+ CLI session, and the launchers need no attention — they resolve the newest installed
55
+ version at run time. `/darkhunt-guard:status` prints the version actually running,
56
+ which is the only reliable answer: an update that resolved against a registry without
57
+ the new version reports success and changes nothing.
58
+
24
59
  ## What it captures
25
60
 
26
61
  The session transcript, mapped to spans: user and assistant messages, thinking blocks,
@@ -46,10 +81,11 @@ Two rules constrain it, both deliberate:
46
81
 
47
82
  ## Commands
48
83
 
49
- | | |
50
- | ------------------------ | ------------------------------------------------------------------ |
51
- | `/darkhunt-guard:setup` | Connect this machine. Re-runnable; shows current state first. |
52
- | `/darkhunt-guard:status` | Is capture actually working? Routing, hook activity, shipping lag. |
84
+ | | |
85
+ | -------------------------- | --------------------------------------------------------------------------------- |
86
+ | `/darkhunt-guard:setup` | Connect this machine. Re-runnable; shows current state first. |
87
+ | `/darkhunt-guard:status` | Is capture actually working? Routing, launchers, hook activity, shipping lag. |
88
+ | `/darkhunt-guard:backfill` | Import Codex CLI sessions from before this machine was connected. Dry-runs first. |
53
89
 
54
90
  Both lanes fail silently by design — correct for the agent, unhelpful for whoever
55
91
  operates it — so `status` is where the truth lives.
@@ -0,0 +1,73 @@
1
+ ---
2
+ description: Import Codex CLI sessions that happened before this machine was connected
3
+ argument-hint: '[--days=N, transcript paths, or nothing for everything on disk]'
4
+ allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/dist/bin/backfill.mjs:*), Bash(${CLAUDE_PLUGIN_ROOT}/dist/bin/status.mjs:*)
5
+ ---
6
+
7
+ Ship transcripts that the capture lane never saw — sessions from before the plugin was
8
+ installed, or from a stretch where hooks were not firing. On Codex the usual cause of
9
+ the second is missing launchers, so read the `launchers` line below before assuming the
10
+ gap has stopped growing.
11
+
12
+ Where this machine is currently pointed:
13
+
14
+ !`${CLAUDE_PLUGIN_ROOT}/dist/bin/status.mjs 2>&1 || true`
15
+
16
+ Paths or flags the user gave: $ARGUMENTS
17
+
18
+ If they asked for a window in words — "the last week", "since Monday", "this month" —
19
+ translate it to `--days=N` rather than asking them to phrase it as a flag. `--days`
20
+ selects on when a transcript was **last written to**, so a session opened three weeks
21
+ ago and worked in yesterday is inside a 7-day window. Say that if the count surprises
22
+ them.
23
+
24
+ ## The one hard rule
25
+
26
+ **Dry-run first, and get the destination confirmed before you ship.** A backfill is one
27
+ command that moves a person's entire local history into a tenant. Checkpoints make a
28
+ re-run harmless; they do nothing about a run aimed at the wrong application, which
29
+ cannot be taken back and looks exactly like a correct one afterwards.
30
+
31
+ ## What to do
32
+
33
+ 1. Map without sending. Nothing leaves the machine and no checkpoint moves:
34
+
35
+ ```
36
+ ${CLAUDE_PLUGIN_ROOT}/dist/bin/backfill.mjs --dry-run $ARGUMENTS
37
+ ```
38
+
39
+ With no window and no paths this is the machine's entire history, which on a working
40
+ machine is routinely tens of thousands of records. If they have not said how far back
41
+ they want, offer `--days=7` before offering all of it.
42
+
43
+ Report the transcript count and the record count. `records` excludes `session_meta`,
44
+ so it will not equal the sum of the per-kind lines — say so if you quote both.
45
+
46
+ 2. Put the destination to them **before** shipping, reading it from the status block
47
+ above: endpoint, tenant, workspace, application. Ask them to confirm that is where
48
+ this history belongs. Two things make this worth a sentence rather than a nod:
49
+
50
+ - Both endpoints share `~/.darkhunt/endpoint-config.json`. Unless it carries an
51
+ `endpoints.codex` override, these sessions land in the same application as the
52
+ Claude Code ones.
53
+ - Volume. A first backfill on a working machine is routinely tens of thousands of
54
+ records, all attributed to that one application.
55
+
56
+ If they want them separated, stop and tell them to set
57
+ `endpoints.claude-code.applicationId` first — after a partial run the checkpoints
58
+ have moved and the records already sent stay where they went.
59
+
60
+ 3. Only on a yes, run the same command without `--dry-run`. It can take a while on a
61
+ large history; let it finish rather than re-running it.
62
+
63
+ 4. Report what it printed. On a non-zero exit, say plainly that **nothing was lost**:
64
+ a failed pass holds every checkpoint, so the identical command re-ships the same
65
+ byte range once the cause is fixed.
66
+
67
+ ## Also
68
+
69
+ - Narrow it by passing paths — one session, or a date range — rather than adding flags
70
+ this command does not have: `backfill.mjs ~/.codex/sessions/2026/08/31/rollout-*.jsonl`.
71
+ - This is a CLI, not a hook: it may use the network, and it does not depend on whether
72
+ hooks are firing. A machine whose capture is broken can still backfill.
73
+ - Do not offer to change the configuration here. Send them to `/darkhunt-guard:setup`.
package/commands/setup.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Connect this machine to Darkhunt — capture Codex CLI sessions and enforce policy
3
- allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/dist/bin/enroll.mjs:*), Bash(${CLAUDE_PLUGIN_ROOT}/dist/bin/status.mjs:*)
3
+ allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/dist/bin/enroll.mjs:*), Bash(${CLAUDE_PLUGIN_ROOT}/dist/bin/status.mjs:*), Bash(pbpaste:*), Bash(wl-paste:*), Bash(xclip:*), Bash(powershell.exe:*)
4
4
  ---
5
5
 
6
6
  Set up the Darkhunt endpoint plugin on this machine.
@@ -11,62 +11,76 @@ Current state:
11
11
 
12
12
  ## The one hard rule
13
13
 
14
- **Never ask the user for their API key, and never accept one in this conversation.**
14
+ **Never write the user's API key into your reply, into a file, or into a command's
15
+ arguments.** It is an unscoped tenant credential. Everything in this session goes to the
16
+ model provider, onto disk in the transcript, and — since this plugin captures sessions —
17
+ into the trace data itself. An argument is visible in the process list too.
15
18
 
16
- A Darkhunt API key is an unscoped tenant credential. Anything in this session is sent to
17
- the model provider, written to the transcript on disk, kept in context for every later
18
- turn, and handed to subagents — and this plugin then ships that transcript to Darkhunt,
19
- so a key pasted here ends up in the captured trace data too. Enrollment finds the key
20
- itself, or the user runs one command in their own terminal. There is no third option,
21
- and no amount of user insistence makes pasting it here correct.
19
+ You do not need the key's value to do this job. Read it from the clipboard straight into
20
+ the command's stdin, so it never becomes text you have seen.
22
21
 
23
22
  ## What to do
24
23
 
25
- **If the status above printed a profile, tenant and workspace**, this machine is already
26
- enrolled. Show the tenant, workspace, application and endpoint, and ask whether they want
27
- to re-enroll against a different one. Do not re-run enrollment unless they say yes.
24
+ **If the status above shows a profile, tenant and workspace**, this machine is already
25
+ enrolled. Report the tenant, workspace, application and endpoint, and ask whether they
26
+ want to re-enroll somewhere else. Do nothing further unless they say yes.
28
27
 
29
- **Otherwise**, run:
28
+ **Otherwise**, enroll it:
30
29
 
31
- ```
32
- ${CLAUDE_PLUGIN_ROOT}/dist/bin/enroll.mjs
33
- ```
30
+ 1. Ask the user to copy their Darkhunt API key — the **Copy** button beside it in the
31
+ dashboard, under the application's Connect step or **Settings → Security → API Keys**.
32
+ Ask them to tell you when it is copied. Do not ask them to paste it.
34
33
 
35
- It looks for a key in `$DARKHUNT_API_KEY`, then in the credentials `darkhunt-cli enroll`
36
- wrote. Then react to the exit code:
34
+ 2. Pipe the clipboard into enrollment. Pick the reader for their platform — `pbpaste` on
35
+ macOS, `wl-paste` or `xclip -o -selection clipboard` on Linux,
36
+ `powershell.exe -NoProfile -Command Get-Clipboard` on Windows:
37
37
 
38
- - **Exit 0** — done. Show the user exactly what it printed: endpoint, tenant, workspace,
39
- application, and which key it used. Ask them to confirm this is where they expect their
40
- sessions to go. This matters — a config routing to the wrong tenant looks identical to
41
- a correct one from everywhere else, and this summary is the only place the mistake is
42
- visible.
38
+ ```
39
+ pbpaste | ${CLAUDE_PLUGIN_ROOT}/dist/bin/enroll.mjs --apiKey=-
40
+ ```
43
41
 
44
- - **Exit 3 no key.** Do **not** ask for one. Tell the user to run this in their own
45
- terminal, outside this session:
42
+ Add `--apiBaseUrl=<url>` if they are on a Darkhunt other than `api.darkhunt.ai`.
46
43
 
47
- ```
48
- darkhunt-cli enroll --api-key <key>
49
- ```
44
+ 3. React to the exit code:
50
45
 
51
- Their key is in the dashboard under **Settings Security API Keys**. Once that
52
- succeeds, run the enroll command above again it will pick the key up. If they do not
53
- have `darkhunt-cli`, they can instead `export DARKHUNT_API_KEY=<key>` in the shell they
54
- launched Codex CLI from, and restart the session.
46
+ - **0** done. Show exactly what it printed: endpoint, tenant, workspace,
47
+ application. Ask them to confirm that is where they expect their sessions to go. A
48
+ config aimed at the wrong tenant looks identical to a correct one everywhere else,
49
+ and this summary is the only place the mistake is visible.
55
50
 
56
- - **Exit 2 — a choice is needed.** The key reaches more than one tenant, workspace or
57
- application, and the options are listed. Show them by name, ask which one, then re-run
58
- adding the chosen id `--tenantId=`, `--workspaceId=` or `--applicationId=` — keeping
59
- any already resolved. Repeat until it succeeds; there can be up to three rounds.
51
+ - **2**the key reaches more than one tenant, workspace or application, and the
52
+ options are listed. Show them by name, ask which, then re-run the same piped command
53
+ with the chosen id added (`--tenantId=`, `--workspaceId=` or `--applicationId=`),
54
+ keeping any already settled. Up to three rounds.
60
55
 
61
- - **Any other exit** — show the error. Do not retry with different arguments hoping it
62
- passes.
56
+ - **1 with "stdin was empty"** — the clipboard did not hold the key. Ask them to copy
57
+ it again, then retry. Do not ask them to paste it here instead.
63
58
 
64
- Finally, tell them capture begins on their **next** Codex CLI session, because hooks
65
- are loaded when a session starts, and that `/darkhunt-guard:status` shows whether it is
66
- working.
59
+ - **1 with "rejected"** the clipboard held something that is not a valid key. Same
60
+ remedy.
61
+
62
+ - **3** — no key was found at all, which should not happen on this path. Do not ask
63
+ for the key here. Send them to the dashboard instead: the application's Connect step
64
+ renders a ready-made block that writes `~/.darkhunt/endpoint-config.json` with every
65
+ value already filled in, to paste into their own terminal. That block writes
66
+ credentials only, so it must be followed by:
67
+
68
+ ```
69
+ ${CLAUDE_PLUGIN_ROOT}/dist/bin/install-hooks.mjs
70
+ ```
71
+
72
+ Then re-run the status command above to confirm both halves took.
73
+
74
+ 4. Tell them capture begins on their **next** Codex CLI session — hooks load when a
75
+ session starts — and that `/darkhunt-guard:status` shows whether it is working.
76
+
77
+ If the status output reports a launcher problem, run
78
+ `${CLAUDE_PLUGIN_ROOT}/dist/bin/install-hooks.mjs` and read what it prints. Codex
79
+ resolves this plugin's hook commands by PATH name, so a machine with correct
80
+ credentials and no launchers on `PATH` is configured and capturing nothing.
67
81
 
68
82
  ## Also
69
83
 
70
- - Do not edit `~/.darkhunt/endpoint-config.json` by hand enrollment writes it at 0600,
71
- and a file with looser permissions is refused at load.
84
+ - Do not edit `~/.darkhunt/endpoint-config.json` by hand. Enrollment writes it at 0600
85
+ and a looser mode is refused at load.
72
86
  - Enforcement ships **off**. Setup does not turn it on, and you should not offer to.
@@ -1,6 +1,6 @@
1
1
  ---
2
- description: Is Darkhunt capture actually working? Shows routing, hook activity and shipping lag
3
- allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/dist/bin/status.mjs:*)
2
+ description: Is Darkhunt capture actually working? Shows routing, launchers, hook activity and shipping lag
3
+ allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/dist/bin/status.mjs:*), Bash(${CLAUDE_PLUGIN_ROOT}/dist/bin/install-hooks.mjs:*)
4
4
  ---
5
5
 
6
6
  !`${CLAUDE_PLUGIN_ROOT}/dist/bin/status.mjs 2>&1 || true`
@@ -15,6 +15,13 @@ What the fields mean, and what to say:
15
15
  `/darkhunt-guard:setup`. Nothing is being captured, and nothing is being blocked.
16
16
  - **tenant / workspace / application** — where sessions are being sent. Worth reading
17
17
  aloud: a wrong destination looks exactly like a right one everywhere else.
18
+ - **launchers PROBLEM** — the failure with no other symptom. Codex resolves this
19
+ plugin's hook commands by PATH name (`darkhunt-codex-spool`, `darkhunt-codex-guard`),
20
+ so a machine whose config is perfect but whose launchers are missing, stale, or in a
21
+ directory that is not on `PATH` captures nothing and reports nothing wrong anywhere
22
+ else. Missing or stale is fixed by running
23
+ `${CLAUDE_PLUGIN_ROOT}/dist/bin/install-hooks.mjs`; a `PATH` problem is theirs to fix
24
+ in their shell profile, and the line says which it is.
18
25
  - **user … [from agent]** — traces are attributed to the signed-in Codex CLI account.
19
26
  `[from config]` means a value someone typed in, which on a shared machine is likely to
20
27
  be the wrong person.
@@ -18295,6 +18295,7 @@ function summarize(output) {
18295
18295
  return text;
18296
18296
  return `${text.slice(0, TOOL_SUMMARY_LIMIT)}\u2026 [truncated, ${text.length} chars]`;
18297
18297
  }
18298
+ var RESUMED_USER_TEXT_LIMIT = 4e3;
18298
18299
  var SessionEmitter = class {
18299
18300
  client;
18300
18301
  options;
@@ -18321,10 +18322,26 @@ var SessionEmitter = class {
18321
18322
  constructor(client, options) {
18322
18323
  this.client = client;
18323
18324
  this.options = options;
18325
+ if (options.resumeTurn) {
18326
+ this.lastUserText = options.resumeTurn.userText;
18327
+ this.userTurnOpen = options.resumeTurn.open;
18328
+ }
18324
18329
  }
18325
18330
  get recordCount() {
18326
18331
  return this.count;
18327
18332
  }
18333
+ /**
18334
+ * The turn still in flight, for the forwarder to hand to the next pass. Read after
18335
+ * `finish()` — a turn is only settled once the pass has ended everything it opened.
18336
+ */
18337
+ get turn() {
18338
+ const text = this.lastUserText;
18339
+ const carried = text !== void 0 && text.length > RESUMED_USER_TEXT_LIMIT ? `${text.slice(0, RESUMED_USER_TEXT_LIMIT)}\u2026 [truncated, ${text.length} chars]` : text;
18340
+ return {
18341
+ ...carried !== void 0 ? { userText: carried } : {},
18342
+ open: this.userTurnOpen
18343
+ };
18344
+ }
18328
18345
  /**
18329
18346
  * Create a span under a seed derived from the record. Scoped by session id, so two
18330
18347
  * sessions sharing a record uuid cannot collide, and by a discriminator, so the
@@ -18580,6 +18597,11 @@ function resumeOffset(checkpoints, path) {
18580
18597
  return 0;
18581
18598
  return previous.offset;
18582
18599
  }
18600
+ function resumeTurn(checkpoints, path, from) {
18601
+ if (from <= 0)
18602
+ return void 0;
18603
+ return checkpoints[path]?.turn;
18604
+ }
18583
18605
 
18584
18606
  // packages/core/dist/forwarder/tail.js
18585
18607
  import { closeSync, openSync, readSync, statSync as statSync3 } from "node:fs";
@@ -18737,7 +18759,7 @@ function saveHealth(scope, health) {
18737
18759
  }
18738
18760
 
18739
18761
  // packages/core/dist/forwarder/run.js
18740
- import { readdirSync as readdirSync2 } from "node:fs";
18762
+ import { readdirSync as readdirSync2, statSync as statSync4 } from "node:fs";
18741
18763
 
18742
18764
  // node_modules/@darkhunt-security/telemetry/dist/client.js
18743
18765
  init_esm();
@@ -21597,12 +21619,14 @@ async function runForwarder(mapper, options = {}) {
21597
21619
  for (const path of paths2) {
21598
21620
  try {
21599
21621
  const from = resumeOffset(checkpoints, path);
21622
+ const carried = resumeTurn(checkpoints, path, from);
21600
21623
  const emitter = new SessionEmitter(client, {
21601
21624
  sessionId: mapper.sessionIdFor(path),
21602
21625
  vendor: mapper.vendor,
21603
21626
  passKey: String(from),
21604
21627
  ...ids !== void 0 ? { ids } : {},
21605
- ...userId !== void 0 ? { userId } : {}
21628
+ ...userId !== void 0 ? { userId } : {},
21629
+ ...carried !== void 0 ? { resumeTurn: carried } : {}
21606
21630
  });
21607
21631
  let cursor = from;
21608
21632
  let reached = from;
@@ -21628,7 +21652,12 @@ async function runForwarder(mapper, options = {}) {
21628
21652
  if (ingested === 0)
21629
21653
  continue;
21630
21654
  await emitter.finish();
21631
- staged[path] = { offset: reached, size: fileSize };
21655
+ const turn = emitter.turn;
21656
+ staged[path] = {
21657
+ offset: reached,
21658
+ size: fileSize,
21659
+ ...turn.userText !== void 0 || turn.open ? { turn } : {}
21660
+ };
21632
21661
  result2.transcripts++;
21633
21662
  result2.records += emitter.recordCount;
21634
21663
  if (watch.failures() > 0)
@@ -21665,6 +21694,19 @@ async function runForwarder(mapper, options = {}) {
21665
21694
  release();
21666
21695
  }
21667
21696
  }
21697
+ function withinDays(paths2, days) {
21698
+ if (!Number.isFinite(days) || days <= 0) {
21699
+ throw new RangeError(`--days must be a positive number of days, got ${days}`);
21700
+ }
21701
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1e3;
21702
+ return paths2.filter((path) => {
21703
+ try {
21704
+ return statSync4(path).mtimeMs >= cutoff;
21705
+ } catch {
21706
+ return true;
21707
+ }
21708
+ });
21709
+ }
21668
21710
  function discoverTranscripts(mapper) {
21669
21711
  const found = [];
21670
21712
  const walk = (dir) => {
@@ -21843,10 +21885,21 @@ var flag = (n) => {
21843
21885
  };
21844
21886
  var profile = flag("profile");
21845
21887
  var credentialsPath2 = flag("credentials");
21846
- var paths = explicit.length > 0 ? explicit : discoverTranscripts(codexTranscript);
21888
+ var sinceDays;
21889
+ if (flag("days") !== void 0) {
21890
+ sinceDays = Number(flag("days"));
21891
+ if (!Number.isFinite(sinceDays) || sinceDays <= 0) {
21892
+ process.stderr.write(`darkhunt: --days must be a positive number, got '${flag("days")}'
21893
+ `);
21894
+ process.exit(1);
21895
+ }
21896
+ }
21897
+ var discovered = explicit.length > 0 ? explicit : discoverTranscripts(codexTranscript);
21898
+ var paths = sinceDays === void 0 ? discovered : withinDays(discovered, sinceDays);
21899
+ var window2 = sinceDays === void 0 ? "" : ` written to in the last ${sinceDays} day(s)`;
21847
21900
  var say = (m) => process.stderr.write(`darkhunt: ${m}
21848
21901
  `);
21849
- say(`${dryRun ? "DRY RUN \u2014 nothing will be sent. " : ""}${paths.length} transcript(s)`);
21902
+ say(`${dryRun ? "DRY RUN \u2014 nothing will be sent. " : ""}${paths.length} transcript(s)${window2}`);
21850
21903
  var result = await runForwarder(codexTranscript, { paths, dryRun, profile, credentialsPath: credentialsPath2 });
21851
21904
  say(
21852
21905
  `${dryRun ? "mapped" : "shipped"} ${result.records} record(s) from ${result.transcripts} transcript(s)`
@@ -52,6 +52,14 @@ var SPOOL_DIR = join4(CONFIG_DIR, "spool");
52
52
  // packages/core/dist/runtime/beat.js
53
53
  var BEAT_LIVE_MS = 30 * 60 * 1e3;
54
54
 
55
+ // packages/core/dist/runtime/guard.js
56
+ async function readStdin(stream) {
57
+ const chunks = [];
58
+ for await (const chunk of stream)
59
+ chunks.push(chunk);
60
+ return Buffer.concat(chunks).toString("utf8");
61
+ }
62
+
55
63
  // packages/core/dist/forwarder/tail.js
56
64
  var DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
57
65
  var LINE_CEILING = 64 * 1024 * 1024;
@@ -998,18 +1006,18 @@ function init(argv) {
998
1006
  const cfgPath = configPath();
999
1007
  const existingConfig = readJson(cfgPath) ?? {};
1000
1008
  const existingCreds = separateFile ? readJson(credsPath) : existingConfig;
1001
- const apiKey = flags["apiKey"] ?? process.env["DARKHUNT_API_KEY"] ?? existingCreds?.apiKey;
1009
+ const apiKey2 = flags["apiKey"] ?? process.env["DARKHUNT_API_KEY"] ?? existingCreds?.apiKey;
1002
1010
  const tenantId = flags["tenantId"] ?? process.env["DARKHUNT_TENANT_ID"] ?? existingCreds?.tenantId;
1003
1011
  const apiBaseUrl = flags["apiBaseUrl"] ?? process.env["DARKHUNT_API_BASE_URL"] ?? existingCreds?.apiBaseUrl ?? "https://api.darkhunt.ai";
1004
1012
  const workspaceId = flags["workspaceId"] ?? process.env["DARKHUNT_WORKSPACE_ID"] ?? existingCreds?.workspaceId;
1005
1013
  const applicationId = flags["applicationId"] ?? process.env["DARKHUNT_APPLICATION_ID"] ?? existingCreds?.applicationId;
1006
1014
  const userId = flags["userId"] ?? existingCreds?.userId;
1007
- assertPresent("apiKey", apiKey, "DARKHUNT_API_KEY");
1015
+ assertPresent("apiKey", apiKey2, "DARKHUNT_API_KEY");
1008
1016
  assertPresent("tenantId", tenantId, "DARKHUNT_TENANT_ID");
1009
1017
  assertPresent("workspaceId", workspaceId, "DARKHUNT_WORKSPACE_ID");
1010
1018
  assertPresent("applicationId", applicationId, "DARKHUNT_APPLICATION_ID");
1011
1019
  const credentials = {
1012
- apiKey,
1020
+ apiKey: apiKey2,
1013
1021
  apiBaseUrl,
1014
1022
  tenantId,
1015
1023
  workspaceId,
@@ -1115,7 +1123,8 @@ function resolveKey(explicit, cliCredentialsPath) {
1115
1123
  return {
1116
1124
  apiKey: cli.apiKey,
1117
1125
  source: "darkhunt-cli",
1118
- ...typeof cli.apiBaseUrl === "string" ? { apiBaseUrl: cli.apiBaseUrl } : {}
1126
+ ...typeof cli.apiBaseUrl === "string" ? { apiBaseUrl: cli.apiBaseUrl } : {},
1127
+ ...typeof cli.tenantId === "string" && cli.tenantId !== "" ? { tenantId: cli.tenantId } : {}
1119
1128
  };
1120
1129
  }
1121
1130
  } catch {
@@ -1124,10 +1133,10 @@ function resolveKey(explicit, cliCredentialsPath) {
1124
1133
  }
1125
1134
  var MAX_LISTED = 15;
1126
1135
  var DEFAULT_API_BASE_URL = "https://api.darkhunt.ai";
1127
- async function get(base, apiKey, path) {
1136
+ async function get(base, apiKey2, path) {
1128
1137
  let res;
1129
1138
  try {
1130
- res = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${apiKey}` } });
1139
+ res = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${apiKey2}` } });
1131
1140
  } catch (err) {
1132
1141
  throw new EnrollError(`cannot reach ${base}: ${err.message}`);
1133
1142
  }
@@ -1164,18 +1173,54 @@ function resolve2(kind, options, chosen) {
1164
1173
  throw new AmbiguousError(kind, options);
1165
1174
  }
1166
1175
  var CLI_CREDENTIALS_PATH = join5(homedir3(), ".darkhunt", "credentials.json");
1176
+ function idsFromEnv() {
1177
+ const read = (name) => {
1178
+ const value = process.env[name];
1179
+ return value !== void 0 && value.trim() !== "" ? value : void 0;
1180
+ };
1181
+ const tenantId = read("DARKHUNT_TENANT_ID");
1182
+ const workspaceId = read("DARKHUNT_WORKSPACE_ID");
1183
+ const applicationId = read("DARKHUNT_APPLICATION_ID");
1184
+ return {
1185
+ ...tenantId !== void 0 ? { tenantId } : {},
1186
+ ...workspaceId !== void 0 ? { workspaceId } : {},
1187
+ ...applicationId !== void 0 ? { applicationId } : {}
1188
+ };
1189
+ }
1190
+ async function accessibleTenants(base, apiKey2, candidates, recorded) {
1191
+ const known = candidates.find((c) => c.id === recorded);
1192
+ if (known)
1193
+ return [known];
1194
+ if (candidates.length <= 1)
1195
+ return candidates;
1196
+ const reachable = await Promise.all(candidates.map(async (c) => {
1197
+ try {
1198
+ await get(base, apiKey2, `/workflow-manager/api/t/${c.id}/workspaces`);
1199
+ return c;
1200
+ } catch {
1201
+ return void 0;
1202
+ }
1203
+ }));
1204
+ const usable = reachable.filter((c) => c !== void 0);
1205
+ return usable.length > 0 ? usable : candidates;
1206
+ }
1167
1207
  async function enroll(options) {
1208
+ const env = idsFromEnv();
1209
+ const chosen = {
1210
+ ...env,
1211
+ ...Object.fromEntries(Object.entries(options).filter(([, v]) => v !== void 0))
1212
+ };
1168
1213
  const key = resolveKey(options.apiKey, CLI_CREDENTIALS_PATH);
1169
- const { apiKey } = key;
1214
+ const { apiKey: apiKey2 } = key;
1170
1215
  const base = (options.apiBaseUrl ?? key.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
1171
- const me = await get(base, apiKey, "/user-management/api/me");
1172
- const tenantId = resolve2("tenant", toChoices(list(me.tenants)), options.tenantId);
1173
- const workspaces = toChoices(list(await get(base, apiKey, `/workflow-manager/api/t/${tenantId}/workspaces`)));
1174
- const workspaceId = resolve2("workspace", workspaces, options.workspaceId);
1175
- const applications = toChoices(list(await get(base, apiKey, `/workflow-manager/api/t/${tenantId}/workspaces/${workspaceId}/applications`)));
1176
- const applicationId = resolve2("application", applications, options.applicationId);
1216
+ const me = await get(base, apiKey2, "/user-management/api/me");
1217
+ const tenantId = resolve2("tenant", await accessibleTenants(base, apiKey2, toChoices(list(me.tenants)), key.tenantId), chosen.tenantId ?? key.tenantId);
1218
+ const workspaces = toChoices(list(await get(base, apiKey2, `/workflow-manager/api/t/${tenantId}/workspaces`)));
1219
+ const workspaceId = resolve2("workspace", workspaces, chosen.workspaceId);
1220
+ const applications = toChoices(list(await get(base, apiKey2, `/workflow-manager/api/t/${tenantId}/workspaces/${workspaceId}/applications`)));
1221
+ const applicationId = resolve2("application", applications, chosen.applicationId);
1177
1222
  const result = init([
1178
- `--apiKey=${apiKey}`,
1223
+ `--apiKey=${apiKey2}`,
1179
1224
  `--apiBaseUrl=${base}`,
1180
1225
  `--tenantId=${tenantId}`,
1181
1226
  `--workspaceId=${workspaceId}`,
@@ -1193,14 +1238,92 @@ async function enroll(options) {
1193
1238
  };
1194
1239
  }
1195
1240
 
1241
+ // adapters/codex/bin/lib/launchers.mjs
1242
+ import { chmodSync as chmodSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
1243
+ import { homedir as homedir4 } from "node:os";
1244
+ import { join as join6 } from "node:path";
1245
+ var LAUNCHERS = {
1246
+ "darkhunt-codex-spool": "spool.mjs",
1247
+ "darkhunt-codex-guard": "guard.mjs"
1248
+ };
1249
+ var BIN_DIR = join6(homedir4(), ".local", "bin");
1250
+ var script = (target) => `#!/bin/sh
1251
+ # Installed by darkhunt endpoint-plugins. Resolves the newest installed
1252
+ # plugin version at run time; do not hard-code a version here.
1253
+ D=$(ls -d "$HOME"/.codex/plugins/cache/*/darkhunt-guard/*/dist/bin 2>/dev/null | sort -V | tail -1)
1254
+ [ -n "$D" ] || exit 0
1255
+ exec "$D/${target}" "$@"
1256
+ `;
1257
+ function installLaunchers(binDir = BIN_DIR) {
1258
+ mkdirSync2(binDir, { recursive: true });
1259
+ const installed = [];
1260
+ for (const [name, target] of Object.entries(LAUNCHERS)) {
1261
+ const path = join6(binDir, name);
1262
+ writeFileSync2(path, script(target), { mode: 493 });
1263
+ chmodSync2(path, 493);
1264
+ installed.push(path);
1265
+ }
1266
+ return installed;
1267
+ }
1268
+ function launcherState(binDir = BIN_DIR, path = process.env["PATH"] ?? "") {
1269
+ const missing = [];
1270
+ const stale = [];
1271
+ for (const [name, target] of Object.entries(LAUNCHERS)) {
1272
+ const file = join6(binDir, name);
1273
+ if (!existsSync2(file)) {
1274
+ missing.push(name);
1275
+ continue;
1276
+ }
1277
+ let body = "";
1278
+ try {
1279
+ body = readFileSync3(file, "utf8");
1280
+ } catch {
1281
+ }
1282
+ if (body !== script(target)) stale.push(name);
1283
+ }
1284
+ return { binDir, missing, stale, onPath: path.split(":").includes(binDir) };
1285
+ }
1286
+ function launcherWarnings(state) {
1287
+ const lines = [];
1288
+ if (state.missing.length > 0) {
1289
+ lines.push(
1290
+ `hook launchers missing: ${state.missing.join(", ")} \u2014 hooks cannot run.`,
1291
+ ` install them: install-hooks.mjs`
1292
+ );
1293
+ }
1294
+ if (state.stale.length > 0) {
1295
+ lines.push(
1296
+ `hook launchers are not the ones this version writes: ${state.stale.join(", ")}.`,
1297
+ ` refresh them: install-hooks.mjs`
1298
+ );
1299
+ }
1300
+ if (!state.onPath) {
1301
+ lines.push(
1302
+ `${state.binDir} is not on PATH \u2014 hooks will not run.`,
1303
+ ` add it: export PATH="${state.binDir}:$PATH" in your shell profile, then open a new terminal.`
1304
+ );
1305
+ }
1306
+ return lines;
1307
+ }
1308
+
1196
1309
  // adapters/codex/bin/enroll.mjs
1197
1310
  var flag = (name) => {
1198
1311
  const arg = process.argv.slice(2).find((a) => a.startsWith(`--${name}=`));
1199
1312
  return arg ? arg.slice(name.length + 3) : void 0;
1200
1313
  };
1314
+ var apiKey = flag("apiKey");
1315
+ if (apiKey === "-") {
1316
+ apiKey = (await readStdin(process.stdin)).trim();
1317
+ if (apiKey === "") {
1318
+ process.stderr.write(
1319
+ "darkhunt: --apiKey=- was given but stdin was empty.\n Copy your API key first, then pipe it in \u2014 e.g. `pbpaste | enroll.mjs --apiKey=-`.\n"
1320
+ );
1321
+ process.exit(1);
1322
+ }
1323
+ }
1201
1324
  try {
1202
1325
  const r = await enroll({
1203
- ...flag("apiKey") !== void 0 ? { apiKey: flag("apiKey") } : {},
1326
+ ...apiKey !== void 0 ? { apiKey } : {},
1204
1327
  ...flag("apiBaseUrl") !== void 0 ? { apiBaseUrl: flag("apiBaseUrl") } : {},
1205
1328
  ...flag("tenantId") !== void 0 ? { tenantId: flag("tenantId") } : {},
1206
1329
  ...flag("workspaceId") !== void 0 ? { workspaceId: flag("workspaceId") } : {},
@@ -1211,6 +1334,7 @@ try {
1211
1334
  environment: "$DARKHUNT_API_KEY",
1212
1335
  "darkhunt-cli": "the key darkhunt-cli enrolled"
1213
1336
  };
1337
+ const launchers = installLaunchers();
1214
1338
  process.stdout.write(
1215
1339
  `darkhunt: enrolled${r.email ? ` as ${r.email}` : ""} using ${KEY_SOURCE[r.keySource]}
1216
1340
  endpoint ${r.apiBaseUrl}
@@ -1218,9 +1342,16 @@ try {
1218
1342
  workspace ${r.workspaceId}
1219
1343
  application ${r.applicationId}
1220
1344
  config ${r.configPath} (0600)
1221
- capture: on enforce: off
1345
+ ` + launchers.map((p) => ` launcher ${p}
1346
+ `).join("") + ` capture: on enforce: off
1222
1347
  `
1223
1348
  );
1349
+ const warnings = launcherWarnings(launcherState());
1350
+ if (warnings.length > 0) {
1351
+ process.stdout.write(`
1352
+ ${warnings.map((l) => `darkhunt: WARNING ${l}
1353
+ `).join("")}`);
1354
+ }
1224
1355
  } catch (err) {
1225
1356
  if (err instanceof MissingKeyError) {
1226
1357
  process.stderr.write(
@@ -18295,6 +18295,7 @@ function summarize(output) {
18295
18295
  return text;
18296
18296
  return `${text.slice(0, TOOL_SUMMARY_LIMIT)}\u2026 [truncated, ${text.length} chars]`;
18297
18297
  }
18298
+ var RESUMED_USER_TEXT_LIMIT = 4e3;
18298
18299
  var SessionEmitter = class {
18299
18300
  client;
18300
18301
  options;
@@ -18321,10 +18322,26 @@ var SessionEmitter = class {
18321
18322
  constructor(client, options) {
18322
18323
  this.client = client;
18323
18324
  this.options = options;
18325
+ if (options.resumeTurn) {
18326
+ this.lastUserText = options.resumeTurn.userText;
18327
+ this.userTurnOpen = options.resumeTurn.open;
18328
+ }
18324
18329
  }
18325
18330
  get recordCount() {
18326
18331
  return this.count;
18327
18332
  }
18333
+ /**
18334
+ * The turn still in flight, for the forwarder to hand to the next pass. Read after
18335
+ * `finish()` — a turn is only settled once the pass has ended everything it opened.
18336
+ */
18337
+ get turn() {
18338
+ const text = this.lastUserText;
18339
+ const carried = text !== void 0 && text.length > RESUMED_USER_TEXT_LIMIT ? `${text.slice(0, RESUMED_USER_TEXT_LIMIT)}\u2026 [truncated, ${text.length} chars]` : text;
18340
+ return {
18341
+ ...carried !== void 0 ? { userText: carried } : {},
18342
+ open: this.userTurnOpen
18343
+ };
18344
+ }
18328
18345
  /**
18329
18346
  * Create a span under a seed derived from the record. Scoped by session id, so two
18330
18347
  * sessions sharing a record uuid cannot collide, and by a discriminator, so the
@@ -18580,6 +18597,11 @@ function resumeOffset(checkpoints, path) {
18580
18597
  return 0;
18581
18598
  return previous.offset;
18582
18599
  }
18600
+ function resumeTurn(checkpoints, path, from) {
18601
+ if (from <= 0)
18602
+ return void 0;
18603
+ return checkpoints[path]?.turn;
18604
+ }
18583
18605
 
18584
18606
  // packages/core/dist/forwarder/tail.js
18585
18607
  import { closeSync, openSync, readSync, statSync as statSync3 } from "node:fs";
@@ -18737,7 +18759,7 @@ function saveHealth(scope, health) {
18737
18759
  }
18738
18760
 
18739
18761
  // packages/core/dist/forwarder/run.js
18740
- import { readdirSync as readdirSync2 } from "node:fs";
18762
+ import { readdirSync as readdirSync2, statSync as statSync4 } from "node:fs";
18741
18763
 
18742
18764
  // node_modules/@darkhunt-security/telemetry/dist/client.js
18743
18765
  init_esm();
@@ -21597,12 +21619,14 @@ async function runForwarder(mapper, options = {}) {
21597
21619
  for (const path of paths) {
21598
21620
  try {
21599
21621
  const from = resumeOffset(checkpoints, path);
21622
+ const carried = resumeTurn(checkpoints, path, from);
21600
21623
  const emitter = new SessionEmitter(client, {
21601
21624
  sessionId: mapper.sessionIdFor(path),
21602
21625
  vendor: mapper.vendor,
21603
21626
  passKey: String(from),
21604
21627
  ...ids !== void 0 ? { ids } : {},
21605
- ...userId !== void 0 ? { userId } : {}
21628
+ ...userId !== void 0 ? { userId } : {},
21629
+ ...carried !== void 0 ? { resumeTurn: carried } : {}
21606
21630
  });
21607
21631
  let cursor = from;
21608
21632
  let reached = from;
@@ -21628,7 +21652,12 @@ async function runForwarder(mapper, options = {}) {
21628
21652
  if (ingested === 0)
21629
21653
  continue;
21630
21654
  await emitter.finish();
21631
- staged[path] = { offset: reached, size: fileSize };
21655
+ const turn = emitter.turn;
21656
+ staged[path] = {
21657
+ offset: reached,
21658
+ size: fileSize,
21659
+ ...turn.userText !== void 0 || turn.open ? { turn } : {}
21660
+ };
21632
21661
  result.transcripts++;
21633
21662
  result.records += emitter.recordCount;
21634
21663
  if (watch.failures() > 0)
package/dist/bin/init.mjs CHANGED
@@ -2,11 +2,6 @@
2
2
  import { createRequire as __dhCreateRequire } from 'node:module';
3
3
  const require = __dhCreateRequire(import.meta.url);
4
4
 
5
- // adapters/codex/bin/init.mjs
6
- import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
7
- import { homedir as homedir4 } from "node:os";
8
- import { join as join6 } from "node:path";
9
-
10
5
  // packages/core/dist/config/local.js
11
6
  import { join as join3 } from "node:path";
12
7
 
@@ -1083,30 +1078,75 @@ import { homedir as homedir3 } from "node:os";
1083
1078
  import { join as join5 } from "node:path";
1084
1079
  var CLI_CREDENTIALS_PATH = join5(homedir3(), ".darkhunt", "credentials.json");
1085
1080
 
1086
- // adapters/codex/bin/init.mjs
1087
- var LAUNCHERS = { "darkhunt-codex-spool": "spool.mjs", "darkhunt-codex-guard": "guard.mjs" };
1081
+ // adapters/codex/bin/lib/launchers.mjs
1082
+ import { chmodSync as chmodSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
1083
+ import { homedir as homedir4 } from "node:os";
1084
+ import { join as join6 } from "node:path";
1085
+ var LAUNCHERS = {
1086
+ "darkhunt-codex-spool": "spool.mjs",
1087
+ "darkhunt-codex-guard": "guard.mjs"
1088
+ };
1088
1089
  var BIN_DIR = join6(homedir4(), ".local", "bin");
1089
- function installLaunchers() {
1090
- mkdirSync2(BIN_DIR, { recursive: true });
1091
- const installed = [];
1092
- for (const [name, target] of Object.entries(LAUNCHERS)) {
1093
- const path = join6(BIN_DIR, name);
1094
- writeFileSync2(
1095
- path,
1096
- `#!/bin/sh
1090
+ var script = (target) => `#!/bin/sh
1097
1091
  # Installed by darkhunt endpoint-plugins. Resolves the newest installed
1098
1092
  # plugin version at run time; do not hard-code a version here.
1099
1093
  D=$(ls -d "$HOME"/.codex/plugins/cache/*/darkhunt-guard/*/dist/bin 2>/dev/null | sort -V | tail -1)
1100
1094
  [ -n "$D" ] || exit 0
1101
1095
  exec "$D/${target}" "$@"
1102
- `,
1103
- { mode: 493 }
1104
- );
1096
+ `;
1097
+ function installLaunchers(binDir = BIN_DIR) {
1098
+ mkdirSync2(binDir, { recursive: true });
1099
+ const installed = [];
1100
+ for (const [name, target] of Object.entries(LAUNCHERS)) {
1101
+ const path = join6(binDir, name);
1102
+ writeFileSync2(path, script(target), { mode: 493 });
1105
1103
  chmodSync2(path, 493);
1106
1104
  installed.push(path);
1107
1105
  }
1108
1106
  return installed;
1109
1107
  }
1108
+ function launcherState(binDir = BIN_DIR, path = process.env["PATH"] ?? "") {
1109
+ const missing = [];
1110
+ const stale = [];
1111
+ for (const [name, target] of Object.entries(LAUNCHERS)) {
1112
+ const file = join6(binDir, name);
1113
+ if (!existsSync2(file)) {
1114
+ missing.push(name);
1115
+ continue;
1116
+ }
1117
+ let body = "";
1118
+ try {
1119
+ body = readFileSync2(file, "utf8");
1120
+ } catch {
1121
+ }
1122
+ if (body !== script(target)) stale.push(name);
1123
+ }
1124
+ return { binDir, missing, stale, onPath: path.split(":").includes(binDir) };
1125
+ }
1126
+ function launcherWarnings(state) {
1127
+ const lines = [];
1128
+ if (state.missing.length > 0) {
1129
+ lines.push(
1130
+ `hook launchers missing: ${state.missing.join(", ")} \u2014 hooks cannot run.`,
1131
+ ` install them: install-hooks.mjs`
1132
+ );
1133
+ }
1134
+ if (state.stale.length > 0) {
1135
+ lines.push(
1136
+ `hook launchers are not the ones this version writes: ${state.stale.join(", ")}.`,
1137
+ ` refresh them: install-hooks.mjs`
1138
+ );
1139
+ }
1140
+ if (!state.onPath) {
1141
+ lines.push(
1142
+ `${state.binDir} is not on PATH \u2014 hooks will not run.`,
1143
+ ` add it: export PATH="${state.binDir}:$PATH" in your shell profile, then open a new terminal.`
1144
+ );
1145
+ }
1146
+ return lines;
1147
+ }
1148
+
1149
+ // adapters/codex/bin/init.mjs
1110
1150
  try {
1111
1151
  const r = init(process.argv.slice(2));
1112
1152
  const launchers = installLaunchers();
@@ -1118,10 +1158,11 @@ try {
1118
1158
  `).join("") + ` capture: on enforce: off
1119
1159
  `
1120
1160
  );
1121
- if (!(process.env["PATH"] ?? "").split(":").includes(BIN_DIR)) {
1161
+ const warnings = launcherWarnings(launcherState());
1162
+ if (warnings.length > 0) {
1122
1163
  process.stdout.write(`
1123
- darkhunt: WARNING ${BIN_DIR} is not on PATH \u2014 hooks will not run.
1124
- `);
1164
+ ${warnings.map((l) => `darkhunt: WARNING ${l}
1165
+ `).join("")}`);
1125
1166
  }
1126
1167
  } catch (err) {
1127
1168
  process.stderr.write(`darkhunt: ${err.message}
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire as __dhCreateRequire } from 'node:module';
3
+ const require = __dhCreateRequire(import.meta.url);
4
+
5
+ // adapters/codex/bin/lib/launchers.mjs
6
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ var LAUNCHERS = {
10
+ "darkhunt-codex-spool": "spool.mjs",
11
+ "darkhunt-codex-guard": "guard.mjs"
12
+ };
13
+ var BIN_DIR = join(homedir(), ".local", "bin");
14
+ var script = (target) => `#!/bin/sh
15
+ # Installed by darkhunt endpoint-plugins. Resolves the newest installed
16
+ # plugin version at run time; do not hard-code a version here.
17
+ D=$(ls -d "$HOME"/.codex/plugins/cache/*/darkhunt-guard/*/dist/bin 2>/dev/null | sort -V | tail -1)
18
+ [ -n "$D" ] || exit 0
19
+ exec "$D/${target}" "$@"
20
+ `;
21
+ function installLaunchers(binDir = BIN_DIR) {
22
+ mkdirSync(binDir, { recursive: true });
23
+ const installed2 = [];
24
+ for (const [name, target] of Object.entries(LAUNCHERS)) {
25
+ const path = join(binDir, name);
26
+ writeFileSync(path, script(target), { mode: 493 });
27
+ chmodSync(path, 493);
28
+ installed2.push(path);
29
+ }
30
+ return installed2;
31
+ }
32
+ function launcherState(binDir = BIN_DIR, path = process.env["PATH"] ?? "") {
33
+ const missing = [];
34
+ const stale = [];
35
+ for (const [name, target] of Object.entries(LAUNCHERS)) {
36
+ const file = join(binDir, name);
37
+ if (!existsSync(file)) {
38
+ missing.push(name);
39
+ continue;
40
+ }
41
+ let body = "";
42
+ try {
43
+ body = readFileSync(file, "utf8");
44
+ } catch {
45
+ }
46
+ if (body !== script(target)) stale.push(name);
47
+ }
48
+ return { binDir, missing, stale, onPath: path.split(":").includes(binDir) };
49
+ }
50
+ function launcherWarnings(state) {
51
+ const lines = [];
52
+ if (state.missing.length > 0) {
53
+ lines.push(
54
+ `hook launchers missing: ${state.missing.join(", ")} \u2014 hooks cannot run.`,
55
+ ` install them: install-hooks.mjs`
56
+ );
57
+ }
58
+ if (state.stale.length > 0) {
59
+ lines.push(
60
+ `hook launchers are not the ones this version writes: ${state.stale.join(", ")}.`,
61
+ ` refresh them: install-hooks.mjs`
62
+ );
63
+ }
64
+ if (!state.onPath) {
65
+ lines.push(
66
+ `${state.binDir} is not on PATH \u2014 hooks will not run.`,
67
+ ` add it: export PATH="${state.binDir}:$PATH" in your shell profile, then open a new terminal.`
68
+ );
69
+ }
70
+ return lines;
71
+ }
72
+
73
+ // adapters/codex/bin/install-hooks.mjs
74
+ var installed = installLaunchers();
75
+ process.stdout.write(
76
+ `darkhunt: hook launchers installed
77
+ ` + installed.map((p) => ` ${p}
78
+ `).join("")
79
+ );
80
+ var warnings = launcherWarnings(launcherState());
81
+ if (warnings.length > 0) {
82
+ process.stdout.write(`
83
+ ${warnings.map((l) => `darkhunt: ${l}
84
+ `).join("")}`);
85
+ }
86
+ process.stdout.write(`
87
+ capture starts on your NEXT Codex CLI session.
88
+ `);
@@ -2,6 +2,11 @@
2
2
  import { createRequire as __dhCreateRequire } from 'node:module';
3
3
  const require = __dhCreateRequire(import.meta.url);
4
4
 
5
+ // adapters/codex/bin/status.mjs
6
+ import { readFileSync as readFileSync7 } from "node:fs";
7
+ import { dirname, join as join11 } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
5
10
  // packages/core/dist/config/local.js
6
11
  import { existsSync, readFileSync, statSync } from "node:fs";
7
12
  import { join as join3 } from "node:path";
@@ -1318,17 +1323,87 @@ var codexTranscript = {
1318
1323
  }
1319
1324
  };
1320
1325
 
1326
+ // adapters/codex/bin/lib/launchers.mjs
1327
+ import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
1328
+ import { homedir as homedir5 } from "node:os";
1329
+ import { join as join10 } from "node:path";
1330
+ var LAUNCHERS = {
1331
+ "darkhunt-codex-spool": "spool.mjs",
1332
+ "darkhunt-codex-guard": "guard.mjs"
1333
+ };
1334
+ var BIN_DIR = join10(homedir5(), ".local", "bin");
1335
+ var script = (target) => `#!/bin/sh
1336
+ # Installed by darkhunt endpoint-plugins. Resolves the newest installed
1337
+ # plugin version at run time; do not hard-code a version here.
1338
+ D=$(ls -d "$HOME"/.codex/plugins/cache/*/darkhunt-guard/*/dist/bin 2>/dev/null | sort -V | tail -1)
1339
+ [ -n "$D" ] || exit 0
1340
+ exec "$D/${target}" "$@"
1341
+ `;
1342
+ function launcherState(binDir = BIN_DIR, path = process.env["PATH"] ?? "") {
1343
+ const missing = [];
1344
+ const stale = [];
1345
+ for (const [name, target] of Object.entries(LAUNCHERS)) {
1346
+ const file = join10(binDir, name);
1347
+ if (!existsSync3(file)) {
1348
+ missing.push(name);
1349
+ continue;
1350
+ }
1351
+ let body = "";
1352
+ try {
1353
+ body = readFileSync6(file, "utf8");
1354
+ } catch {
1355
+ }
1356
+ if (body !== script(target)) stale.push(name);
1357
+ }
1358
+ return { binDir, missing, stale, onPath: path.split(":").includes(binDir) };
1359
+ }
1360
+ function launcherWarnings(state) {
1361
+ const lines = [];
1362
+ if (state.missing.length > 0) {
1363
+ lines.push(
1364
+ `hook launchers missing: ${state.missing.join(", ")} \u2014 hooks cannot run.`,
1365
+ ` install them: install-hooks.mjs`
1366
+ );
1367
+ }
1368
+ if (state.stale.length > 0) {
1369
+ lines.push(
1370
+ `hook launchers are not the ones this version writes: ${state.stale.join(", ")}.`,
1371
+ ` refresh them: install-hooks.mjs`
1372
+ );
1373
+ }
1374
+ if (!state.onPath) {
1375
+ lines.push(
1376
+ `${state.binDir} is not on PATH \u2014 hooks will not run.`,
1377
+ ` add it: export PATH="${state.binDir}:$PATH" in your shell profile, then open a new terminal.`
1378
+ );
1379
+ }
1380
+ return lines;
1381
+ }
1382
+
1321
1383
  // adapters/codex/bin/status.mjs
1322
1384
  try {
1323
1385
  const s = status("codex", codexTranscript);
1324
1386
  const n = (x) => x.toLocaleString();
1325
1387
  console.log(`darkhunt endpoint status \u2014 codex`);
1388
+ const root = join11(dirname(fileURLToPath(import.meta.url)), "..", "..");
1389
+ let installed = "unknown";
1390
+ try {
1391
+ installed = JSON.parse(readFileSync7(join11(root, "package.json"), "utf8")).version;
1392
+ } catch {
1393
+ }
1394
+ console.log(` plugin ${installed} ${root}`);
1326
1395
  console.log(` profile ${s.profile} -> ${s.baseUrl}`);
1327
1396
  console.log(` tenant ${s.tenantId} workspace ${s.workspaceId}`);
1328
1397
  console.log(
1329
1398
  ` user ${s.userId ?? "(none \u2014 traces are unattributed)"}` + (s.userIdSource === "none" ? "" : ` [from ${s.userIdSource}]`)
1330
1399
  );
1331
1400
  console.log(` capture ${s.capture ? "ON" : "OFF"} enforce ${s.enforce}`);
1401
+ const launchers = launcherState();
1402
+ const launcherProblems = launcherWarnings(launchers);
1403
+ console.log(
1404
+ ` launchers ${launcherProblems.length === 0 ? `OK (${launchers.binDir}, on PATH)` : "PROBLEM"}`
1405
+ );
1406
+ for (const line of launcherProblems) console.log(` ${line}`);
1332
1407
  console.log(
1333
1408
  ` hook events ${s.spoolEvents}${s.spoolLastAt ? " last " + s.spoolLastAt : " (no session hook has ever fired)"}`
1334
1409
  );
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@darkhunt-security/endpoint-codex",
3
- "version": "0.9.0",
3
+ "version": "0.9.3",
4
4
  "type": "module",
5
5
  "description": "Darkhunt endpoint adapter for Codex CLI: hook codec, rollout transcript mapper, plugin manifest.",
6
+ "bin": {
7
+ "endpoint-codex": "dist/bin/enroll.mjs"
8
+ },
6
9
  "files": [
7
10
  "dist/bin",
8
11
  "hooks",
@@ -10,8 +13,8 @@
10
13
  ".codex-plugin"
11
14
  ],
12
15
  "devDependencies": {
13
- "@darkhunt-security/endpoint-contracts": "0.9.0",
14
- "@darkhunt-security/endpoint-core": "0.9.0"
16
+ "@darkhunt-security/endpoint-contracts": "0.9.1",
17
+ "@darkhunt-security/endpoint-core": "0.9.1"
15
18
  },
16
19
  "publishConfig": {
17
20
  "access": "public",