@gajae-code/coding-agent 0.11.8 → 0.11.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/dist/types/cli.d.ts +1 -0
  3. package/dist/types/config/model-profiles.d.ts +2 -3
  4. package/dist/types/config/model-registry.d.ts +2 -0
  5. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
  6. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
  7. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
  8. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
  9. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
  10. package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
  11. package/dist/types/gjc-runtime/state-writer.d.ts +45 -0
  12. package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
  13. package/dist/types/modes/components/welcome.d.ts +2 -0
  14. package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
  15. package/dist/types/runtime-mcp/redaction.d.ts +2 -0
  16. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -1
  17. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  18. package/dist/types/sdk/bus/index.d.ts +8 -1
  19. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
  20. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +14 -7
  21. package/dist/types/sdk/bus/telegram-daemon.d.ts +74 -5
  22. package/dist/types/sdk/session.d.ts +7 -0
  23. package/dist/types/session/agent-session.d.ts +2 -0
  24. package/dist/types/session/internal/managed-session-scope.d.ts +13 -3
  25. package/dist/types/session/internal/managed-session-storage.d.ts +8 -3
  26. package/dist/types/session/session-manager.d.ts +5 -1
  27. package/dist/types/session/session-storage.d.ts +13 -0
  28. package/dist/types/skill-state/active-state.d.ts +4 -0
  29. package/dist/types/task/provider-retry-status.d.ts +13 -0
  30. package/dist/types/task/types.d.ts +3 -0
  31. package/dist/types/tools/index.d.ts +3 -1
  32. package/package.json +8 -8
  33. package/scripts/generate-sdk-operation-inventory.ts +1 -0
  34. package/src/cli/mcp-cli.ts +4 -21
  35. package/src/cli/notify-cli.ts +1 -0
  36. package/src/cli.ts +32 -0
  37. package/src/commands/deep-interview.ts +9 -4
  38. package/src/commit/agentic/index.ts +3 -3
  39. package/src/commit/map-reduce/index.ts +2 -2
  40. package/src/config/model-profiles.ts +9 -10
  41. package/src/config/model-registry.ts +4 -2
  42. package/src/defaults/gjc/skills/deep-interview/SKILL.md +78 -163
  43. package/src/eval/js/tool-bridge.ts +1 -1
  44. package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
  45. package/src/gjc-runtime/deep-interview-draft.ts +843 -0
  46. package/src/gjc-runtime/deep-interview-payload.ts +262 -0
  47. package/src/gjc-runtime/deep-interview-recorder.ts +310 -227
  48. package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
  49. package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
  50. package/src/gjc-runtime/deep-interview-state.ts +916 -4
  51. package/src/gjc-runtime/ralplan-runtime.ts +3 -1
  52. package/src/gjc-runtime/state-writer.ts +166 -2
  53. package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
  54. package/src/gjc-runtime/workflow-manifest.ts +191 -5
  55. package/src/hooks/skill-state.ts +23 -1
  56. package/src/internal-urls/docs-index.generated.ts +6 -5
  57. package/src/modes/components/welcome.ts +10 -2
  58. package/src/modes/controllers/selector-controller.ts +38 -8
  59. package/src/modes/interactive-mode.ts +29 -9
  60. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  61. package/src/prompts/agents/architect.md +8 -1
  62. package/src/prompts/agents/critic.md +8 -1
  63. package/src/prompts/agents/planner.md +8 -1
  64. package/src/prompts/system/system-prompt.md +1 -0
  65. package/src/runtime-mcp/json-rpc.ts +7 -4
  66. package/src/runtime-mcp/redaction.ts +30 -0
  67. package/src/sdk/broker/identity.ts +22 -12
  68. package/src/sdk/broker/session-index.ts +11 -1
  69. package/src/sdk/bus/chat-daemon-control.ts +5 -3
  70. package/src/sdk/bus/daemon-paths.ts +2 -0
  71. package/src/sdk/bus/index.ts +60 -5
  72. package/src/sdk/bus/notification-service.ts +19 -5
  73. package/src/sdk/bus/operator-runtime.ts +31 -5
  74. package/src/sdk/bus/telegram-cli.ts +6 -13
  75. package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
  76. package/src/sdk/bus/telegram-daemon-contract.ts +15 -7
  77. package/src/sdk/bus/telegram-daemon-control.ts +14 -5
  78. package/src/sdk/bus/telegram-daemon.ts +685 -120
  79. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  80. package/src/sdk/session.ts +12 -0
  81. package/src/session/agent-session.ts +140 -10
  82. package/src/session/internal/managed-session-scope.ts +339 -228
  83. package/src/session/internal/managed-session-storage.ts +17 -4
  84. package/src/session/session-manager.ts +96 -33
  85. package/src/session/session-storage.ts +23 -0
  86. package/src/skill-state/active-state.ts +10 -0
  87. package/src/skill-state/workflow-mutation-guard.ts +41 -7
  88. package/src/task/executor.ts +154 -44
  89. package/src/task/provider-retry-status.ts +88 -0
  90. package/src/task/render.ts +45 -26
  91. package/src/task/types.ts +3 -0
  92. package/src/tools/ask.ts +1 -0
  93. package/src/tools/bash-allowed-prefixes.ts +45 -1
  94. package/src/tools/index.ts +3 -1
  95. package/src/tools/subagent-render.ts +74 -16
  96. package/src/tools/subagent.ts +2 -0
  97. package/vendor/insane-search/MANIFEST.json +4 -1
  98. package/vendor/insane-search/engine/bias_check.py +5 -0
  99. package/vendor/insane-search/engine/fetch_chain.py +67 -1
  100. package/vendor/insane-search/engine/transport.py +13 -2
  101. package/vendor/insane-search/engine/validators.py +4 -1
