@sema-agent/core 2.12.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/agents/agent-transcript-tool.d.ts +1 -0
  2. package/dist/agents/agent-transcript-tool.js +1 -1
  3. package/dist/agents/cascade.d.ts +4 -0
  4. package/dist/agents/cascade.js +54 -9
  5. package/dist/agents/repair-loop.d.ts +2 -0
  6. package/dist/agents/repair-loop.js +101 -2
  7. package/dist/agents/retain-ledger.d.ts +18 -2
  8. package/dist/agents/retain-ledger.js +37 -8
  9. package/dist/agents/roster-store.d.ts +4 -0
  10. package/dist/agents/roster-store.js +22 -1
  11. package/dist/agents/teacher.js +4 -2
  12. package/dist/agents/team.js +7 -4
  13. package/dist/agents/verify.d.ts +15 -2
  14. package/dist/agents/verify.js +36 -1
  15. package/dist/bin/sema-tb.js +2 -2
  16. package/dist/brain/circuit-breaker.d.ts +1 -0
  17. package/dist/brain/circuit-breaker.js +26 -9
  18. package/dist/brain/degrading.js +13 -1
  19. package/dist/brain/failover.js +2 -0
  20. package/dist/brain/repetition.js +10 -1
  21. package/dist/brain/routing.js +5 -1
  22. package/dist/brain/status-sink.js +5 -1
  23. package/dist/brain/stream-engine.js +4 -1
  24. package/dist/brain/tool-call-repair.js +32 -14
  25. package/dist/core/auto-compaction.js +17 -7
  26. package/dist/core/auto-promote.js +2 -1
  27. package/dist/core/checkpoint-store.d.ts +1 -0
  28. package/dist/core/checkpoint-store.js +28 -10
  29. package/dist/core/consolidate-scope.js +4 -3
  30. package/dist/core/context-edit.js +3 -0
  31. package/dist/core/context-guard.js +3 -0
  32. package/dist/core/git-worktree-env.d.ts +6 -1
  33. package/dist/core/git-worktree-env.js +17 -1
  34. package/dist/core/hooks.d.ts +1 -0
  35. package/dist/core/hooks.js +40 -2
  36. package/dist/core/lsp-diagnostics.js +13 -7
  37. package/dist/core/lsp-protocol.d.ts +1 -1
  38. package/dist/core/lsp-protocol.js +20 -8
  39. package/dist/core/lsp-session.d.ts +12 -2
  40. package/dist/core/lsp-session.js +114 -49
  41. package/dist/core/lsp.d.ts +13 -1
  42. package/dist/core/lsp.js +64 -9
  43. package/dist/core/mcp.d.ts +1 -0
  44. package/dist/core/mcp.js +4 -3
  45. package/dist/core/memory-engine/data-plane.js +4 -0
  46. package/dist/core/memory-engine/engine.d.ts +2 -1
  47. package/dist/core/memory-engine/engine.js +82 -7
  48. package/dist/core/memory-engine/file-backend.d.ts +1 -0
  49. package/dist/core/memory-engine/file-backend.js +13 -3
  50. package/dist/core/memory-engine/layout.js +12 -1
  51. package/dist/core/memory.js +3 -0
  52. package/dist/core/permission-rules.d.ts +2 -1
  53. package/dist/core/permission-rules.js +24 -3
  54. package/dist/core/runner/active-skill-scope.js +34 -6
  55. package/dist/core/runner/assemble-result.d.ts +1 -1
  56. package/dist/core/runner/assemble-result.js +0 -1
  57. package/dist/core/runner/prepare-task.js +23 -3
  58. package/dist/core/runner/runtask.js +48 -30
  59. package/dist/core/runner/tool-disclosure.d.ts +1 -0
  60. package/dist/core/runner/tool-disclosure.js +26 -7
  61. package/dist/core/runner/usage-accounting.d.ts +6 -0
  62. package/dist/core/runner/usage-accounting.js +9 -8
  63. package/dist/core/secret-env.js +5 -1
  64. package/dist/core/sensitive-path-policy.js +9 -3
  65. package/dist/core/session-store.js +15 -4
  66. package/dist/core/skill-tool-specifier.d.ts +8 -0
  67. package/dist/core/skill-tool-specifier.js +58 -0
  68. package/dist/core/skills-directory.d.ts +1 -1
  69. package/dist/core/skills-directory.js +16 -4
  70. package/dist/core/store-contracts/session-repo-contract.js +40 -0
  71. package/dist/core/task-registry-monitor.js +7 -1
  72. package/dist/core/tool-policy.js +65 -17
  73. package/dist/core/trace.d.ts +1 -0
  74. package/dist/core/types.d.ts +3 -5
  75. package/dist/core/with-retry.js +0 -1
  76. package/dist/engine/compaction/compaction.d.ts +1 -0
  77. package/dist/engine/compaction/compaction.js +14 -5
  78. package/dist/engine/lsp/frame-decoder.js +6 -3
  79. package/dist/engine/lsp/node-lsp-manager.d.ts +2 -0
  80. package/dist/engine/lsp/node-lsp-manager.js +103 -26
  81. package/dist/engine/lsp/stdio-lsp-transport.js +16 -7
  82. package/dist/index.d.ts +1 -1
  83. package/dist/internal/harness.d.ts +1 -1
  84. package/dist/internal/harness.js +1 -1
  85. package/dist/internal/llm.d.ts +1 -1
  86. package/dist/orchestration/workflow-governance.js +43 -9
  87. package/dist/orchestration/workflow-sandbox-conformance.js +85 -51
  88. package/dist/orchestration/workflow-script-store.js +34 -4
  89. package/dist/orchestration/workflow.js +10 -6
  90. package/dist/prompt-assembly/artifact-store.d.ts +2 -0
  91. package/dist/prompt-assembly/artifact-store.js +39 -24
  92. package/dist/prompt-assembly/assemble.js +38 -7
  93. package/dist/prompt-assembly/epoch.js +19 -2
  94. package/dist/prompt-assembly/event-registry.js +3 -3
  95. package/dist/prompt-assembly/explain.js +1 -1
  96. package/dist/prompt-assembly/tool-catalog.js +16 -1
  97. package/dist/prompt-assembly/turn-snapshot.js +4 -1
  98. package/dist/stores/file/fs-atomic.d.ts +3 -0
  99. package/dist/stores/file/fs-atomic.js +53 -8
  100. package/dist/stores/file/index.d.ts +1 -0
  101. package/dist/stores/file/index.js +7 -0
  102. package/dist/stores/file/session-store.d.ts +2 -0
  103. package/dist/stores/file/session-store.js +28 -1
  104. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -0
  105. package/dist/tools/fs/bash-readonly-classifier.js +57 -8
  106. package/dist/tools/fs/fs-bash.d.ts +1 -1
  107. package/dist/tools/fs/fs-bash.js +93 -5
  108. package/dist/tools/loop-tick.js +1 -1
  109. package/dist/tools/monitor.js +55 -9
  110. package/dist/tools/scheduler-tools.js +9 -3
  111. package/dist/tools/web.d.ts +8 -2
  112. package/dist/tools/web.js +46 -17
  113. package/dist/tools/worktree.d.ts +2 -0
  114. package/dist/tools/worktree.js +233 -143
  115. package/package.json +5 -2
