@brainervirus/workit-core 8.19.1 → 8.19.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.19.1",
3
+ "version": "8.19.2",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -32,7 +32,7 @@ const wordsOf = (command: string): string[] =>
32
32
  )
33
33
  .filter(Boolean);
34
34
 
35
- const stateDir = (env: NodeJS.ProcessEnv): string => {
35
+ export const stateDir = (env: NodeJS.ProcessEnv): string => {
36
36
  if (env.WORKFLOW_TOOLKIT_STATE) return env.WORKFLOW_TOOLKIT_STATE;
37
37
  if (env.XDG_STATE_HOME) return path.join(env.XDG_STATE_HOME, "workit");
38
38
  const home = env.HOME || os.homedir();
@@ -17,6 +17,7 @@ import {
17
17
  type LiveDeps,
18
18
  type LiveOwner,
19
19
  } from "../core/live-session";
20
+ import { resolveStateDir } from "../core/logger";
20
21
  import { workspaceLiveSessionGuard } from "../core/workspaces";
21
22
  import { appendHookObserved, ledgerPath } from "../ledger";
22
23
  import type { HookDecision } from "./protocol";
@@ -41,7 +42,8 @@ const canonical = (file: string): string => {
41
42
  let rest = "";
42
43
  for (let dir = path.resolve(file); ; dir = path.dirname(dir)) {
43
44
  try {
44
- return path.join(realpathSync(dir), rest);
45
+ // Native resolution also expands Windows 8.3 short names (RUNNER~1).
46
+ return path.join(realpathSync.native(dir), rest);
45
47
  } catch {
46
48
  if (path.dirname(dir) === dir) return path.resolve(file);
47
49
  rest = rest ? path.join(path.basename(dir), rest) : path.basename(dir);
@@ -325,9 +327,10 @@ const POPD = new Set(["popd", "pop-location"]);
325
327
 
326
328
  /** A `cd` operand resolved against `dir`; undefined when it cannot be known. */
327
329
  const cdInto = (dir: string | undefined, words: string[]): string | undefined => {
328
- const target = words.slice(1).find((word) => !word.startsWith("-") || word === "-");
329
- // Only a leading `~` expands; a mid-path `~` is literal (Windows 8.3
330
+ const named = words.slice(1).find((word) => !word.startsWith("-") || word === "-");
331
+ // Only a leading `~` or `$HOME` expands; a mid-path `~` is literal (Windows 8.3
330
332
  // short names like RUNNER~1).
333
+ const target = named === undefined ? undefined : homeExpanded(named);
331
334
  if (dir === undefined || target === "-" || (target && /^~|[$`*?]/.test(target))) return undefined;
332
335
  return target === undefined ? os.homedir() : path.resolve(dir, target);
333
336
  };
@@ -335,6 +338,12 @@ const cdInto = (dir: string | undefined, words: string[]): string | undefined =>
335
338
  const quoteWord = (word: string): string =>
336
339
  /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
337
340
 
341
+ /** A leading `~`, `$HOME` or `${HOME}` the shell would expand. */
342
+ const homeExpanded = (file: string): string => {
343
+ const match = /^(?:~|\$HOME|\$\{HOME\})(?=\/|$)/.exec(file);
344
+ return match ? (process.env.HOME || os.homedir()) + file.slice(match[0].length) : file;
345
+ };
346
+
338
347
  /**
339
348
  * Per top-level segment, the files it writes (redirects, `touch`, `rm`,
340
349
  * `sed -i`…) and `workit git commit|branch`, resolved against the
@@ -351,8 +360,10 @@ const segmentTargets = (
351
360
  let dir: string | undefined = cwd;
352
361
  let stack: Array<string | undefined> = [];
353
362
  const groups: Array<{ dir: string | undefined; stack: Array<string | undefined> }> = [];
354
- const at = (file: string) =>
355
- path.isAbsolute(file) ? file : dir === undefined ? null : path.resolve(dir, file);
363
+ const at = (file: string) => {
364
+ const home = homeExpanded(file);
365
+ return path.isAbsolute(home) ? home : dir === undefined ? null : path.resolve(dir, home);
366
+ };
356
367
  for (const segment of segmentsOf(command, dialect)) {
357
368
  if (segment.group === "open") {
358
369
  groups.push({ dir, stack: [...stack] });
@@ -474,6 +485,65 @@ const claimAll = (owner: LiveOwner, targets: readonly string[], deps?: LiveDeps)
474
485
  const liveTargets = (cwd: string, paths: readonly string[] | null): string[] =>
475
486
  paths?.length ? paths.map((file) => path.resolve(cwd, file)) : [path.join(cwd, DIR_TARGET)];
476
487
 
488
+ // --- the issued-registry guard (#292) ---------------------------------------
489
+
490
+ /**
491
+ * The files an agent tool call writes under `<state>/issued*`: the issued-id
492
+ * records and their token hashes. Only the hook and the CLI write there; an
493
+ * agent that could would forge an issued session or token by hand.
494
+ */
495
+ const issuedTarget = (cwd: string, targets: readonly string[]): string | null => {
496
+ const state = resolveStateDir();
497
+ const found = targets.find((target) =>
498
+ underIssued(state, cwd, homeExpanded(target), process.platform, canonical),
499
+ );
500
+ return found ?? null;
501
+ };
502
+
503
+ /**
504
+ * Whether `target` (resolved against `cwd`) is `<state>/issued*` or inside
505
+ * it. Pure over `platform`, so Windows shapes are testable anywhere: mixed
506
+ * `\` and `/`, drive-letter case, and the `/c/Users/...` form a POSIX shell
507
+ * on Windows uses. `real` resolves symlinks and 8.3 short names (RUNNER~1) on
508
+ * the live platform.
509
+ */
510
+ export const underIssued = (
511
+ state: string,
512
+ cwd: string,
513
+ target: string,
514
+ platform: NodeJS.Platform = process.platform,
515
+ real: (file: string) => string = (file) => file,
516
+ ): boolean => {
517
+ const win = platform === "win32";
518
+ const p = win ? path.win32 : path.posix;
519
+ const fold = (value: string) => (win || platform === "darwin" ? value.toLowerCase() : value);
520
+ const named = win ? target.replace(/^\/([a-zA-Z])(?=\/|$)/, "$1:") : target;
521
+ const relative = p.relative(fold(real(p.resolve(state))), fold(real(p.resolve(cwd, named))));
522
+ if (relative.startsWith("..") || p.isAbsolute(relative)) return false;
523
+ return relative.split(p.sep)[0].startsWith("issued");
524
+ };
525
+
526
+ /**
527
+ * A POSIX shell drops an unquoted `\` before the analysis sees the words, so
528
+ * `> C:\state\issued\x` loses its separators. On Windows, a command that
529
+ * writes and spells out the registry path is denied.
530
+ */
531
+ const unquotedWindowsIssued = (command: string): boolean => {
532
+ if (process.platform !== "win32" || !command.includes("\\")) return false;
533
+ const flat = (value: string) => value.replaceAll("\\", "/").toLowerCase();
534
+ return flat(command).includes(`${flat(resolveStateDir())}/issued`) && shellWrites(command).writes;
535
+ };
536
+
537
+ const issuedDeny = (file: string): HookDecision => {
538
+ const unblock =
539
+ "Issued ids and tokens are written only by Workit's SubagentStart hook. Run the verifier or reviewer agent so the host issues its own session; never edit the issued registries by hand.";
540
+ return {
541
+ kind: "deny",
542
+ reason: `workit issued-registry guard: ${file} is under Workit's issued-session state. ${unblock}`,
543
+ unblock,
544
+ };
545
+ };
546
+
477
547
  /**
478
548
  * Deny a working-tree edit while the branch task has unmet before-write
479
549
  * requirements. `paths` are the files the tool writes, or null when unknown
@@ -488,6 +558,8 @@ export function writeGate(
488
558
  owner?: LiveOwner | null,
489
559
  deps?: LiveDeps,
490
560
  ): HookDecision {
561
+ const forged = paths?.length ? issuedTarget(cwd, paths) : null;
562
+ if (forged) return issuedDeny(forged);
491
563
  if (!owner) return beforeWriteGate(cwd, paths);
492
564
  const targets = liveTargets(cwd, paths);
493
565
  const live = liveGate(owner, targets, deps);
@@ -511,6 +583,14 @@ export function shellGate(
511
583
  dialect: ShellDialect = "posix",
512
584
  deps?: LiveDeps,
513
585
  ): HookDecision {
586
+ try {
587
+ const forged =
588
+ issuedTarget(cwd, segmentTargets(cwd, command, dialect)) ??
589
+ (unquotedWindowsIssued(command) ? "the issued registry" : null);
590
+ if (forged) return issuedDeny(forged);
591
+ } catch {
592
+ // Fail open, like the other shell analyses here.
593
+ }
514
594
  const writes = shellWrites(command);
515
595
  let targets: string[] = [];
516
596
  if (owner) {