@geraldmaron/construct 1.4.0 → 1.4.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.
Files changed (65) hide show
  1. package/bin/construct +2 -1
  2. package/bin/construct-postinstall.mjs +27 -2
  3. package/config/tag-vocabulary.json +264 -0
  4. package/lib/config/schema.mjs +1 -1
  5. package/lib/doctor/diagnosis.mjs +20 -0
  6. package/lib/doctor/index.mjs +5 -0
  7. package/lib/embed/worker.mjs +5 -0
  8. package/lib/env-config.mjs +10 -2
  9. package/lib/export-validate.mjs +34 -2
  10. package/lib/hooks/guard-bash.mjs +3 -1
  11. package/lib/host/readiness.mjs +109 -0
  12. package/lib/host-disposition.mjs +10 -1
  13. package/lib/install/stage-project.mjs +41 -4
  14. package/lib/intake/prepare.mjs +2 -0
  15. package/lib/mcp/destructive-approval.mjs +57 -0
  16. package/lib/mcp/server.mjs +209 -35
  17. package/lib/mcp/tool-rate-limit.mjs +47 -0
  18. package/lib/mcp/tool-safety.mjs +94 -0
  19. package/lib/mcp/tool-surface-parity.mjs +60 -0
  20. package/lib/mcp/tools/orchestration-run.mjs +45 -7
  21. package/lib/mcp/tools/project.mjs +25 -8
  22. package/lib/mcp/tools/storage.mjs +9 -1
  23. package/lib/mcp/tools/web-search-governance.mjs +96 -0
  24. package/lib/mcp/tools/web-search.mjs +6 -44
  25. package/lib/mcp-platform-config.mjs +27 -18
  26. package/lib/oracle/daemon-entry.mjs +6 -0
  27. package/lib/orchestration/runtime.mjs +81 -42
  28. package/lib/orchestration/web-capability.mjs +59 -0
  29. package/lib/orchestration/worker.mjs +263 -19
  30. package/lib/orchestration-policy.mjs +36 -0
  31. package/lib/output-quality.mjs +61 -2
  32. package/lib/path-policy.mjs +56 -0
  33. package/lib/providers/secret-audit-wiring.mjs +35 -18
  34. package/lib/providers/secret-resolver.mjs +28 -13
  35. package/lib/registry/catalog.mjs +6 -0
  36. package/lib/registry/loader.mjs +7 -1
  37. package/lib/registry/validate.mjs +3 -3
  38. package/lib/sandbox.mjs +1 -1
  39. package/lib/service-manager.mjs +59 -9
  40. package/lib/storage/admin.mjs +7 -2
  41. package/package.json +6 -1
  42. package/registry/agent-manifest.json +117 -0
  43. package/registry/capabilities.json +1880 -0
  44. package/schemas/brand-voice.schema.json +24 -0
  45. package/schemas/capability-registry.schema.json +72 -0
  46. package/schemas/certification-run.schema.json +130 -0
  47. package/schemas/demo-recording.schema.json +46 -0
  48. package/schemas/eval-dataset.schema.json +79 -0
  49. package/schemas/execution-capability-profile.schema.json +46 -0
  50. package/schemas/execution-policy.schema.json +114 -0
  51. package/schemas/improvement-proposal.schema.json +65 -0
  52. package/schemas/mcp-tool-output.schema.json +61 -0
  53. package/schemas/platform-capabilities.schema.json +83 -0
  54. package/schemas/project-config.schema.json +227 -0
  55. package/schemas/project-demo.schema.json +60 -0
  56. package/schemas/provider-behavior-matrix.schema.json +91 -0
  57. package/schemas/scope.schema.json +197 -0
  58. package/schemas/specialist-trace.schema.json +107 -0
  59. package/schemas/team.schema.json +99 -0
  60. package/schemas/unified-registry.schema.json +548 -0
  61. package/scripts/sync-specialists.mjs +135 -12
  62. package/specialists/org/specialists/cx-researcher.json +1 -0
  63. package/specialists/prompts/cx-researcher.md +9 -8
  64. package/vendor/pandoc-ext/README.md +3 -0
  65. package/vendor/pandoc-ext/diagram.lua +687 -0