@@ -26,9 +26,10 @@ import {
26
26
  } from "./native-publish-outcome";
27
27
 
28
28
  export const MANAGED_ARTIFACT_MAX_DEPTH = 32;
29
- export const MANAGED_ARTIFACT_MAX_FILES = 10_000;
29
+ export const MANAGED_ARTIFACT_MAX_FILES = 50_000;
30
30
  export const MANAGED_ARTIFACT_MAX_FILE_BYTES = 64 * 1024 * 1024;
31
31
  export const MANAGED_ARTIFACT_MAX_TOTAL_BYTES = 512 * 1024 * 1024;
32
+ export const MANAGED_ARTIFACT_COPY_BATCH_SIZE = 256;
32
33
  const LOCK_LEASE_MS = 60_000;
33
34
  const LOCK_HEARTBEAT_MS = 10_000;
34
35
  const LOCK_WAIT_MS = 5_000;
@@ -120,6 +121,7 @@ export type ManagedStorageFailure =
120
121
  | "destination_conflict"
121
122
  | "source_changed"
122
123
  | "unsafe_artifacts"
124
+ | "artifact_capacity_exceeded"
123
125
  | "durability_failed"
124
126
  | "atomic_unavailable"
125
127
  | "durability_not_provable"
@@ -1638,8 +1640,20 @@ export async function acquireManagedLock(
1638
1640
  }
1639
1641
  }
1640
1642
 
1643
+ export interface ManagedArtifactTreeLimits {
1644
+ maxFiles?: number;
1645
+ maxTotalBytes?: number;
1646
+ }
1647
+
1641
1648
  /** Bounds a no-follow artifact tree before it can be copied or deleted. */
1642
- export function validateManagedArtifactTree(root: string): void {
1649
+ export function validateManagedArtifactTree(root: string, limits: ManagedArtifactTreeLimits = {}): void {
1650
+ const clampLimit = (limit: number | undefined, maximum: number): number => {
1651
+ if (limit === undefined) return maximum;
1652
+ if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit <= 0) throw new Error("unsafe_artifacts");
1653
+ return Math.min(limit, maximum);
1654
+ };
1655
+ const maxFiles = clampLimit(limits.maxFiles, MANAGED_ARTIFACT_MAX_FILES);
1656
+ const maxTotalBytes = clampLimit(limits.maxTotalBytes, MANAGED_ARTIFACT_MAX_TOTAL_BYTES);
1643
1657
  let files = 0;
1644
1658
  let bytes = 0;
1645
1659
  const visit = (directory: string, depth: number): void => {
@@ -1656,8 +1670,7 @@ export function validateManagedArtifactTree(root: string): void {
1656
1670
  if (!stat.isFile() || stat.size > MANAGED_ARTIFACT_MAX_FILE_BYTES) throw new Error("unsafe_artifacts");
1657
1671
  files++;
1658
1672
  bytes += stat.size;
1659
- if (files > MANAGED_ARTIFACT_MAX_FILES || bytes > MANAGED_ARTIFACT_MAX_TOTAL_BYTES)
1660
- throw new Error("unsafe_artifacts");
1673
+ if (files > maxFiles || bytes > maxTotalBytes) throw new Error("artifact_capacity_exceeded");
1661
1674
  }
1662
1675
  };
1663
1676
  const rootStat = fs.lstatSync(root);
@@ -547,6 +547,13 @@ export class SessionMigrationPolicyError extends Error {
547
547
  }
548
548
  }