@@ -20,12 +20,29 @@ const PROBE_FACTS_OFF = {
20
20
  teammateEnabled: false,
21
21
  orchestrationDeferred: false,
22
22
  isSubagent: false,
23
+ promptProfile: "simple",
24
+ fableMitigations: false,
25
+ };
26
+ const PROBE_FACTS_ON = {
27
+ policyEnabled: true,
28
+ hooksEnabled: true,
29
+ isolationEnabled: true,
30
+ withinTaskCompactionEnabled: true,
31
+ supervisorEnabled: true,
32
+ orchestrationEnabled: true,
33
+ awarenessEnabled: true,
34
+ goalEnabled: true,
35
+ worktreeIsolated: true,
36
+ teammateEnabled: true,
37
+ orchestrationDeferred: true,
38
+ isSubagent: true,
39
+ promptProfile: "classic",
40
+ fableMitigations: true,
23
41
  };
24
- const PROBE_FACTS_ON = Object.fromEntries(Object.keys(PROBE_FACTS_OFF).map((k) => [k, true]));
25
42
  const PROBE_VECTORS = [
26
43
  PROBE_FACTS_OFF,
27
44
  PROBE_FACTS_ON,
28
- ...Object.keys(PROBE_FACTS_OFF).map((k) => ({ ...PROBE_FACTS_OFF, [k]: true })),
45
+ ...Object.keys(PROBE_FACTS_OFF).map((k) => ({ ...PROBE_FACTS_OFF, [k]: PROBE_FACTS_ON[k] })),
29
46
  ];