@@ -0,0 +1,47 @@
1
+ /**
2
+ * lib/mcp/tool-rate-limit.mjs — per-tool call-rate budget for the MCP CallTool path.
3
+ *
4
+ * Solo deployment mode never instantiates lib/mcp/broker.mjs's Broker (that class
5
+ * is role/policy-based and only wired in for team/enterprise), so the live MCP
6
+ * dispatch path has no bound on how often a single tool can be called — a stuck
7
+ * loop or an injected instruction can hammer a destructive tool until it exhausts
8
+ * its approval tokens, or burn cost/tokens spamming a read tool. checkToolRateLimit
9
+ * applies a sliding-window budget tiered by safety class, independent of role.
10
+ */
11
+
12
+ const DEFAULT_WINDOW_MS = 60_000;
13
+ const DEFAULT_BUDGET_BY_CLASS = Object.freeze({
14
+ destructive: 5,
15
+ write: 60,
16
+ read: 300,
17
+ });
18
+
19
+ export class ToolRateLimited extends Error {
20
+ constructor(tool, budget, windowMs) {
21
+ super(`rate-limited: ${tool} exceeded ${budget} calls per ${Math.round(windowMs / 1000)}s`);
22
+ this.name = 'ToolRateLimited';
23
+ this.tool = tool;
24
+ }
25
+ }
26
+
27
+ export class ToolRateLimiter {
28
+ constructor({ budgetByClass = DEFAULT_BUDGET_BY_CLASS, windowMs = DEFAULT_WINDOW_MS, now = () => Date.now() } = {}) {
29
+ this.budgetByClass = budgetByClass;
30
+ this.windowMs = windowMs;
31
+ this.now = now;
32
+ this.calls = new Map();
33
+ }
34
+
35
+ // Disabled when windowMs is falsy (0/NaN), mirroring the existing
36
+ // CONSTRUCT_MCP_TOOL_TIMEOUT_MS override convention in lib/mcp/server.mjs.
37
+
38
+ check(tool, safetyClass) {
39
+ if (!this.windowMs) return;
40
+ const budget = this.budgetByClass[safetyClass] ?? this.budgetByClass.read;
41
+ const ts = this.now();
42
+ const fresh = (this.calls.get(tool) || []).filter((t) => ts - t < this.windowMs);
43
+ if (fresh.length >= budget) throw new ToolRateLimited(tool, budget, this.windowMs);
44
+ fresh.push(ts);
45
+ this.calls.set(tool, fresh);
46
+ }
47
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * lib/mcp/tool-safety.mjs — per-tool safety classification for the MCP catalog.
3
+ *
4
+ * One entry per tool name in lib/mcp/server.mjs's ALL_TOOL_DEFS (plus the `call`
5
+ * gateway). `class` is the host-facing risk tier; `filesystem`/`network`/`process`
6
+ * name which resource classes the tool can touch, derived from each tool's own
7
+ * description and input schema. withSafetyEnvelope (server.mjs) throws if a tool
8
+ * is missing here, so a newly added tool cannot ship without classification.
9
+ */
10
+
11
+ const READ = 'read';
12
+ const WRITE = 'write';
13
+ const DESTRUCTIVE = 'destructive';
14
+
15
+ export const DEFAULT_OUTPUT_SCHEMA = Object.freeze({ type: 'object' });
16
+
17
+ export const TOOL_SAFETY = Object.freeze({
18
+ agent_health: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
19
+ summarize_diff: { class: READ, filesystem: 'read', network: 'none', process: 'spawn' },
20
+ scan_file: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
21
+ extract_document_text: { class: READ, filesystem: 'read', network: 'none', process: 'spawn' },
22
+ ingest_document: { class: WRITE, filesystem: 'write', network: 'none', process: 'spawn' },
23
+ infer_document_schema: { class: WRITE, filesystem: 'write', network: 'fetch', process: 'spawn' },
24
+ list_schema_artifacts: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
25
+ storage_status: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
26
+ storage_sync: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
27
+ storage_reset: { class: DESTRUCTIVE, filesystem: 'write', network: 'none', process: 'none' },
28
+ delete_ingested_artifacts: { class: DESTRUCTIVE, filesystem: 'write', network: 'none', process: 'none' },
29
+ project_context: { class: READ, filesystem: 'read', network: 'none', process: 'spawn' },
30
+ get_skill: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
31
+ orchestration_policy: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
32
+ list_skills: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
33
+ worker_run: { class: WRITE, filesystem: 'write', network: 'fetch', process: 'spawn' },
34
+ broker_check: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
35
+ agent_contract: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
36
+ find_tool: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
37
+ get_template: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
38
+ list_templates: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
39
+ search_skills: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
40
+ list_teams: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
41
+ suggest_skills: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
42
+ cx_trace: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
43
+ cx_score: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
44
+ cx_trace_update: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
45
+ session_list: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
46
+ session_load: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
47
+ session_search: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
48
+ session_save: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
49
+ memory_search: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
50
+ memory_add_observations: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
51
+ memory_create_entities: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
52
+ memory_recent: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
53
+ rovo_search: { class: READ, filesystem: 'none', network: 'fetch', process: 'none' },
54
+ efficiency_snapshot: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
55
+ session_usage: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
56
+ provider_fetch: { class: WRITE, filesystem: 'write', network: 'fetch', process: 'none' },
57
+ scope_show: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
58
+ scope_list: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
59
+ scope_drafts: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
60
+ scope_health: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
61
+ outcomes_summary: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
62
+ outcomes_record: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
63
+ knowledge_add: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
64
+ scope_create: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
65
+ scope_archive: { class: DESTRUCTIVE, filesystem: 'write', network: 'none', process: 'none' },
66
+ sandbox_list: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
67
+ knowledge_graph_ask: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
68
+ learning_status: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
69
+ document_export: { class: WRITE, filesystem: 'write', network: 'none', process: 'spawn' },
70
+ publish_detect: { class: READ, filesystem: 'read', network: 'none', process: 'spawn' },
71
+ publish_run: { class: WRITE, filesystem: 'write', network: 'none', process: 'spawn' },
72
+ knowledge_search: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
73
+ workflow_init: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
74
+ workflow_add_task: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
75
+ workflow_update_task: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
76
+ workflow_needs_main_input: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
77
+ workflow_validate: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
78
+ workflow_status: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
79
+ workflow_contract_validate: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
80
+ workflow_import_plan: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
81
+ cx_trace_telemetry: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
82
+ artifact_workflow: { class: WRITE, filesystem: 'write', network: 'none', process: 'spawn' },
83
+ author_artifact: { class: WRITE, filesystem: 'write', network: 'none', process: 'none' },
84
+ model_resolve: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
85
+ triage_recommend: { class: READ, filesystem: 'read', network: 'none', process: 'spawn' },
86
+ workflow_invoke: { class: WRITE, filesystem: 'write', network: 'none', process: 'spawn' },
87
+ capability_describe: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
88
+ construct_execution_resolve: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
89
+ orchestration_run: { class: WRITE, filesystem: 'write', network: 'fetch', process: 'none' },
90
+ web_search: { class: READ, filesystem: 'none', network: 'fetch', process: 'none' },
91
+ orchestration_status: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
92
+ orchestration_readiness: { class: READ, filesystem: 'read', network: 'none', process: 'none' },
93
+ call: { class: DESTRUCTIVE, filesystem: 'write', network: 'fetch', process: 'spawn' },
94
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * lib/mcp/tool-surface-parity.mjs — partition invariant for the MCP tool surface.
3
+ *
4
+ * The exposed surface is split into a flat core (CORE_TOOL_NAMES) and a long tail
5
+ * reached through the `call` gateway. Both are hand-maintained beside the catalog
6
+ * (ALL_TOOL_DEFS), so a typo in a core name silently drops a tool from BOTH the
7
+ * flat surface and the gateway enum, making it unreachable. These helpers enforce
8
+ * the partition from the catalog as the single source of truth: every declared
9
+ * core name must be a real catalog tool, and the flat core plus the gateway enum
10
+ * must together cover the catalog exactly once (no gap, no overlap, no phantom).
11
+ */
12
+
13
+ // A core name that is not in the catalog is a typo or a stale entry; it must be
14
+ // rejected loudly rather than silently dropping the tool from every surface.
15
+
16
+ export function assertCoreSubsetOfCatalog(coreNames, catalog) {
17
+ const catalogSet = catalog instanceof Set ? catalog : new Set(catalog);
18
+ const phantom = [...coreNames].filter((name) => !catalogSet.has(name));
19
+ if (phantom.length > 0) {
20
+ throw new Error(
21
+ `tool-surface-parity: ${phantom.length} core tool name(s) absent from the catalog: ${phantom.join(', ')} — `
22
+ + 'a typo in CORE_TOOL_NAMES drops the tool from both the flat surface and the call enum.',
23
+ );
24
+ }
25
+ return true;
26
+ }
27
+
28
+ // The flat core and the gateway enum must partition the catalog exactly once.
29
+ // Returns the partition diagnostics; assertToolSurfacePartition throws on any break.
30
+
31
+ export function checkToolSurfacePartition({ catalog, flat, enumNames }) {
32
+ const catalogSet = catalog instanceof Set ? catalog : new Set(catalog);
33
+ const surfaced = [...flat, ...enumNames];
34
+
35
+ const overlap = flat.filter((name) => enumNames.includes(name));
36
+ const duplicates = [...new Set(surfaced.filter((name, idx) => surfaced.indexOf(name) !== idx))];
37
+ const missing = [...catalogSet].filter((name) => !flat.includes(name) && !enumNames.includes(name));
38
+ const phantom = [...new Set(surfaced.filter((name) => !catalogSet.has(name)))];
39
+
40
+ return {
41
+ ok: overlap.length === 0 && duplicates.length === 0 && missing.length === 0 && phantom.length === 0,
42
+ overlap: overlap.sort(),
43
+ duplicates: duplicates.sort(),
44
+ missing: missing.sort(),
45
+ phantom: phantom.sort(),
46
+ };
47
+ }
48
+
49
+ export function assertToolSurfacePartition({ catalog, flat, enumNames }) {
50
+ const result = checkToolSurfacePartition({ catalog, flat, enumNames });
51
+ if (!result.ok) {
52
+ const parts = [];
53
+ if (result.overlap.length) parts.push(`flat AND enum: ${result.overlap.join(', ')}`);
54
+ if (result.duplicates.length) parts.push(`surfaced twice: ${result.duplicates.join(', ')}`);
55
+ if (result.missing.length) parts.push(`unreachable: ${result.missing.join(', ')}`);
56
+ if (result.phantom.length) parts.push(`not in catalog: ${result.phantom.join(', ')}`);
57
+ throw new Error(`tool-surface-parity: partition broken — ${parts.join('; ')}`);
58
+ }
59
+ return true;
60
+ }
@@ -13,16 +13,20 @@
13
13
  */
14
14
 
15
15
  import { runOrchestration, startRun, getRun, getRuns } from '../../orchestration/runtime.mjs';
16
+ import { governWebResults } from './web-search-governance.mjs';
16
17
 
17
18
  const TERMINAL = ['completed', 'completed-with-failures', 'cancelled', 'error'];
18
19
 
19
20
  function shapeRun(run) {
21
+ const allPrepared = Array.isArray(run.tasks) && run.tasks.length > 0 && run.tasks.every((t) => t.status === 'prepared');
20
22
  return {
21
23
  runId: run.runId,
22
- status: run.status,
24
+ status: allPrepared ? 'completed-prepare-only' : run.status,
25
+ prepareOnly: allPrepared,
26
+ semantics: run.semantics ?? null,
23
27
  executionMode: run.execution?.executionMode,
24
- degraded: run.execution?.degraded ?? false,
25
- degradationReason: run.execution?.degradationReason ?? null,
28
+ degraded: run.degraded ?? run.execution?.degraded ?? false,
29
+ degradationReason: run.degradationReason ?? run.execution?.degradationReason ?? null,
26
30
  intent: run.plan?.intent ?? null,
27
31
  track: run.plan?.track ?? null,
28
32
  suggestedWorkflowType: run.plan?.suggestedWorkflowType ?? null,
@@ -31,10 +35,33 @@ function shapeRun(run) {
31
35
  tasks: (run.tasks || []).map((t) => ({
32
36
  id: t.id, role: t.role, status: t.status, executor: t.executor,
33
37
  output: t.output ?? null, reasoning: t.reasoning ?? null, error: t.error ?? null,
38
+ // ADR-0050: which web grant reached the web, the F08-governed evidence, and search count.
39
+ webCapability: t.webCapability ?? null,
40
+ webEvidence: t.webEvidence ?? null,
41
+ webSearchRequests: t.webSearchRequests ?? 0,
34
42
  })),
35
43
  };
36
44
  }
37
45
 
46
+ // Fail-closed remote ingress guard (ADR-0050): the remote orchestration service is
47
+ // out-of-repo and cannot be trusted to govern. Re-run every task's webEvidence through
48
+ // the single F08 grader so a citation can never arrive trusted or ungoverned, and mark the
49
+ // run degraded if any item was not already trust:'untrusted' with a valid Admiralty grade.
50
+
51
+ export function governRemoteWebEvidence(run, now = Date.now()) {
52
+ let tampered = false;
53
+ for (const t of run.tasks || []) {
54
+ if (!Array.isArray(t.webEvidence) || t.webEvidence.length === 0) continue;
55
+ if (t.webEvidence.some((e) => e?.trust !== 'untrusted' || !/^[A-F][1-6]$/.test(e?.admiralty || ''))) tampered = true;
56
+ t.webEvidence = governWebResults(t.webEvidence, { now });
57
+ }
58
+ if (tampered) {
59
+ run.degraded = true;
60
+ run.degradationReason = run.degradationReason || 'remote-web-evidence-regoverned';
61
+ }
62
+ return run;
63
+ }
64
+
38
65
  function toRequest(args) {
39
66
  return {
40
67
  request: args.request,
@@ -108,13 +135,24 @@ export async function orchestrationStatus(args = {}, { env = process.env, cwd =
108
135
  // is set; the wire shape matches the standalone orchestration service's
109
136
  // /api/orchestration/runs contract (ADR-0022).
110
137
 
111
- async function runViaService(remote, reqObj, { wait, timeout_ms, workerBackend, fetchImpl }) {
138
+ // Per-request AbortSignal so a single hung fetch cannot stall runViaService
139
+ // past a deterministic window even before the poll loop's overall deadline check.
140
+
141
+ function timedRemoteFetch(fetchImpl, url, opts, perRequestMs) {
142
+ const signal = AbortSignal.timeout(perRequestMs);
143
+ return Promise.race([
144
+ fetchImpl(url, { ...opts, signal }),
145
+ new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason ?? new Error(`remote orchestration fetch timed out after ${perRequestMs}ms`)), { once: true })),
146
+ ]);
147
+ }
148
+
149
+ async function runViaService(remote, reqObj, { wait, timeout_ms, workerBackend, fetchImpl, perRequestTimeoutMs = 200 }) {
112
150
  const { base, token } = remote;
113
151
  const headers = { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
114
152
  const body = JSON.stringify({ ...reqObj, workerBackend });
115
153
  let started;
116
154
  try {
117
- const res = await fetchImpl(`${base}/api/orchestration/runs`, { method: 'POST', headers, body });
155
+ const res = await timedRemoteFetch(fetchImpl, `${base}/api/orchestration/runs`, { method: 'POST', headers, body }, perRequestTimeoutMs);
118
156
  if (res.status === 401 || res.status === 403) {
119
157
  return { error: `Remote service rejected the request (HTTP ${res.status}). Set CONSTRUCT_ORCHESTRATION_TOKEN (or CONSTRUCT_DASHBOARD_TOKEN).`, service: base, failFast: true };
120
158
  }
@@ -137,7 +175,7 @@ async function runViaService(remote, reqObj, { wait, timeout_ms, workerBackend,
137
175
  }
138
176
  await new Promise((r) => setTimeout(r, 250));
139
177
  try {
140
- const res = await fetchImpl(`${base}/api/orchestration/runs/${encodeURIComponent(started.runId)}`, { headers });
178
+ const res = await timedRemoteFetch(fetchImpl, `${base}/api/orchestration/runs/${encodeURIComponent(started.runId)}`, { headers }, perRequestTimeoutMs);
141
179
  const envelope = await res.json();
142
180
  if (!res.ok) return { error: `Remote service error while polling (HTTP ${res.status}): ${envelope.error || 'unknown'}`, runId: started.runId, service: base };
143
181
  run = envelope.data;
@@ -145,7 +183,7 @@ async function runViaService(remote, reqObj, { wait, timeout_ms, workerBackend,
145
183
  return { error: `Lost connection to the remote service while polling run ${started.runId}: ${err.message}`, runId: started.runId, service: base, failFast: true };
146
184
  }
147
185
  }
148
- return { ...shapeRun(run), service: base };
186
+ return { ...shapeRun(governRemoteWebEvidence(run)), service: base };
149
187
  }
150
188
 
151
189
  async function statusViaService(remote, { run_id, limit }, { fetchImpl }) {
@@ -8,7 +8,8 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs';
8
8
  import { loadRegistry } from '../../registry/loader.mjs';
9
9
  import { join, resolve } from 'node:path';
10
10
  import { homedir } from 'node:os';
11
- import { execSync } from 'node:child_process';
11
+ import { execFileSync } from 'node:child_process';
12
+ import { resolveWithinRoot } from '../../path-policy.mjs';
12
13
  import { loadConstructEnv } from '../../env-config.mjs';
13
14
  import { inspectContextState } from '../../context-state.mjs';
14
15
  import { loadWorkflow, summarizeWorkflow, inspectWorkflowHealth } from '../../workflow-state.mjs';
@@ -16,8 +17,11 @@ import { resolveExecutionContractModelMetadata, selectModelTierForWorkCategory }
16
17
  import { buildPublicHealthSurface } from '../../status.mjs';
17
18
  import { doctorRoot } from '../../config/xdg.mjs';
18
19
 
19
- export function exec(cmd, cwd) {
20
- return execSync(cmd, { stdio: 'pipe', timeout: 10000, cwd }).toString().trim();
20
+ // Model-controlled refs reach these git calls. Run git through argv with no shell so a
21
+ // base_ref like `$(...)` is handed to git as one literal argument, never evaluated.
22
+
23
+ function runGit(argv, cwd) {
24
+ return execFileSync('git', argv, { stdio: 'pipe', timeout: 10000, cwd }).toString().trim();
21
25
  }
22
26
 
23
27
  export function readJSON(filePath) {
@@ -140,10 +144,16 @@ export function summarizeDiff(args) {
140
144
  const baseRef = args.base_ref ?? 'HEAD~1';
141
145
  const cwd = args.cwd ? resolve(args.cwd) : process.cwd();
142
146
 
147
+ // A ref that starts with `-` would be read by git as an option, not a revision;
148
+ // control characters never belong in a ref. Reject both before argv handoff.
149
+ if (typeof baseRef !== 'string' || baseRef.startsWith('-') || /[\0\n\r]/.test(baseRef)) {
150
+ return { error: 'Invalid base_ref' };
151
+ }
152
+
143
153
  let stat, nameStatus;
144
154
  try {
145
- stat = exec(`git diff --stat ${baseRef}`, cwd);
146
- nameStatus = exec(`git diff --name-status ${baseRef}`, cwd);
155
+ stat = runGit(['diff', '--stat', baseRef], cwd);
156
+ nameStatus = runGit(['diff', '--name-status', baseRef], cwd);
147
157
  } catch {
148
158
  return { error: 'Not a git repository or git not available' };
149
159
  }
@@ -184,7 +194,14 @@ export function summarizeDiff(args) {
184
194
  }
185
195
 
186
196
  export function scanFile(args) {
187
- const filePath = resolve(args.file_path);
197
+ const root = args.cwd ? resolve(args.cwd) : process.cwd();
198
+
199
+ let filePath;
200
+ try {
201
+ filePath = resolveWithinRoot(root, args.file_path);
202
+ } catch (err) {
203
+ return { error: err.message };
204
+ }
188
205
 
189
206
  let rawBuffer;
190
207
  try {
@@ -302,14 +319,14 @@ export function projectContext(args, { ROOT_DIR }) {
302
319
  let changed_files = [];
303
320
 
304
321
  try {
305
- const log = exec('git log --oneline -10', cwd);
322
+ const log = runGit(['log', '--oneline', '-10'], cwd);
306
323
  recent_commits = log.split('\n').filter(Boolean);
307
324
  } catch {
308
325
  // not a git repo or no commits
309
326
  }
310
327
 
311
328
  try {
312
- const status = exec('git status --short', cwd);
329
+ const status = runGit(['status', '--short'], cwd);
313
330
  const statusLines = status.split('\n').filter(Boolean);
314
331
  changed_files = statusLines.map((l) => l.trim());
315
332
  working_tree_status = statusLines.length > 0 ? 'dirty' : 'clean';
@@ -2,11 +2,13 @@
2
2
  * lib/mcp/tools/storage.mjs — Storage MCP tools: status, sync, reset, and artifact deletion.
3
3
  *
4
4
  * All functions are async. Wraps lib/storage/admin.mjs and lib/storage/sync.mjs.
5
- * Requires confirm=true guards on destructive operations (reset, delete).
5
+ * Destructive operations (reset, delete) require confirm=true plus a one-time out-of-band
6
+ * approval token, since these tools are reachable only through the model's argument channel.
6
7
  */
7
8
  import { resolve } from 'node:path';
8
9
  import { deleteIngestedArtifacts, getStorageStatus, inferProjectName, purgeExpiredData, resetStorage } from '../../storage/admin.mjs';
9
10
  import { syncFileStateToSql } from '../../storage/sync.mjs';
11
+ import { consumeApprovalToken } from '../destructive-approval.mjs';
10
12
 
11
13
  export async function storageStatus(args) {
12
14
  const cwd = args.cwd ? resolve(String(args.cwd)) : process.cwd();
@@ -34,6 +36,9 @@ export async function storageReset(args) {
34
36
  if (args.confirm !== true) {
35
37
  return { error: 'storage_reset requires confirm=true' };
36
38
  }
39
+ if (!consumeApprovalToken('storage_reset', args.approval_token)) {
40
+ return { error: 'storage_reset requires a valid out-of-band approval token; confirm=true alone is not authorization' };
41
+ }
37
42
  return resetStorage(cwd, {
38
43
  env: process.env,
39
44
  project,
@@ -48,6 +53,9 @@ export async function deleteIngestedArtifactsTool(args) {
48
53
  if (args.confirm !== true) {
49
54
  return { error: 'delete_ingested_artifacts requires confirm=true' };
50
55
  }
56
+ if (!consumeApprovalToken('delete_ingested_artifacts', args.approval_token)) {
57
+ return { error: 'delete_ingested_artifacts requires a valid out-of-band approval token; confirm=true alone is not authorization' };
58
+ }
51
59
  const files = Array.isArray(args.files)
52
60
  ? args.files.map((value) => String(value))
53
61
  : [];
@@ -0,0 +1,96 @@
1
+ /**
2
+ * lib/mcp/tools/web-search-governance.mjs — the single F08 grader for web evidence.
3
+ *
4
+ * Every web result any specialist or tool ever sees passes through governWebResults here,
5
+ * so the F08 governance invariants (ADR-0017) are produced in exactly one place: a
6
+ * claim-relative class, an Admiralty grade defaulting to C3 with a derived confidence,
7
+ * a normalized date with a 12-month staleness flag, most-recent-first ordering, and the
8
+ * OWASP-LLM01 `trust: 'untrusted'` stamp on externally-sourced text. Results without a
9
+ * verifiable http(s) URL are dropped, never guessed. web-search.mjs (the governed
10
+ * provider tool), the orchestration worker's provider-native web path, and the remote
11
+ * orchestration ingress guard all re-use this grader so a citation can never reach a
12
+ * specialist ungoverned or mislabeled as trusted.
13
+ */
14
+
15
+ const COMMUNITY_HOSTS = ['reddit.com', 'stackoverflow.com', 'news.ycombinator.com', 'discord.com', 'github.com'];
16
+ const HIGH_CONFIDENCE_GRADES = new Set(['A1', 'A2', 'B1']);
17
+ const STALE_AFTER_DAYS = 365;
18
+
19
+ export function normalizeDate(it) {
20
+ const raw = it.date || it.publishedAt || it.published || it.published_at || it.datePublished || null;
21
+ if (!raw) return null;
22
+ const t = Date.parse(raw);
23
+ return Number.isNaN(t) ? null : new Date(t).toISOString().slice(0, 10);
24
+ }
25
+
26
+ // Claim-relative class (ADR-0017): community content is admissible primary evidence for
27
+ // sentiment/demand claims under a checklist the tool cannot verify deterministically, so
28
+ // community hosts default to `tertiary` (conservative for factual claims) and everything
29
+ // else to `secondary`, leaving the researcher to promote a community source when the claim
30
+ // is about experience.
31
+
32
+ export function classifyResult(url) {
33
+ try {
34
+ const host = new URL(url).hostname.replace(/^www\./, '');
35
+ return COMMUNITY_HOSTS.some((h) => host === h || host.endsWith('.' + h)) ? 'tertiary' : 'secondary';
36
+ } catch {
37
+ return 'tertiary';
38
+ }
39
+ }
40
+
41
+ // Confidence maps from the Admiralty grade; `high` is reserved for A1/A2/B1 (ADR-0017). A
42
+ // grade with reliability A/B or credibility 1/2 is `medium`; anything weaker is `low`.
43
+
44
+ export function confidenceFromGrade(grade) {
45
+ if (HIGH_CONFIDENCE_GRADES.has(grade)) return 'high';
46
+ const reliability = grade?.[0];
47
+ const credibility = grade?.[1];
48
+ if (reliability === 'A' || reliability === 'B' || credibility === '1' || credibility === '2') return 'medium';
49
+ return 'low';
50
+ }
51
+
52
+ // Grade one raw item into a governed web result, or null when it carries no verifiable
53
+ // http(s) URL. A raw item may come from a governed provider (may carry its own admiralty)
54
+ // or a provider-native citation (never does — defaults to C3 + needsGrading).
55
+
56
+ export function governWebResult(it, { now = Date.now() } = {}) {
57
+ if (!it || typeof it.url !== 'string' || !/^https?:\/\//.test(it.url)) return null;
58
+ const graded = typeof it.admiralty === 'string' && /^[A-F][1-6]$/.test(it.admiralty);
59
+ const admiralty = graded ? it.admiralty : 'C3';
60
+ const date = normalizeDate(it);
61
+ const stale = date != null && (now - Date.parse(date)) > STALE_AFTER_DAYS * 86_400_000;
62
+ return {
63
+ source: 'web',
64
+ url: it.url,
65
+ title: typeof it.title === 'string' && it.title ? it.title : it.url,
66
+ snippet: typeof it.snippet === 'string' ? it.snippet : '',
67
+ class: classifyResult(it.url),
68
+ admiralty,
69
+ confidence: confidenceFromGrade(admiralty),
70
+ date,
71
+ stale,
72
+ needsGrading: !graded,
73
+ // OWASP LLM01: web snippets are externally-sourced data — must not be treated as instructions
74
+ trust: 'untrusted',
75
+ };
76
+ }
77
+
78
+ // Order most-recent-first: fresh dated results, then undated (input order kept by the
79
+ // stable sort), then stale ones; newest first within a tier.
80
+
81
+ function recencyTier(r) {
82
+ return r.date && !r.stale ? 0 : r.date ? 2 : 1;
83
+ }
84
+
85
+ export function governWebResults(items, { now = Date.now() } = {}) {
86
+ const results = (Array.isArray(items) ? items : [])
87
+ .map((it) => governWebResult(it, { now }))
88
+ .filter(Boolean);
89
+ results.sort((a, b) => {
90
+ const t = recencyTier(a) - recencyTier(b);
91
+ if (t !== 0) return t;
92
+ if (a.date && b.date) return Date.parse(b.date) - Date.parse(a.date);
93
+ return 0;
94
+ });
95
+ return results;
96
+ }
@@ -8,11 +8,12 @@
8
8
  * Admiralty grade with a derived confidence (ADR-0017) — results without a URL are dropped, not
9
9
  * guessed; (2) every result is labeled `source: 'web'` and the tool never falls back to repo/source
10
10
  * search; (3) when no governed provider is configured or the provider is unreachable, the tool
11
- * returns a typed degradation and zero results rather than claiming it searched the web.
11
+ * returns a typed degradation and zero results rather than claiming it searched the web. The grading
12
+ * itself lives in web-search-governance.mjs, the single chokepoint shared with the orchestration
13
+ * worker's provider-native web path (ADR-0050).
12
14
  */
13
15
 
14
- const COMMUNITY_HOSTS = ['reddit.com', 'stackoverflow.com', 'news.ycombinator.com', 'discord.com', 'github.com'];
15
- const HIGH_CONFIDENCE_GRADES = new Set(['A1', 'A2', 'B1']);
16
+ import { governWebResults } from './web-search-governance.mjs';
16
17
 
17
18
  function providerConfig(env) {
18
19
  const url = env.WEB_SEARCH_URL;
@@ -20,36 +21,11 @@ function providerConfig(env) {
20
21
  return { url, key: env.WEB_SEARCH_KEY || null };
21
22
  }
22
23
 
23
- // Claim-relative class (ADR-0017): community content is admissible primary evidence for sentiment/
24
- // demand claims under a checklist the tool cannot verify deterministically, so it defaults community
25
- // hosts to `tertiary` (conservative for factual claims) and everything else to `secondary`, leaving
26
- // the researcher to promote a community source to primary when the claim is about experience.
27
-
28
- function classifyResult(url) {
29
- try {
30
- const host = new URL(url).hostname.replace(/^www\./, '');
31
- return COMMUNITY_HOSTS.some((h) => host === h || host.endsWith('.' + h)) ? 'tertiary' : 'secondary';
32
- } catch {
33
- return 'tertiary';
34
- }
35
- }
36
-
37
- // Confidence maps from the Admiralty grade; `high` is reserved for A1/A2/B1 (ADR-0017). A grade with
38
- // reliability A/B or credibility 1/2 is `medium`; anything weaker is `low`.
39
-
40
- function confidenceFromGrade(grade) {
41
- if (HIGH_CONFIDENCE_GRADES.has(grade)) return 'high';
42
- const reliability = grade?.[0];
43
- const credibility = grade?.[1];
44
- if (reliability === 'A' || reliability === 'B' || credibility === '1' || credibility === '2') return 'medium';
45
- return 'low';
46
- }
47
-
48
24
  function degraded(reason, note) {
49
25
  return { source: 'web', degraded: true, degradationReason: reason, results: [], note };
50
26
  }
51
27
 
52
- export async function webSearch(args = {}, { env = process.env, fetchImpl = globalThis.fetch } = {}) {
28
+ export async function webSearch(args = {}, { env = process.env, fetchImpl = globalThis.fetch, now = Date.now() } = {}) {
53
29
  const { query, claim, recency = null } = args;
54
30
  if (!query || typeof query !== 'string') {
55
31
  return { error: { code: 'INVALID_INPUT', message: 'query (string) is required' } };
@@ -74,21 +50,7 @@ export async function webSearch(args = {}, { env = process.env, fetchImpl = glob
74
50
  }
75
51
 
76
52
  const items = Array.isArray(payload?.results) ? payload.results : [];
77
- const results = items
78
- .filter((it) => it && typeof it.url === 'string' && /^https?:\/\//.test(it.url))
79
- .map((it) => {
80
- const admiralty = typeof it.admiralty === 'string' && /^[A-F][1-6]$/.test(it.admiralty) ? it.admiralty : 'C3';
81
- return {
82
- source: 'web',
83
- url: it.url,
84
- title: typeof it.title === 'string' && it.title ? it.title : it.url,
85
- snippet: typeof it.snippet === 'string' ? it.snippet : '',
86
- class: classifyResult(it.url),
87
- admiralty,
88
- confidence: confidenceFromGrade(admiralty),
89
- needsGrading: !(typeof it.admiralty === 'string' && /^[A-F][1-6]$/.test(it.admiralty)),
90
- };
91
- });
53
+ const results = governWebResults(items, { now });
92
54
 
93
55
  return { source: 'web', degraded: false, query, claim, results, dropped: items.length - results.length };
94
56
  }