549
549
 
550
+ export class SessionArtifactCapacityError extends Error {
551
+ constructor(message: string) {
552
+ super(message);
553
+ this.name = "SessionArtifactCapacityError";
554
+ }
555
+ }
556
+
550
557
  export type ResumeTailInspection = ResumeTailResumable | ResumeTailTerminal | ResumeTailError;
551
558
  export interface StrictSessionOpenSuccess {
552
559
  kind: "opened";
@@ -555,7 +562,8 @@ export interface StrictSessionOpenSuccess {
555
562
 
556
563
  export interface StrictSessionOpenFailure {
557
564
  kind: "error";
558
- reason: ResumeTailError["reason"] | "identity-mismatch" | "migration-required";
565
+ reason: ResumeTailError["reason"] | "identity-mismatch" | "migration-required" | "artifact_capacity_exceeded";
566
+ message?: string;
559
567
  }
560
568
 
561
569
  /** Exact transcript bytes and authority captured from one descriptor-bound read. */
@@ -2110,41 +2118,90 @@ export async function recoverOrphanedBackups(sessionDir: string, storage: Sessio
2110
2118
  }
2111
2119
 
2112
2120
  /**
2113
- * Reads all session files from the directory and returns them sorted by mtime (newest first).
2114
- * Uses low-level file I/O to efficiently read only the first 4KB of each file
2115
- * to extract the JSON header and first user message without loading entire session logs into memory.
2121
+ * Returns session metadata sorted by mtime (newest first).
2122
+ *
2123
+ * Directory entries are cheap to stat, but opening every transcript before applying a
2124
+ * small caller limit makes welcome-screen startup scale with the entire session history.
2125
+ * Rank paths first, then read bounded batches until the requested number of valid
2126
+ * sessions has been found. Invalid or future-version files do not prevent older valid
2127
+ * sessions from filling the result.
2116
2128
  */
2117
- async function getSortedSessions(sessionDir: string, storage: SessionStorage): Promise<RecentSessionInfo[]> {
2129
+ async function getSortedSessions(
2130
+ sessionDir: string,
2131
+ storage: SessionStorage,
2132
+ limit?: number,
2133
+ ): Promise<RecentSessionInfo[]> {
2118
2134
  await recoverOrphanedBackups(sessionDir, storage);
2119
2135
  try {
2120
- const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl");
2136
+ let candidates: Array<{ path: string; mtime: number }> | undefined;
2137
+ if (storage.listFilesByMtime) {
2138
+ try {
2139
+ candidates = (await storage.listFilesByMtime(sessionDir, "*.jsonl")).map(candidate => ({
2140
+ path: candidate.path,
2141
+ mtime: candidate.mtimeMs,
2142
+ }));
2143
+ } catch (error) {
2144
+ logger.warn("Native session mtime listing failed; using JavaScript fallback", {
2145
+ sessionDir,
2146
+ error: toError(error).message,
2147
+ });
2148
+ }
2149
+ } else {
2150
+ logger.debug("Native session mtime listing unavailable; using JavaScript fallback", { sessionDir });
2151
+ }
2152
+ candidates ??= storage.listFilesSync(sessionDir, "*.jsonl").flatMap(path => {
2153
+ try {
2154
+ return [{ path, mtime: storage.statSync(path).mtimeMs }];
2155
+ } catch {
2156
+ return [];
2157
+ }
2158
+ });
2159
+ candidates.sort((left, right) => {
2160
+ if (left.mtime !== right.mtime) return right.mtime - left.mtime;
2161
+ return left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
2162
+ });
2163
+
2121
2164
  const sessions: RecentSessionInfo[] = [];
2122
- await Promise.all(
2123
- files.map(async (path: string) => {
2124
- try {
2125
- const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES);
2126
- const content = await readSessionListPrefix(path, storage, buffer);
2127
- const entries = parseJsonlLenient<Record<string, unknown>>(content);
2128
- if (entries.length === 0) return;
2129
- const header = entries[0] as Record<string, unknown>;
2130
- if (
2131
- header.type !== "session" ||
2132
- typeof header.id !== "string" ||
2133
- !isSupportedSessionVersion(header.version)
2134
- )
2135
- return;
2136
- if (typeof header.version === "number" && header.version >= 4) {
2137
- for (const patch of await readSessionListTrailingPatches(path, storage, buffer)) {
2138
- applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
2165
+ const batchSize = Math.max(32, limit ?? 0);
2166
+
2167
+ for (
2168
+ let offset = 0;
2169
+ offset < candidates.length && (limit === undefined || sessions.length < limit);
2170
+ offset += batchSize
2171
+ ) {
2172
+ const batch = candidates.slice(offset, offset + batchSize);
2173
+ const parsed = await Promise.all(
2174
+ batch.map(async candidate => {
2175
+ try {
2176
+ const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES);
2177
+ const content = await readSessionListPrefix(candidate.path, storage, buffer);
2178
+ const entries = parseJsonlLenient<Record<string, unknown>>(content);
2179
+ if (entries.length === 0) return undefined;
2180
+ const header = entries[0] as Record<string, unknown>;
2181
+ if (
2182
+ header.type !== "session" ||
2183
+ typeof header.id !== "string" ||
2184
+ !isSupportedSessionVersion(header.version)
2185
+ )
2186
+ return undefined;
2187
+ if (typeof header.version === "number" && header.version >= 4) {
2188
+ for (const patch of await readSessionListTrailingPatches(candidate.path, storage, buffer)) {
2189
+ applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
2190
+ }
2139
2191
  }
2192
+ const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries);
2193
+ return new RecentSessionInfo(candidate.path, candidate.mtime, header, firstPrompt);
2194
+ } catch {
2195
+ return undefined;
2140
2196
  }
2141
- const mtime = storage.statSync(path).mtimeMs;
2142
- const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries);
2143
- sessions.push(new RecentSessionInfo(path, mtime, header, firstPrompt));
2144
- } catch {}
2145
- }),
2146
- );
2147
- return sessions.sort((a, b) => b.mtime - a.mtime);
2197
+ }),
2198
+ );
2199
+ for (const session of parsed) {
2200
+ if (session) sessions.push(session);
2201
+ if (limit !== undefined && sessions.length >= limit) break;
2202
+ }
2203
+ }
2204
+ return sessions;
2148
2205
  } catch {
2149
2206
  return [];
2150
2207
  }
@@ -2155,7 +2212,7 @@ export async function findMostRecentSession(
2155
2212
  sessionDir: string,
2156
2213
  storage: SessionStorage = new FileSessionStorage(),
2157
2214
  ): Promise<string | null> {
2158
- const sessions = await getSortedSessions(sessionDir, storage);
2215
+ const sessions = await getSortedSessions(sessionDir, storage, 1);
2159
2216
  return sessions[0]?.path || null;
2160
2217
  }
2161
2218
 
@@ -3427,8 +3484,7 @@ export async function getRecentSessions(
3427
3484
  limit = DEFAULT_WELCOME_RECENT_SESSION_LIMIT,
3428
3485
  storage: SessionStorage = new FileSessionStorage(),
3429
3486
  ): Promise<RecentSessionInfo[]> {
3430
- const sessions = await getSortedSessions(sessionDir, storage);
3431
- return sessions.slice(0, limit);
3487
+ return getSortedSessions(sessionDir, storage, limit);
3432
3488
  }
3433
3489
 
3434
3490
  /**
@@ -7299,6 +7355,7 @@ export class SessionManager {
7299
7355
  if (opened.kind === "error") {
7300
7356
  managedDestinationStore.assertBound();
7301
7357
  if (opened.code === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
7358
+ if (opened.code === "artifact_capacity_exceeded") throw new SessionArtifactCapacityError(opened.message);
7302
7359
  throw new Error(`Could not open managed session: ${opened.message}`);
7303
7360
  }
7304
7361
  assertManagedDestinationBound();
@@ -7809,6 +7866,8 @@ export class SessionManager {
7809
7866
  } catch (error) {
7810
7867
  if (error instanceof SessionMigrationPolicyError)
7811
7868
  return { kind: "error", reason: "legacy_migration_disabled" };
7869
+ if (error instanceof SessionArtifactCapacityError)
7870
+ return { kind: "error", reason: "artifact_capacity_exceeded", message: error.message };
7812
7871
  if (
7813
7872
  error instanceof Error &&
7814
7873
  (error.message.includes("source_changed") || error.message.includes("changed before migration"))
@@ -7867,6 +7926,10 @@ export class SessionManager {
7867
7926
  );
7868
7927
  if (opened.kind === "error") {
7869
7928
  if (opened.reason === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
7929
+ if (opened.reason === "artifact_capacity_exceeded")
7930
+ throw new SessionArtifactCapacityError(
7931
+ opened.message ?? "Session artifacts exceed the migration capacity.",
7932
+ );
7870
7933
  return undefined;
7871
7934
  }
7872
7935
  return opened.manager;
@@ -167,6 +167,8 @@ export interface SessionStorage {
167
167
  readSnapshotSync?(path: string): SessionStorageSnapshot;
168
168
  statSync(path: string): SessionStorageStat;
169
169
  listFilesSync(dir: string, pattern: string): string[];
170
+ /** List matching files with mtimes without issuing one JavaScript stat call per path. */
171
+ listFilesByMtime?(dir: string, pattern: string): Promise<Array<{ path: string; mtimeMs: number }>>;
170
172
  /**
171
173
  * Strict directory scan that never suppresses scan/root errors. Used by strict
172
174
  * authorization inventory; the forgiving {@link listFilesSync} stays display-only.
@@ -705,6 +707,21 @@ export class FileSessionStorage implements SessionStorage {
705
707
  return [];
706
708
  }
707
709
  }
710
+ async listFilesByMtime(dir: string, pattern: string): Promise<Array<{ path: string; mtimeMs: number }>> {
711
+ const result = await native.glob({
712
+ path: dir,
713
+ pattern,
714
+ fileType: native.FileType.File,
715
+ recursive: false,
716
+ hidden: false,
717
+ gitignore: false,
718
+ sortByMtime: true,
719
+ });
720
+ return result.matches.map(match => ({
721
+ path: path.join(dir, match.path),
722
+ mtimeMs: match.mtime ?? 0,
723
+ }));
724
+ }
708
725
 
709
726
  listFilesStrictSync(dir: string, pattern: string): string[] {
710
727
  // Strict: never suppress scan/root errors. Authorization inventory depends on
@@ -1470,6 +1487,12 @@ export class MemorySessionStorage implements SessionStorage {
1470
1487
  }
1471
1488
  return files;
1472
1489
  }
1490
+ listFilesByMtime(dir: string, pattern: string): Promise<Array<{ path: string; mtimeMs: number }>> {
1491
+ const files = this.listFilesSync(dir, pattern)
1492
+ .map(path => ({ path, mtimeMs: this.statSync(path).mtimeMs }))
1493
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
1494
+ return Promise.resolve(files);
1495
+ }
1473
1496
  listFilesStrictSync(dir: string, pattern: string): string[] {
1474
1497
  // In-memory scan never suppresses; identical to the display scan.
1475
1498
  return this.listFilesSync(dir, pattern);
@@ -71,6 +71,8 @@ export interface SkillActiveEntry {
71
71
  handoff_at?: string;
72
72
  active_subskills?: ActiveSubskillEntry[];
73
73
  source_state_revision?: number;
74
+ /** Durable ordering token from the source workflow mode-state, not this cache file's revision. */
75
+ committed_mode_state_revision?: number;
74
76
  }
75
77
 
76
78
  export interface SkillActiveState {
@@ -114,6 +116,8 @@ export interface SyncSkillActiveStateOptions {
114
116
  handoff_at?: string;
115
117
  active_subskills?: ActiveSubskillEntry[];
116
118
  sourceRevision?: number;
119
+ /** Durable ordering token from the source workflow mode-state, not this cache file's revision. */
120
+ committedModeRevision?: number;
117
121
  }
118
122
 
119
123
  const HUD_TEXT_LIMIT = 80;
@@ -785,6 +789,9 @@ async function persistActiveEntry(
785
789
  await writeActiveEntry(cwd, sessionScope, entry.skill, entry, {
786
790
  cwd,
787
791
  audit: activeStateWriterAudit("write-active-entry", sessionScope),
792
+ ...(typeof entry.committed_mode_state_revision === "number"
793
+ ? { orderingRevision: entry.committed_mode_state_revision }
794
+ : {}),
788
795
  });
789
796
  }
790
797
  }
@@ -863,6 +870,9 @@ export async function syncSkillActiveState(options: SyncSkillActiveStateOptions)
863
870
  ? { active_subskills: preservedActiveSubskills }
864
871
  : {}),