30
47
  const PROBE_INPUTS_BASE = {
31
48
  roleBase: "\u0000probe-role\u0000",
@@ -4,7 +4,7 @@ export const EVENT_PROMPT_REGISTRY = new Map([
4
4
  { kind: "changed_files", carrier: "message.user-prefix", trust: "operator", dedupe: "replace-by-key", defaultPolicy: "off", rendererRef: "turn-attachments.ts#renderChangedFiles" },
5
5
  { kind: "plan_mode", carrier: "message.user-prefix", trust: "operator", dedupe: "replace-by-key", defaultPolicy: "off", rendererRef: "turn-attachments.ts#PLAN_MODE_FULL_BODY" },
6
6
  { kind: "date_change", carrier: "message.user-prefix", trust: "operator", dedupe: "replace-by-key", defaultPolicy: "always", rendererRef: "turn-attachments.ts#renderDateChange" },
7
- { kind: "instructions_change", carrier: "message.user-prefix", trust: "operator", dedupe: "replace-by-key", maxBytes: 512, defaultPolicy: "always", rendererRef: "turn-attachments.ts#renderInstructionsChange" },
7
+ { kind: "instructions_change", carrier: "message.user-prefix", trust: "operator", dedupe: "replace-by-key", maxBytes: 512, defaultPolicy: "always", rendererRef: "turn-attachments.ts#collectInstructionsChange" },
8
8
  { kind: "budget_usd", carrier: "message.user-prefix", trust: "operator", dedupe: "replace-by-key", defaultPolicy: "off", rendererRef: "turn-attachments.ts#renderBudgetUsd" },
9
9
  { kind: "background_tasks", carrier: "message.user-prefix", trust: "operator", dedupe: "replace-by-key", defaultPolicy: "off", rendererRef: "turn-attachments.ts#renderBackgroundTasks" },
10
10
  { kind: "tools_delta", carrier: "message.user-prefix", trust: "external", dedupe: "replace-by-key", defaultPolicy: "off", rendererRef: "turn-attachments.ts#renderToolsDelta" },
@@ -12,8 +12,8 @@ export const EVENT_PROMPT_REGISTRY = new Map([
12
12
  { kind: "skills_listing", carrier: "message.user-prefix", trust: "operator", dedupe: "replace-by-key", defaultPolicy: "on", rendererRef: "turn-attachments.ts#renderSkillsListingDelta" },
13
13
  { kind: "mcp_instructions", carrier: "message.user-prefix", trust: "external", dedupe: "replace-by-key", defaultPolicy: "off", rendererRef: "turn-attachments.ts#renderMcpInstructionsDelta" },
14
14
  { kind: "mcp_dropped_tools", carrier: "message.user-prefix", trust: "external", dedupe: "replace-by-key", defaultPolicy: "off", rendererRef: "turn-attachments.ts#renderMcpDroppedTools" },
15
- { kind: "deadline_converge", carrier: "message.user-prefix", trust: "operator", dedupe: "once-per-run", defaultPolicy: "always", rendererRef: "runtask.ts#deadline-t1" },
16
- { kind: "deadline_deliver_now", carrier: "message.user-prefix", trust: "operator", dedupe: "once-per-run", defaultPolicy: "always", rendererRef: "runtask.ts#deadline-t2" },
15
+ { kind: "deadline_converge", carrier: "message.user-prefix", trust: "operator", dedupe: "once-per-run", defaultPolicy: "always", rendererRef: "runtask.ts#nudgeSchedule" },
16
+ { kind: "deadline_deliver_now", carrier: "message.user-prefix", trust: "operator", dedupe: "once-per-run", defaultPolicy: "always", rendererRef: "runtask.ts#nudgeSchedule" },
17
17
  { kind: "deadline_finalize", carrier: "message.user-prefix", trust: "operator", dedupe: "once-per-run", defaultPolicy: "always", rendererRef: "runtask.ts#buildFinalizeText" },
18
18
  { kind: "final_verification", carrier: "message.user-prefix", trust: "operator", dedupe: "once-per-run", defaultPolicy: "always", rendererRef: "runtask.ts#final-verification" },
19
19
  ].map((e) => [e.kind, e]));
@@ -30,7 +30,7 @@ export function describeDefaultPack() {
30
30
  return {
31
31
  packId: SEMA_DEFAULT_PACK.packId,
32
32
  roleBaseText: DEFAULT_SYSTEM_PROMPT,
33
- sections: SEMA_DEFAULT_PACK.sections.map((s) => ({
33
+ sections: [...SEMA_DEFAULT_PACK.sections].sort((a, b) => a.rank - b.rank).map((s) => ({
34
34
  id: s.id,
35
35
  slot: s.slot,
36
36
  rank: s.rank,
@@ -1,4 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
+ const PRESENTATION_KEYWORDS = new Set(["description", "title", "examples", "$id"]);
3
+ const SCHEMA_NAME_MAPS = new Set([
4
+ "properties",
5
+ "patternProperties",
6
+ "definitions",
7
+ "$defs",
8
+ "dependentSchemas",
9
+ ]);
2
10
  function stripPresentation(node) {
3
11
  if (Array.isArray(node))
4
12
  return node.map(stripPresentation);
@@ -6,8 +14,15 @@ function stripPresentation(node) {
6
14
  return node;
7
15
  const out = {};
8
16
  for (const [k, v] of Object.entries(node)) {
9
- if (k === "description" || k === "title" || k === "examples" || k === "$id")
17
+ if (PRESENTATION_KEYWORDS.has(k))
10
18
  continue;
19
+ if (SCHEMA_NAME_MAPS.has(k) && v !== null && typeof v === "object" && !Array.isArray(v)) {
20
+ const named = {};
21
+ for (const [name, sub] of Object.entries(v))
22
+ named[name] = stripPresentation(sub);
23
+ out[k] = named;
24
+ continue;
25
+ }
11
26
  out[k] = k === "required" && Array.isArray(v) ? [...v].sort() : stripPresentation(v);
12
27
  }
13
28
  return out;
@@ -61,7 +61,10 @@ export function buildTurnPromptSnapshot(raw, api, promptHasher) {
61
61
  loweringVersion: `${lowering.version}/${lowering.form}/${api}`,
62
62
  };
63
63
  const bump = () => {
64
- snapshot.cacheIdentity = identityOf(elements);
64
+ const next = identityOf(elements);
65
+ if (next === snapshot.cacheIdentity)
66
+ return;
67
+ snapshot.cacheIdentity = next;
65
68
  snapshot.onChange?.(snapshot);
66
69
  };
67
70
  const snapshot = {
@@ -10,7 +10,10 @@ export declare class AppendLog {
10
10
  private fd;
11
11
  private disposed;
12
12
  private readonly path;
13
+ private boundary;
13
14
  constructor(path: string);
15
+ private sizeOrUnknown;
16
+ private repairTornTail;
14
17
  append(record: unknown, fsync: boolean): void;
15
18
  close(): void;
16
19
  closeForSwap(): void;
@@ -1,4 +1,4 @@
1
- import { closeSync, constants as FS, existsSync, linkSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync, writeSync, fsyncSync, } from "node:fs";
1
+ import { closeSync, constants as FS, existsSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, truncateSync, unlinkSync, writeFileSync, writeSync, fsyncSync, } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, join, resolve as resolvePath } from "node:path";
4
4
  import { createHash, randomBytes } from "node:crypto";
@@ -148,30 +148,75 @@ export class AppendLog {
148
148
  fd;
149
149
  disposed = false;
150
150
  path;
151
+ boundary = -1;
151
152
  constructor(path) {
152
153
  this.path = path;
153
154
  ensureDir(dirnameOf(path));
154
155
  truncateTornTail(path);
155
- this.fd = openSync(path, "a", 0o600);
156
+ this.fd = openSync(path, "a+", 0o600);
157
+ this.boundary = this.sizeOrUnknown();
158
+ }
159
+ sizeOrUnknown() {
160
+ try {
161
+ return fstatSync(this.fd).size;
162
+ }
163
+ catch {
164
+ return -1;
165
+ }
166
+ }
167
+ repairTornTail() {
168
+ const size = this.sizeOrUnknown();
169
+ if (size < 0) {
170
+ this.boundary = -1;
171
+ return;
172
+ }
173
+ if (size === this.boundary)
174
+ return;
175
+ if (size === 0) {
176
+ this.boundary = 0;
177
+ return;
178
+ }
179
+ const tail = Buffer.allocUnsafe(1);
180
+ try {
181
+ if (readSync(this.fd, tail, 0, 1, size - 1) === 1 && tail[0] === 0x0a) {
182
+ this.boundary = size;
183
+ return;
184
+ }
185
+ }
186
+ catch {
187
+ return;
188
+ }
189
+ truncateTornTail(this.path);
190
+ this.boundary = this.sizeOrUnknown();
156
191
  }
157
192
  append(record, fsync) {
158
193
  if (this.fd < 0 && !this.disposed) {
159
194
  truncateTornTail(this.path);
160
- this.fd = openSync(this.path, "a", 0o600);
195
+ this.fd = openSync(this.path, "a+", 0o600);
196
+ this.boundary = this.sizeOrUnknown();
161
197
  }
162
198
  if (this.fd < 0) {
163
199
  const e = new Error("append: this log was closed (its session/box was deleted or replaced) — refusing to write through a released descriptor");
164
200
  e.code = "log_closed";
165
201
  throw e;
166
202
  }
203
+ this.repairTornTail();
167
204
  const buf = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
168
205
  let written = 0;
169
- while (written < buf.length) {
170
- const n = writeSync(this.fd, buf, written, buf.length - written);
171
- if (n <= 0)
172
- throw new Error(`append: write made no progress (${written}/${buf.length} bytes) — refusing to leave a torn record`);
173
- written += n;
206
+ try {
207
+ while (written < buf.length) {
208
+ const n = writeSync(this.fd, buf, written, buf.length - written);
209
+ if (n <= 0)
210
+ throw new Error(`append: write made no progress (${written}/${buf.length} bytes) — refusing to leave a torn record`);
211
+ written += n;
212
+ }
213
+ }
214
+ catch (err) {
215
+ this.boundary = -1;
216
+ throw err;
174
217
  }
218
+ if (this.boundary >= 0)
219
+ this.boundary += buf.length;
175
220
  if (fsync)
176
221
  fsyncSync(this.fd);
177
222
  }
@@ -38,6 +38,7 @@ export declare class FileStorageBackend {
38
38
  private readonly fileCheckpoints;
39
39
  private readonly fileMemory;
40
40
  private readonly ttl;
41
+ private readonly fileSessions;
41
42
  private readonly fileWorkflowJournal;
42
43
  constructor(opts?: FileStorageBackendOptions);
43
44
  dispose(): Promise<void>;
@@ -32,6 +32,7 @@ export class FileStorageBackend {
32
32
  fileCheckpoints;
33
33
  fileMemory;
34
34
  ttl;
35
+ fileSessions;
35
36
  fileWorkflowJournal;
36
37
  constructor(opts = {}) {
37
38
  this.root = resolveDataRoot(opts.root);
@@ -39,6 +40,7 @@ export class FileStorageBackend {
39
40
  this.lock.acquire();
40
41
  try {
41
42
  const repo = new FileSessionRepo(this.root);
43
+ this.fileSessions = repo;
42
44
  this.ttl = new TtlSessionStore({ repo, evict: opts.evict ?? "forget" });
43
45
  this.sessionStore = this.ttl;
44
46
  this.fileCheckpoints = new FileCheckpointStore(this.root, opts.checkpoint);
@@ -63,6 +65,11 @@ export class FileStorageBackend {
63
65
  }
64
66
  catch {
65
67
  }
68
+ try {
69
+ await this.fileSessions.dispose();
70
+ }
71
+ catch {
72
+ }
66
73
  this.fileCheckpoints.close();
67
74
  try {
68
75
  await this.fileMemory.flushEmbeds();
@@ -2,10 +2,12 @@ import type { Session, SessionMetadata, SessionRepo, SessionTreeEntry } from "..
2
2
  export declare class FileSessionRepo implements SessionRepo {
3
3
  private readonly dir;
4
4
  private readonly tmpDir;
5
+ private readonly joined;
5
6
  constructor(root: string);
6
7
  private pathFor;
7
8
  private read;
8
9
  private storage;
10
+ dispose(): Promise<void>;
9
11
  create(options?: {
10
12
  id?: string;
11
13
  }): Promise<Session>;
@@ -20,11 +20,19 @@ function evictSharedSessionStorage(canonical) {
20
20
  class FileSessionStorage extends BaseSessionStorage {
21
21
  log;
22
22
  canonicalPath;
23
+ holders = new Set();
23
24
  constructor(log, meta, entries, canonicalPath) {
24
25
  super(meta, entries);
25
26
  this.log = log;
26
27
  this.canonicalPath = canonicalPath;
27
28
  }
29
+ addHolder(holder) {
30
+ this.holders.add(holder);
31
+ }
32
+ removeHolder(holder) {
33
+ this.holders.delete(holder);
34
+ return this.holders.size === 0;
35
+ }
28
36
  closeLog() {
29
37
  this.log.close();
30
38
  }
@@ -46,6 +54,7 @@ class FileSessionStorage extends BaseSessionStorage {
46
54
  export class FileSessionRepo {
47
55
  dir;
48
56
  tmpDir;
57
+ joined = new Map();
49
58
  constructor(root) {
50
59
  this.dir = join(root, "sessions");
51
60
  this.tmpDir = join(root, "tmp");
@@ -77,19 +86,37 @@ export class FileSessionRepo {
77
86
  storage(id, createdAt, entries, forkedFrom) {
78
87
  const canonical = canonicalStoreKey(this.pathFor(id));
79
88
  const live = sharedSessionStorages.get(canonical)?.deref();
80
- if (live !== undefined)
89
+ if (live !== undefined) {
90
+ live.addHolder(this);
91
+ this.joined.set(canonical, new WeakRef(live));
81
92
  return live;
93
+ }
82
94
  const log = new AppendLog(this.pathFor(id));
83
95
  const created = new FileSessionStorage(log, { id, createdAt, ...(forkedFrom !== undefined ? { forkedFrom } : {}) }, entries, canonical);
84
96
  sharedSessionStorages.set(canonical, new WeakRef(created));
97
+ created.addHolder(this);
98
+ this.joined.set(canonical, new WeakRef(created));
85
99
  sessionStorageFinalizer.register(created, { canonical, log }, created);
86
100
  return created;
87
101
  }
102
+ async dispose() {
103
+ for (const [canonical, ref] of this.joined) {
104
+ const mine = ref.deref();
105
+ if (mine === undefined)
106
+ continue;
107
+ if (sharedSessionStorages.get(canonical)?.deref() !== mine)
108
+ continue;
109
+ if (mine.removeHolder(this))
110
+ evictSharedSessionStorage(canonical);
111
+ }
112
+ this.joined.clear();
113
+ }
88
114
  async create(options = {}) {
89
115
  const id = options.id ?? uuidv7();
90
116
  const createdAt = new Date().toISOString();
91
117
  const path = this.pathFor(id);
92
118
  if (!existsSync(path)) {
119
+ evictSharedSessionStorage(canonicalStoreKey(path));
93
120
  atomicWriteFile(this.tmpDir, path, `${JSON.stringify({ kind: "meta", id, createdAt })}\n`);
94
121
  return new StoredSession(this.storage(id, createdAt, []));
95
122
  }
@@ -14,6 +14,7 @@ export interface CompoundReadonlyVerdict {
14
14
  reason?: string;
15
15
  outOfRootRead?: true;
16
16
  outOfRootPaths?: readonly string[];
17
+ checkedPaths?: readonly string[];
17
18
  }
18
19
  export declare function formatOutOfRootReadApprovalOption(directory: string): string;
19
20
  export declare function classifyCompoundReadonlyDetailed(command: string, allow: ReadonlySet<string>, boundary?: BashReadonlyRootBoundary): CompoundReadonlyVerdict;
@@ -107,10 +107,42 @@ function hasUnquotedExpansionMetachar(rawToken) {
107
107
  }
108
108
  return false;
109
109
  }
110
+ function longOptionNameOf(tok) {
111
+ if (!tok.startsWith("--") || tok.length === 2)
112
+ return undefined;
113
+ const body = tok.slice(2);
114
+ const eq = body.indexOf("=");
115
+ const name = eq >= 0 ? body.slice(0, eq) : body;
116
+ return name.length > 0 ? name : undefined;
117
+ }
118
+ function isLongOptionAbbrevOf(name, full) {
119
+ return full.startsWith(name);
120
+ }
110
121
  function isGrepPatternFlagToken(tok) {
111
- if (tok.startsWith("--"))
112
- return tok.startsWith("--regexp") || tok.startsWith("--file");
113
- return /^-[A-Za-z]*[ef]/.test(tok);
122
+ if (tok.startsWith("--")) {
123
+ const long = longOptionNameOf(tok);
124
+ return long !== undefined && (isLongOptionAbbrevOf(long, "regexp") || isLongOptionAbbrevOf(long, "file"));
125
+ }
126
+ return /^-[A-Za-z0-9]*[ef]/.test(tok);
127
+ }
128
+ function isGrepPatternPayloadLongOption(tok) {
129
+ if (!tok.includes("="))
130
+ return false;
131
+ const name = longOptionNameOf(tok);
132
+ return name !== undefined && isLongOptionAbbrevOf(name, "regexp");
133
+ }
134
+ function isCutDelimiterPayloadLongOption(tok) {
135
+ if (!tok.includes("="))
136
+ return false;
137
+ const name = longOptionNameOf(tok);
138
+ return name !== undefined && (isLongOptionAbbrevOf(name, "delimiter") || isLongOptionAbbrevOf(name, "output-delimiter"));
139
+ }
140
+ function isGrepFileStdinLongOption(tok) {
141
+ const eq = tok.indexOf("=");
142
+ if (eq < 0 || tok.slice(eq + 1) !== "-")
143
+ return false;
144
+ const name = longOptionNameOf(tok);
145
+ return name !== undefined && isLongOptionAbbrevOf(name, "file");
114
146
  }
115
147
  function grepClusterValueOwner(tok) {
116
148
  if (!/^-[A-Za-z]/.test(tok) || tok.startsWith("--"))
@@ -147,6 +179,7 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
147
179
  const args = tokens.folded.slice(1);
148
180
  const findings = [];
149
181
  const candidates = [];
182
+ const bareWordOperands = [];
150
183
  for (const rawArg of tokens.raw.slice(1)) {
151
184
  if (!hasUnquotedExpansionMetachar(rawArg))
152
185
  continue;
@@ -176,9 +209,9 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
176
209
  k++;
177
210
  continue;
178
211
  }
179
- if (name === "cut" && (/^-d./.test(t) || /^--(output-)?delimiter=/.test(t)))
212
+ if (name === "cut" && (/^-d./.test(t) || isCutDelimiterPayloadLongOption(t)))
180
213
  continue;
181
- if (name === "grep" && (grepClusterValueOwner(t) === "e" || /^--regexp=/.test(t)))
214
+ if (name === "grep" && (grepClusterValueOwner(t) === "e" || isGrepPatternPayloadLongOption(t)))
182
215
  continue;
183
216
  if (t.startsWith("-") && t !== "-") {
184
217
  for (const payload of attachedOptionPayloads(t)) {
@@ -195,6 +228,8 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
195
228
  continue;
196
229
  if (isPathShapedToken(t))
197
230
  candidates.push(t);
231
+ else
232
+ bareWordOperands.push(t);
198
233
  }
199
234
  }
200
235
  for (const candidate of candidates) {
@@ -206,9 +241,16 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
206
241
  });
207
242
  continue;
208
243
  }
209
- if (!withinAnyRoot(boundary.roots, resolved))
244
+ if (withinAnyRoot(boundary.roots, resolved))
245
+ findings.push({ kind: "inside", path: resolved });
246
+ else
210
247
  findings.push({ kind: "outside", command: name, path: resolved });
211
248
  }
249
+ for (const operand of bareWordOperands) {
250
+ const resolved = resolveOperandLexically(boundary.cwd ?? boundary.roots[0], operand, boundary.homeDir);
251
+ if (resolved !== undefined && withinAnyRoot(boundary.roots, resolved))
252
+ findings.push({ kind: "inside", path: resolved });
253
+ }
212
254
  return findings;
213
255
  }
214
256
  export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
@@ -312,7 +354,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
312
354
  k++;
313
355
  continue;
314
356
  }
315
- if (name === "grep" && (t === "-f-" || t === "--file=-")) {
357
+ if (name === "grep" && (t === "-f-" || isGrepFileStdinLongOption(t))) {
316
358
  hasStdinDash = true;
317
359
  break;
318
360
  }
@@ -351,22 +393,29 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
351
393
  }
352
394
  function evaluateReadBoundary(foldedSegments, boundary) {
353
395
  const outside = [];
396
+ const inside = [];
354
397
  for (const toks of foldedSegments) {
355
398
  for (const finding of collectSegmentBoundaryFindings(toks, boundary)) {
356
399
  if (finding.kind === "unresolvable")
357
400
  return { reason: finding.reason };
401
+ if (finding.kind === "inside") {
402
+ if (!inside.includes(finding.path))
403
+ inside.push(finding.path);
404
+ continue;
405
+ }
358
406
  if (!outside.some((o) => o.path === finding.path))
359
407
  outside.push(finding);
360
408
  }
361
409
  }
362
410
  if (outside.length === 0)
363
- return {};
411
+ return inside.length > 0 ? { checkedPaths: inside } : {};
364
412
  const paths = outside.map((o) => `"${o.path}"`).join(", ");
365
413
  const allowed = boundary.roots.length > 0 ? boundary.roots.join(", ") : "(none)";
366
414
  return {
367
415
  reason: `"${outside[0].command}" reads ${paths}, outside the allowed directories for this session: ${allowed} — not auto-allowed`,
368
416
  outOfRootRead: true,
369
417
  outOfRootPaths: outside.map((o) => o.path),
418
+ ...(inside.length > 0 ? { checkedPaths: inside } : {}),
370
419
  };
371
420
  }
372
421
  export function classifySimpleCommandReadBoundary(command, boundary) {
@@ -14,7 +14,7 @@ export interface ExecClampOption {
14
14
  }) => () => void;
15
15
  }
16
16
  export declare function bashExitOneInterpretation(command: string): string | undefined;
17
- export declare function canAutoBackground(command: string): boolean;
17
+ export declare function canAutoBackground(command: string, depth?: number): boolean;
18
18
  export declare function createBashTool(env: ExecutionEnv, rootCanonical: string, coAuthor?: string | false, cwdRef?: CwdRef, taskOpts?: {
19
19
  taskRegistry?: TaskRegistry;
20
20
  taskOwner?: string;
@@ -6,6 +6,7 @@ import { hasBackgroundShell } from "../../core/background-shell.js";
6
6
  import { delimitUntrusted } from "../../core/untrusted-text.js";
7
7
  import { MCP_IMAGE_MAX_BASE64 } from "../../core/mcp.js";
8
8
  import { imageMagicMatches, withinAnyRoot } from "./safety.js";
9
+ import { isRemoteExecutionEnv } from "../../core/remote-env.js";
9
10
  import { ghRateLimitHint } from "./gh-rate-limit.js";
10
11
  import { resolveBashTimeoutCaps, bashTimeoutCapsSec, bashMaxOutputChars, clipShellOutput, writeShellOverflowFile, createShellOverflowSpoolFence, shellRecoveryHint, CWD_SENTINEL, } from "./fs-shared.js";
11
12
  import { BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyCompoundReadonly, classifySimpleCommandReadBoundary, } from "./bash-readonly-classifier.js";
@@ -137,7 +138,26 @@ function commandBasename(token) {
137
138
  const idx = token.lastIndexOf("/");
138
139
  return idx === -1 ? token : token.slice(idx + 1);
139
140
  }
140
- export function canAutoBackground(command) {
141
+ const COMMAND_STRING_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash"]);
142
+ function commandStringPayload(tokens) {
143
+ const word = commandBasename(tokens[0] ?? "");
144
+ if (word === "eval")
145
+ return tokens.slice(1).join(" ");
146
+ if (!COMMAND_STRING_SHELLS.has(word))
147
+ return undefined;
148
+ const flagIdx = tokens.findIndex((t, i) => i > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(t));
149
+ return flagIdx === -1 ? undefined : tokens.slice(flagIdx + 1).join(" ");
150
+ }
151
+ function unquotePayload(payload) {
152
+ const s = payload.trim();
153
+ const ansiC = /^\$'([\s\S]*)'$/.exec(s);
154
+ if (ansiC)
155
+ return (ansiC[1] ?? "").replace(/\\n/g, "\n").replace(/\\t/g, "\t");
156
+ const quoted = /^(['"])([\s\S]*)\1$/.exec(s);
157
+ return quoted ? (quoted[2] ?? "") : s;
158
+ }
159
+ const AUTO_BACKGROUND_MAX_WRAPPER_DEPTH = 3;
160
+ export function canAutoBackground(command, depth = 0) {
141
161
  const segments = command
142
162
  .split(/&&|\|\||;|\||\r?\n/)
143
163
  .map((s) => s.trim())
@@ -149,6 +169,9 @@ export function canAutoBackground(command) {
149
169
  const word = commandBasename(tokens[0] ?? "");
150
170
  if (word === "git")
151
171
  return false;
172
+ const payload = depth < AUTO_BACKGROUND_MAX_WRAPPER_DEPTH ? commandStringPayload(tokens) : undefined;
173
+ if (payload !== undefined && payload.trim() !== "" && !canAutoBackground(unquotePayload(payload), depth + 1))
174
+ return false;
152
175
  if (word === "xargs" && tokens.slice(1).some((t) => commandBasename(t) === "git"))
153
176
  return false;
154
177
  }
@@ -641,6 +664,60 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
641
664
  },
642
665
  });
643
666
  }
667
+ function createCanonicalReadBoundary(env, readRoots) {
668
+ const lexicalOnly = isRemoteExecutionEnv(env);
669
+ let rootsOnce;
670
+ const canonicalRoots = async (signal) => {
671
+ rootsOnce ??= (async () => {
672
+ const out = [];
673
+ for (const r of readRoots) {
674
+ const c = await env.canonicalPath(r, signal);
675
+ out.push(c.ok ? c.value : r);
676
+ }
677
+ return out;
678
+ })();
679
+ return await rootsOnce;
680
+ };
681
+ const resolve = async (p, signal) => {
682
+ const parts = p.split(/[\\/]/);
683
+ const tail = [];
684
+ while (parts.length > 0) {
685
+ const canon = await env.canonicalPath(parts.join("/") || "/", signal);
686
+ if (canon.ok) {
687
+ return [canon.value.replace(/[\\/]+$/, ""), ...[...tail].reverse()].join("/");
688
+ }
689
+ tail.push(parts.pop());
690
+ }
691
+ return undefined;
692
+ };
693
+ return {
694
+ escapesAfterResolution: async (paths, signal) => {
695
+ if (lexicalOnly || paths.length === 0)
696
+ return [];
697
+ const roots = await canonicalRoots(signal);
698
+ const escaping = [];
699
+ for (const p of paths) {
700
+ const real = await resolve(p, signal);
701
+ if (real === undefined)
702
+ continue;
703
+ if (!withinAnyRoot(roots, real) && !escaping.includes(real))
704
+ escaping.push(real);
705
+ }
706
+ return escaping;
707
+ },
708
+ allResolveInside: async (paths, signal) => {
709
+ if (lexicalOnly || paths.length === 0)
710
+ return false;
711
+ const roots = await canonicalRoots(signal);
712
+ for (const p of paths) {
713
+ const real = await resolve(p, signal);
714
+ if (real === undefined || !withinAnyRoot(roots, real))
715
+ return false;
716
+ }
717
+ return true;
718
+ },
719
+ };
720
+ }
644
721
  export function createBashReadonlyTool(env, rootCanonical, allow, execClamp, opts) {
645
722
  const timeoutCaps = resolveBashTimeoutCaps(opts);
646
723
  const timeoutCapsSecView = bashTimeoutCapsSec(timeoutCaps);
@@ -659,14 +736,16 @@ export function createBashReadonlyTool(env, rootCanonical, allow, execClamp, opt
659
736
  }
660
737
  return true;
661
738
  };
739
+ const canonicalBoundary = createCanonicalReadBoundary(env, readRoots);
662
740
  return defineTool({
663
741
  name: "Bash",
664
742
  contract: { contractId: "core.bash_readonly@1", implementationRevision: "1" },
665
743
  description: `Run a SINGLE read-only inspection command (e.g. ${sample}, …) and return its output. Shell ` +
666
744
  "operators (pipes, redirects, ;, &&, command substitution, subshells) are rejected and only " +
667
745
  "allowlisted commands run. Reads are confined to the workspace root(s): a path outside them is " +
668
- "refused, as is one that cannot be resolved statically (e.g. a `~` path). Still subject to the " +
669
- "deployment's approval policy.",
746
+ "refused, as is one that cannot be resolved statically (e.g. a `~` path). " +
747
+ (isRemoteExecutionEnv(env) ? "" : "A path that leads outside via a symlink counts as outside. ") +
748
+ "Still subject to the deployment's approval policy.",
670
749
  parameters: Type.Object({
671
750
  command: Type.String({ description: "A single allowlisted read-only command (no shell operators)." }),
672
751
  timeout: Type.Optional(Type.Number({ description: `Timeout in milliseconds (default ${timeoutCaps.defaultMs}, max ${timeoutCaps.maxMs}; requests above the max are capped to it).` })),
@@ -678,8 +757,17 @@ export function createBashReadonlyTool(env, rootCanonical, allow, execClamp, opt
678
757
  if (reason)
679
758
  return errorResult(`Error (Bash): ${reason}`);
680
759
  const boundary = classifySimpleCommandReadBoundary(command, { roots: readRoots, cwd: rootCanonical });
681
- if (boundary.reason !== undefined && !(await readsOnlyEngineOverflowSpool(boundary))) {
682
- return errorResult(`Error (Bash): ${boundary.reason}. bash_readonly is confined to the workspace roots; it has no approval path, so the call is refused rather than escalated.`, { code: "readonly_out_of_root", paths: boundary.outOfRootPaths ?? [] });
760
+ if (boundary.reason !== undefined) {
761
+ const rescued = boundary.outOfRootRead === true && (await canonicalBoundary.allResolveInside(boundary.outOfRootPaths ?? [], ctx.signal));
762
+ if (!rescued && !(await readsOnlyEngineOverflowSpool(boundary))) {
763
+ return errorResult(`Error (Bash): ${boundary.reason}. bash_readonly is confined to the workspace roots; it has no approval path, so the call is refused rather than escalated.`, { code: "readonly_out_of_root", paths: boundary.outOfRootPaths ?? [] });
764
+ }
765
+ }
766
+ const escaping = await canonicalBoundary.escapesAfterResolution(boundary.checkedPaths ?? [], ctx.signal);
767
+ if (escaping.length > 0) {
768
+ const quoted = escaping.map((p) => `"${p}"`).join(", ");
769
+ return errorResult(`Error (Bash): a path this command reads resolves through a symlink to ${quoted}, outside the allowed directories for this session: ${readRoots.join(", ")} — not auto-allowed. ` +
770
+ "bash_readonly is confined to the workspace roots; it has no approval path, so the call is refused rather than escalated.", { code: "readonly_out_of_root", paths: escaping });
683
771
  }
684
772
  return await runShell(env, rootCanonical, "Bash", command, msTimeoutToRequestedSec(timeout), timeoutCapsSecView, ctx.signal, undefined, undefined, execClamp, ctx.toolCallId, true);
685
773
  },
@@ -49,7 +49,7 @@ Read and analyze freely — understanding the state of things has no blast radiu
49
49
  `;
50
50
  const DYNAMIC_APPENDIX = `
51
51
 
52
- If a Monitor is armed (check TaskList), keep \`delaySeconds\` at 1200–1800s — the Monitor is the wake signal and this is only the fallback heartbeat. If you were woken by a \`<task-notification>\`, handle the event before deciding whether to re-arm. To stop the loop, call ${SCHEDULE_WAKEUP_TOOL_NAME} with \`stop: true\` and TaskStop the monitor (use TaskList to find its task ID if no longer in context).`;
52
+ If a Monitor is armed (its task_id was reported in the receipt when you started it), keep \`delaySeconds\` at 1200–1800s — the Monitor is the wake signal and this is only the fallback heartbeat. If you were woken by a \`<task-notification>\`, handle the event before deciding whether to re-arm. To stop the loop, call ${SCHEDULE_WAKEUP_TOOL_NAME} with \`stop: true\` and TaskStop the monitor (its task id is in that start receipt, and in the \`<task-id>\` of every notification the watch sends).`;
53
53
  function pushNotificationAppendix(persistentVariant) {
54
54
  const r = persistentVariant
55
55
  ? "newly blocked on a decision you won't make alone, you're ending the loop"