865
872
  ...(typeof options.sourceRevision === "number" ? { source_state_revision: options.sourceRevision } : {}),
873
+ ...(typeof options.committedModeRevision === "number"
874
+ ? { committed_mode_state_revision: options.committedModeRevision }
875
+ : {}),
866
876
  };
867
877
  const sessionScope = { sessionId: options.sessionId };
868
878
  await removeSupersededPlanningPipelineEntries(options.cwd, sessionScope, entry);
@@ -35,17 +35,28 @@ function planningPhaseBlockMessage(skill: CanonicalGjcWorkflowSkill): string {
35
35
  }
36
36
 
37
37
  const BLOCKED_TOOL_NAMES = new Set(["edit", "write", "ast_edit", "bash"]);
38
+ /**
39
+ * Only `/dev/null` is exempt. `/dev/stdout`, `/dev/stderr`, and `/dev/fd/<n>` are descriptor
40
+ * aliases: `exec 1<>src/product.ts; printf x >/dev/stdout` reaches a real repository file
41
+ * through a rebound descriptor, so they must stay blocked.
42
+ */
43
+ const DEVICE_SINK_PATHS = new Set(["/dev/null"]);
44
+ /** Bash write forms the plain `>`/`>>` scanner misses: clobber, dup-to-path, and read-write open. */
45
+ const BASH_EXTENDED_WRITE_RE = /(?:>\||<>|>&(?![\s;&|]*(?:\d+|-)(?:[\s;&|]|$)))\s*([^\s;&|]+)/g;
46
+ /** Descriptor rebinding is not statically resolvable; any `exec` redirection forces a fail-closed verdict. */
47
+ const BASH_EXEC_REDIRECT_RE = /(?:^|[;&|\n])\s*(?:sudo\s+)?exec\b[^;&|\n]*[<>]/i;
48
+
38
49
  const ARCHIVE_OR_SQLITE_BASE_RE = /^(.+?\.(?:tar\.gz|sqlite3|sqlite|db3|zip|tgz|tar|db))(?:$|:)/i;
39
50
  const INTERNAL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
40
51
  const VIM_FILE_SWITCH_RE = /^\s*:(?:e|e!|edit|edit!)(?:\s+([^<\r\n]+))?(?:<CR>|\r|\n|$)/i;
41
52
  const BASH_MUTATION_COMMAND_RE =
42
- /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:tee|touch|rm|mkdir|cp|mv|install|truncate)\b([^;&|\n]*)|(?:^|[^<>])(?:>>?|\d>>?)\s*([^\s;&|]+)/gi;
53
+ /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?(?:tee|touch|rm|mkdir|cp|mv|install|truncate)\b([^;&|\n]*)|(?:^|[^<>])(?:>>?|\d>>?)\s*([^\s;&|]+)/gi;
43
54
  const BASH_IN_PLACE_MUTATION_COMMAND_RE = /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:sed|perl)\b([^;&|\n]*)/gi;
44
55
  const BASH_OPAQUE_INTERPRETER_WRITE_RE =
45
56
  /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:python3?|node|ruby)\b[^;&|\n]*(?:-c|-e)\b[^;&|\n]*(?:open\s*\(|writeFile(?:Sync)?\s*\(|\.write\s*\()/i;
46
57
  const BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE =
47
58
  /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:python3?|node|ruby)\b[^;&|\n]*(?:<<[-]?\s*['"]?\w+['"]?)[\s\S]*(?:open\s*\(|writeFile(?:Sync)?\s*\(|\.write\s*\()/i;
48
- const BASH_DD_OUTPUT_RE = /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?dd\b([^;&|\n]*)/gi;
59
+ const BASH_DD_OUTPUT_RE = /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?dd\b([^;&|\n]*)/gi;
49
60
 
50
61
  type ToolWithEditMode = AgentTool & {
51
62
  mode?: unknown;
@@ -371,6 +382,10 @@ function cleanShellWord(value: string): string {
371
382
  return value.replace(/^['"]|['"]$/g, "");
372
383
  }
373
384
 
385
+ function isDeviceSinkPath(value: string): boolean {
386
+ return DEVICE_SINK_PATHS.has(cleanShellWord(value));
387
+ }
388
+
374
389
  function extractBashTargets(args: unknown): ExtractedTargets {
375
390
  const record = getRecord(args);
376
391
  const command = safeString(record?.command);
@@ -382,11 +397,25 @@ function extractBashTargets(args: unknown): ExtractedTargets {
382
397
  if (BASH_OPAQUE_INTERPRETER_WRITE_RE.test(command) || BASH_HEREDOC_OPAQUE_INTERPRETER_WRITE_RE.test(command)) {
383
398
  targets.unknown = true;
384
399
  }
400
+ // `exec 1<>file` rebinds a descriptor, so a later `>/dev/null` may not reach the device at all.
401
+ if (BASH_EXEC_REDIRECT_RE.test(command)) {
402
+ targets.unknown = true;
403
+ }
404
+ for (const match of command.matchAll(BASH_EXTENDED_WRITE_RE)) {
405
+ const cleaned = cleanShellWord(match[1] ?? "");
406
+ if (!cleaned) targets.unknown = true;
407
+ else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
408
+ }
385
409
  for (const match of command.matchAll(BASH_DD_OUTPUT_RE)) {
386
410
  const parts = shellWords(match[1] ?? "").map(cleanShellWord);
387
- const output = parts.find(part => part.startsWith("of="));
388
- if (output) addPath(targets, output.slice(3));
389
- else targets.unknown = true;
411
+ // Every `of=` counts: GNU dd honors the last one, so `of=/dev/null of=real.ts` writes real.ts.
412
+ const outputs = parts.filter(part => part.startsWith("of="));
413
+ if (outputs.length === 0) targets.unknown = true;
414
+ for (const output of outputs) {
415
+ const outputPath = cleanShellWord(output.slice(3));
416
+ if (!outputPath) targets.unknown = true;
417
+ else if (!isDeviceSinkPath(outputPath)) addPath(targets, outputPath);
418
+ }
390
419
  }
391
420
  for (const match of command.matchAll(BASH_IN_PLACE_MUTATION_COMMAND_RE)) {
392
421
  const parts = shellWords(match[1] ?? "").map(cleanShellWord);
@@ -399,12 +428,17 @@ function extractBashTargets(args: unknown): ExtractedTargets {
399
428
  for (const match of command.matchAll(BASH_MUTATION_COMMAND_RE)) {
400
429
  const redirected = match[2]?.trim();
401
430
  if (redirected) {
402
- addPath(targets, cleanShellWord(redirected));
431
+ const cleaned = cleanShellWord(redirected);
432
+ // A capture that dequotes to nothing (e.g. `>" src.ts"`) is a truncated parse, not a safe no-op.
433
+ if (!cleaned) targets.unknown = true;
434
+ else if (!isDeviceSinkPath(cleaned)) addPath(targets, cleaned);
403
435
  continue;
404
436
  }
405
437
  const parts = shellWords(match[1] ?? "");
406
438
  const commandName = match[0]
407
- ?.match(/(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(tee|touch|rm|mkdir|cp|mv|install|truncate)\b/i)?.[1]
439
+ ?.match(
440
+ /(?:^|[;&|\n])\s*(?:\w+=[^\s]+\s+)*(?:sudo\s+)?(?:[^\s;&|]*\/)?(tee|touch|rm|mkdir|cp|mv|install|truncate)\b/i,
441
+ )?.[1]
408
442
  ?.toLowerCase();
409
443
  const targetParts =
410
444
  commandName === "cp" || commandName === "mv" || commandName === "install" || commandName === "truncate"