brainclaw 1.14.0 → 1.16.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 (63) hide show
  1. package/README.md +16 -263
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-capture.js +209 -0
  4. package/dist/cli/register-code-map.js +19 -0
  5. package/dist/cli/register-coordination.js +472 -0
  6. package/dist/cli/register-federation.js +258 -0
  7. package/dist/cli/register-lifecycle.js +436 -0
  8. package/dist/cli/register-memory-context.js +502 -0
  9. package/dist/cli/register-planning.js +167 -0
  10. package/dist/cli/register-review.js +149 -0
  11. package/dist/cli/shared.js +5 -0
  12. package/dist/cli.js +212 -2015
  13. package/dist/commands/dispatch-watch.js +25 -2
  14. package/dist/commands/harvest.js +31 -6
  15. package/dist/commands/mcp-catalog.js +1438 -0
  16. package/dist/commands/mcp-contract.js +33 -0
  17. package/dist/commands/mcp-presentation.js +27 -0
  18. package/dist/commands/mcp-read-handlers.js +72 -36
  19. package/dist/commands/mcp-write-admin.js +328 -0
  20. package/dist/commands/mcp-write-claims.js +864 -0
  21. package/dist/commands/mcp-write-coordination.js +1825 -0
  22. package/dist/commands/mcp-write-entities.js +620 -0
  23. package/dist/commands/mcp-write-memory.js +451 -0
  24. package/dist/commands/mcp-write-sequences.js +116 -0
  25. package/dist/commands/mcp-write-support.js +367 -0
  26. package/dist/commands/mcp.js +261 -5570
  27. package/dist/commands/update-handoff.js +28 -42
  28. package/dist/core/agent-capability.js +31 -14
  29. package/dist/core/agent-files.js +1 -1
  30. package/dist/core/agent-registry.js +51 -3
  31. package/dist/core/claims.js +18 -0
  32. package/dist/core/coordination.js +5 -2
  33. package/dist/core/cross-project.js +35 -1
  34. package/dist/core/dispatcher.js +34 -20
  35. package/dist/core/entity-operations.js +335 -12
  36. package/dist/core/entity-registry.js +72 -9
  37. package/dist/core/execution.js +28 -4
  38. package/dist/core/facade-schema.js +30 -4
  39. package/dist/core/federation-cloud.js +142 -11
  40. package/dist/core/federation-outbox.js +292 -0
  41. package/dist/core/federation-signing.js +115 -0
  42. package/dist/core/handoff-review.js +35 -0
  43. package/dist/core/io.js +6 -0
  44. package/dist/core/protocol-tool-policy.js +113 -0
  45. package/dist/core/review-loop-close.js +115 -0
  46. package/dist/core/schema.js +25 -2
  47. package/dist/core/security-detectors.js +35 -6
  48. package/dist/core/security.js +32 -12
  49. package/dist/core/worktree.js +98 -9
  50. package/dist/facts.js +13 -11
  51. package/dist/facts.json +12 -10
  52. package/docs/PROTOCOL.md +7 -3
  53. package/docs/concepts/coordinator-runbook.md +3 -0
  54. package/docs/concepts/dispatch-lifecycle.md +4 -4
  55. package/docs/concepts/loop-engine.md +3 -1
  56. package/docs/concepts/troubleshooting.md +1 -1
  57. package/docs/integrations/codex.md +3 -3
  58. package/docs/integrations/overview.md +1 -1
  59. package/docs/mcp-schema-changelog.md +153 -2
  60. package/docs/playbooks/orchestration.md +1 -1
  61. package/docs/product/entity-model-audit.md +3 -2
  62. package/docs/security.md +22 -1
  63. package/package.json +3 -1
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Ed25519 request signing for the Brainclaw Cloud federation bridge (pln#100).
3
+ *
4
+ * The cloud verifier (brainclaw-cloud/src/middleware/signature.ts) expects three
5
+ * headers on a signed runtime write:
6
+ * X-Agent-Id: the cloud agent id whose public_key_pem is stored in D1
7
+ * X-Agent-Signature: base64 Ed25519 signature over (body + timestamp)
8
+ * X-Agent-Timestamp: ISO-8601 timestamp (5-minute replay window)
9
+ *
10
+ * The signing key is the agent's local Ed25519 private key managed by
11
+ * agent-registry (~/.brainclaw/keys/<id>.ed25519.pem). Its SPKI public-key PEM
12
+ * is what gets registered with the cloud, and sha256(pem) is the fingerprint
13
+ * both sides compute — so a local↔remote fingerprint match proves the same key.
14
+ *
15
+ * @module
16
+ */
17
+ import crypto from 'node:crypto';
18
+ import { loadConfig } from './config.js';
19
+ import { loadAgentSigningKey, resolveRegisteredAgentIdentity } from './agent-registry.js';
20
+ import { logger } from './logger.js';
21
+ export const AGENT_ID_HEADER = 'X-Agent-Id';
22
+ export const AGENT_SIGNATURE_HEADER = 'X-Agent-Signature';
23
+ export const AGENT_TIMESTAMP_HEADER = 'X-Agent-Timestamp';
24
+ /**
25
+ * Sign a request body with an Ed25519 private key, producing the cloud's
26
+ * signature headers. The signed message is exactly `body + timestamp` (UTF-8),
27
+ * mirroring the verifier. Node signs Ed25519 with a null algorithm.
28
+ */
29
+ export function signCloudBody(body, params) {
30
+ const timestamp = params.timestamp ?? new Date().toISOString();
31
+ const privateKey = crypto.createPrivateKey(params.privateKeyPem);
32
+ const signature = crypto.sign(null, Buffer.from(body + timestamp, 'utf-8'), privateKey);
33
+ return {
34
+ [AGENT_ID_HEADER]: params.agentId,
35
+ [AGENT_SIGNATURE_HEADER]: signature.toString('base64'),
36
+ [AGENT_TIMESTAMP_HEADER]: timestamp,
37
+ };
38
+ }
39
+ /**
40
+ * Build the outgoing header set for a cloud runtime write.
41
+ *
42
+ * - Always includes Content-Type + X-API-Key.
43
+ * - Adds the Ed25519 signature headers when a signing identity is available.
44
+ * - Returns `undefined` (fail-closed) when `requireSigned` is set but no signing
45
+ * identity is available — the caller MUST NOT send the request in that case.
46
+ *
47
+ * Pure and dependency-injected (no filesystem/network) so it is unit-testable.
48
+ */
49
+ export function buildCloudWriteHeaders(body, opts) {
50
+ const headers = {
51
+ 'Content-Type': 'application/json',
52
+ 'X-API-Key': opts.apiKey,
53
+ };
54
+ if (opts.signing) {
55
+ Object.assign(headers, signCloudBody(body, {
56
+ agentId: opts.signing.cloudAgentId,
57
+ privateKeyPem: opts.signing.privateKeyPem,
58
+ timestamp: opts.timestamp,
59
+ }));
60
+ }
61
+ else if (opts.requireSigned) {
62
+ return undefined;
63
+ }
64
+ return headers;
65
+ }
66
+ /**
67
+ * Resolve the approved agent identity used to sign runtime writes.
68
+ *
69
+ * The private key is loaded from the LOCAL brainclaw identity (resolved by
70
+ * configured agent name / current-agent), while `cloudAgentId` (the value for
71
+ * X-Agent-Id) comes from the configured cloud agent id when set — the cloud
72
+ * assigns its own id at registration, distinct from the local identity id. When
73
+ * no cloud id is configured (self-hosted / id-preserving), the local id is used.
74
+ *
75
+ * Returns undefined when no agent is configured or no local Ed25519 key exists.
76
+ */
77
+ export function resolveCloudSigningIdentity(cwd, env = process.env) {
78
+ let cfgAgentId;
79
+ let cfgAgentName;
80
+ try {
81
+ const config = loadConfig(cwd);
82
+ cfgAgentId = config.cloud_sync?.agent_id;
83
+ cfgAgentName = config.cloud_sync?.agent_name;
84
+ }
85
+ catch {
86
+ // No project config — fall back to env only.
87
+ }
88
+ // X-Agent-Id must be the CLOUD agent id, which is distinct from the local
89
+ // identity id. BRAINCLAW_AGENT_ID is already the LOCAL id everywhere in
90
+ // brainclaw (current-agent resolution, etc.), so a dedicated
91
+ // BRAINCLAW_CLOUD_AGENT_ID override avoids sending the local id as the cloud
92
+ // header when a session exports BRAINCLAW_AGENT_ID (review finding, pln#100).
93
+ const cloudAgentId = (env.BRAINCLAW_CLOUD_AGENT_ID?.trim() || cfgAgentId || '').trim() || undefined;
94
+ const agentName = (env.BRAINCLAW_AGENT_NAME?.trim() || env.BRAINCLAW_AGENT?.trim() || cfgAgentName || '').trim() || undefined;
95
+ // Resolve the LOCAL identity backing the private key: prefer name, then fall
96
+ // back to a local-id match (self-hosted), then the current agent.
97
+ const identity = resolveRegisteredAgentIdentity({ agentName, cwd, env, allowCurrent: true, allowEnv: true }) ??
98
+ (cloudAgentId ? resolveRegisteredAgentIdentity({ agentId: cloudAgentId, cwd, env }) : undefined);
99
+ if (!identity) {
100
+ logger.debug('Cloud signing: no local agent identity resolved.');
101
+ return undefined;
102
+ }
103
+ const key = loadAgentSigningKey(identity.agent_id, env);
104
+ if (!key) {
105
+ logger.debug(`Cloud signing: agent '${identity.agent_name}' has no local Ed25519 key.`);
106
+ return undefined;
107
+ }
108
+ return {
109
+ cloudAgentId: cloudAgentId ?? identity.agent_id,
110
+ localAgentId: identity.agent_id,
111
+ agentName: identity.agent_name,
112
+ ...key,
113
+ };
114
+ }
115
+ //# sourceMappingURL=federation-signing.js.map
@@ -0,0 +1,35 @@
1
+ import { nowISO } from './ids.js';
2
+ /**
3
+ * The review sub-fields whose presence in a patch marks the review as
4
+ * "completed" and (re)stamps `reviewed_at`. Single source of truth for the
5
+ * completion rule — shared by the canonical grammar (`updateEntity(handoff)`)
6
+ * and the dispatcher/CLI path (`applyHandoffUpdates`) so the two write paths
7
+ * can never drift (pln#625 Phase 3, Codex review of #84).
8
+ */
9
+ export const REVIEW_COMPLETION_FIELDS = [
10
+ 'verdict',
11
+ 'reviewed_by',
12
+ 'summary',
13
+ 'blocking_issues',
14
+ 'suggestions',
15
+ ];
16
+ /**
17
+ * Merge a partial review patch onto an existing review (shallow — PATCH
18
+ * semantics: provided fields overwrite, others survive) and stamp
19
+ * `reviewed_at` when the patch introduces a completion field.
20
+ *
21
+ * A caller that explicitly provides `reviewed_at` (e.g. a federation import
22
+ * replaying a prior review) keeps its value; otherwise a completing patch
23
+ * stamps `nowISO()`. The flat-option callers (applyHandoffUpdates) never carry
24
+ * `reviewed_at`, so for them this is an unconditional restamp-on-completion —
25
+ * identical to the pre-extraction behaviour.
26
+ */
27
+ export function mergeHandoffReview(existing, patch) {
28
+ const merged = { ...(existing ?? {}), ...patch };
29
+ const completed = REVIEW_COMPLETION_FIELDS.some((field) => patch[field] !== undefined);
30
+ if (completed && patch.reviewed_at === undefined) {
31
+ merged.reviewed_at = nowISO();
32
+ }
33
+ return merged;
34
+ }
35
+ //# sourceMappingURL=handoff-review.js.map
package/dist/core/io.js CHANGED
@@ -34,6 +34,12 @@ const ENTITY_DIR_MAP = {
34
34
  'runtime': 'coordination/runtime',
35
35
  'runtime-hosts': 'coordination/runtime-hosts',
36
36
  'runtime-private': 'coordination/runtime-private',
37
+ // federation/ — outbound cloud sync queue (pln#101 Phase 2): durable outbox,
38
+ // archived 'sent' markers, and 'parked' dead-letters.
39
+ 'federation': 'coordination/federation',
40
+ 'federation/outbox': 'coordination/federation/outbox',
41
+ 'federation/sent': 'coordination/federation/sent',
42
+ 'federation/parked': 'coordination/federation/parked',
37
43
  'surface-tasks': 'coordination/surface-tasks',
38
44
  'assignments': 'coordination/assignments',
39
45
  'runs': 'coordination/runs',
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Static protocol tool-policy name lists (core-owned, pln#622 PR1).
3
+ *
4
+ * Ces listes statiques REMPLACENT les dérivations depuis les annotations
5
+ * d'ALL_TOOLS ; la cohérence avec le catalogue est garantie par
6
+ * tests/unit/protocol-tool-policy.test.ts.
7
+ *
8
+ * (These STATIC lists REPLACE the derivations from ALL_TOOLS annotations for
9
+ * core consumers: core/ must not import the commands/ MCP layer, so the tool
10
+ * names are materialised here instead of being derived from the catalog at
11
+ * import time. The catalog — src/commands/mcp-catalog.ts — KEEPS deriving its
12
+ * own copies from tool annotations; bidirectional set equality between these
13
+ * static lists and the catalog derivations is enforced by
14
+ * tests/unit/protocol-tool-policy.test.ts.)
15
+ *
16
+ * Zero imports by design — this module must stay a pure leaf.
17
+ *
18
+ * @module
19
+ */
20
+ /**
21
+ * Tools safe for headless auto-approval (annotation `headlessApproval: 'auto'`
22
+ * in the catalog). Consumed by agent-files writers (Cline autoApprove, Roo
23
+ * alwaysAllow, Codex approval_mode). Order mirrors catalog declaration order
24
+ * so generated agent config files are byte-identical to the derived era.
25
+ */
26
+ export const MCP_HEADLESS_AUTO_TOOL_NAMES = [
27
+ 'bclaw_context',
28
+ 'bclaw_search',
29
+ 'bclaw_estimation_report',
30
+ 'bclaw_list_sequences',
31
+ 'bclaw_assignment_events',
32
+ 'bclaw_list_agents',
33
+ 'bclaw_list_instructions',
34
+ 'bclaw_get_capabilities',
35
+ 'bclaw_list_tools',
36
+ 'bclaw_search_tools',
37
+ 'bclaw_doctor',
38
+ 'bclaw_history',
39
+ 'bclaw_audit',
40
+ 'bclaw_get_discovery',
41
+ 'bclaw_conflict_check',
42
+ 'bclaw_who',
43
+ 'bclaw_check_policy',
44
+ 'bclaw_check_security',
45
+ 'bclaw_read_inbox',
46
+ 'bclaw_get_thread',
47
+ 'bclaw_dispatch_status',
48
+ 'bclaw_code_status',
49
+ 'bclaw_code_find',
50
+ 'bclaw_code_brief',
51
+ 'bclaw_send_message',
52
+ 'bclaw_ack_message',
53
+ 'bclaw_write_note',
54
+ 'bclaw_quick_capture',
55
+ 'bclaw_claim',
56
+ 'bclaw_release_claim',
57
+ 'bclaw_session_start',
58
+ 'bclaw_session_end',
59
+ 'bclaw_add_step',
60
+ 'bclaw_complete_step',
61
+ 'bclaw_update_step',
62
+ 'bclaw_update_handoff',
63
+ 'bclaw_work',
64
+ 'bclaw_coordinate',
65
+ 'bclaw_loop',
66
+ 'bclaw_assignment_update',
67
+ 'bclaw_assignment_action',
68
+ 'bclaw_harvest_candidates',
69
+ 'bclaw_find',
70
+ 'bclaw_get',
71
+ ];
72
+ /**
73
+ * Narrow "canonical grammar" tool set — the read-side facade entries
74
+ * (session + context) plus the five memory verbs. Consumed by writers
75
+ * (e.g. Hermes' tools.include) that want a minimal advertised surface.
76
+ */
77
+ export const MCP_CANONICAL_GRAMMAR_TOOL_NAMES = [
78
+ 'bclaw_context',
79
+ 'bclaw_work',
80
+ 'bclaw_find',
81
+ 'bclaw_get',
82
+ 'bclaw_create',
83
+ 'bclaw_update',
84
+ 'bclaw_transition',
85
+ ];
86
+ /**
87
+ * Tools removed from the MCP surface at the v1.0 cut (Phase 3 slice 3i).
88
+ * Hidden from every `tools/list` response; direct `tools/call` still works
89
+ * as a migration escape hatch.
90
+ */
91
+ export const REMOVED_IN_V1_TOOLS = new Set([
92
+ 'bclaw_list_plans',
93
+ 'bclaw_list_candidates',
94
+ 'bclaw_list_claims',
95
+ 'bclaw_list_actions',
96
+ 'bclaw_list_assignments',
97
+ 'bclaw_list_runs',
98
+ 'bclaw_list_agents', // pln#625 — retired for bclaw_find(entity='agent')
99
+ 'bclaw_read_handoff',
100
+ 'bclaw_create_plan',
101
+ 'bclaw_update_plan',
102
+ 'bclaw_create_candidate',
103
+ 'bclaw_accept',
104
+ 'bclaw_reject',
105
+ 'bclaw_get_execution_context',
106
+ 'bclaw_get_agent_board',
107
+ 'bclaw_get_agent_board_summary',
108
+ 'bclaw_dispatch_analysis',
109
+ 'bclaw_dispatch_review',
110
+ 'bclaw_update_handoff',
111
+ 'bclaw_get_context',
112
+ ]);
113
+ //# sourceMappingURL=protocol-tool-policy.js.map
@@ -0,0 +1,115 @@
1
+ import { getLoop } from './loops/store.js';
2
+ import { complete_turn, advance } from './loops/verbs.js';
3
+ import { withLoopLock } from './loops/lock.js';
4
+ /** review-loop:lop_xxx → the loop id (mirrors assignment-reconciler.ts). */
5
+ const REVIEW_LOOP_SCOPE_RE = /^review-loop:(lop_[0-9a-z]+)/;
6
+ const LOOP_TERMINAL = new Set(['completed', 'cancelled', 'blocked']);
7
+ /** Mirrors verbs.ts:isVerdictAccepted — reviewer_green fires only on a `verdict`
8
+ * artifact whose body starts with "accepted". */
9
+ function isAcceptedVerdict(artifact) {
10
+ if (artifact.type !== 'verdict')
11
+ return false;
12
+ return /^accepted(?:\b|[:\s])/.test((artifact.body ?? '').trim().toLowerCase());
13
+ }
14
+ /**
15
+ * Resolve the reviewer slot to complete. STRICT binding first: the active slot
16
+ * whose assignment_id matches this lane's assignment (the #87 coordinate fix
17
+ * stamps it), so symmetric loops target the right reviewer. If OTHER slots are
18
+ * bound but none matches ours, refuse (never complete someone else's slot).
19
+ * Falls back to a single active reviewer / agent match only for legacy unbound
20
+ * slots. Returns undefined when no active reviewer slot is ours — the caller
21
+ * then checks the resume path.
22
+ */
23
+ function resolveReviewerSlot(loop, assignment) {
24
+ const active = loop.slots.filter((s) => s.role === 'reviewer' && s.status !== 'done' && s.status !== 'cancelled' && s.status !== 'failed');
25
+ if (active.length === 0)
26
+ return undefined;
27
+ if (assignment.id) {
28
+ const bound = active.find((s) => s.assignment_id === assignment.id);
29
+ if (bound)
30
+ return bound;
31
+ // Some active slots are bound to OTHER assignments — do not guess/steal.
32
+ if (active.some((s) => s.assignment_id !== undefined))
33
+ return undefined;
34
+ }
35
+ // Legacy unbound slots: single reviewer, else disambiguate by agent.
36
+ if (active.length === 1)
37
+ return active[0];
38
+ const byAgent = assignment.agent ? active.find((s) => s.agent === assignment.agent) : undefined;
39
+ return byAgent ?? active[0];
40
+ }
41
+ /**
42
+ * Map a harvested review lane onto its loop and close/advance it.
43
+ *
44
+ * Fires ONLY when the assignment scope is a review-loop (`review-loop:lop_…`)
45
+ * AND the lane carries a `review_verdict` — otherwise returns undefined and the
46
+ * caller (harvest) proceeds unchanged. Idempotent, convergent, and defensive:
47
+ * a terminal loop is a no-op, a partial prior pass is resumed, and any
48
+ * loop-verb / lock error is swallowed into a `noop` result so a loop-close
49
+ * failure never breaks harvest (mirrors convergeSlotAssignmentsForClosedLoop).
50
+ */
51
+ export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd) {
52
+ const scopeMatch = assignment.scope?.match(REVIEW_LOOP_SCOPE_RE);
53
+ if (!scopeMatch)
54
+ return undefined;
55
+ if (!lane.review_verdict)
56
+ return undefined;
57
+ const loopId = scopeMatch[1];
58
+ const verdict = lane.review_verdict;
59
+ const noop = (reason, loop_status) => ({
60
+ loop_id: loopId, verdict, action: 'noop', reason, loop_status,
61
+ });
62
+ try {
63
+ // Lock the loop so the compound complete_turn + advance can't interleave
64
+ // with a concurrent harvest (BLOCKING 3). All state is re-read inside.
65
+ return withLoopLock({
66
+ cwd,
67
+ intent: 'review-harvest-close',
68
+ agentId: actor,
69
+ scope: { kind: 'loop', loopId },
70
+ work: () => {
71
+ const loop = getLoop(loopId, cwd);
72
+ if (!loop)
73
+ return noop('loop not found');
74
+ if (LOOP_TERMINAL.has(loop.status))
75
+ return noop(`loop already ${loop.status}`, loop.status);
76
+ const slot = resolveReviewerSlot(loop, assignment);
77
+ const acceptedVerdictExists = loop.artifacts.some(isAcceptedVerdict);
78
+ if (slot) {
79
+ // Active reviewer slot → record the verdict on it. isVerdictAccepted
80
+ // fires reviewer_green ONLY on an "accepted…" body, so approve MUST
81
+ // start with "accepted" and request_changes must NOT.
82
+ const summary = (lane.review_summary ?? '').trim();
83
+ const body = verdict === 'approve'
84
+ ? `accepted${summary ? `: ${summary}` : ''}`
85
+ : `changes-requested${summary ? `: ${summary}` : ''}`;
86
+ complete_turn({ id: loopId, slot_id: slot.slot_id, actor, artifact: { phase: loop.current_phase, type: 'verdict', body } }, cwd);
87
+ }
88
+ else if (!(verdict === 'approve' && acceptedVerdictExists)) {
89
+ // No reviewer slot is ours to complete. Resume ONLY the approve→close
90
+ // case: a prior pass recorded an accepted verdict but died before
91
+ // advancing. For request_changes (or no accepted verdict), the single
92
+ // advance already happened on the first pass — do not re-advance.
93
+ return noop('already processed (no active reviewer slot to (re)advance)', loop.status);
94
+ }
95
+ // Advance: closes on reviewer_green (approve), else moves one phase.
96
+ // Convergent — safe whether we just recorded the verdict or are resuming
97
+ // an interrupted approve.
98
+ const advanced = advance({ id: loopId, actor }, cwd);
99
+ return {
100
+ loop_id: loopId,
101
+ verdict,
102
+ action: advanced.auto_closed ? 'closed' : 'advanced',
103
+ reason: advanced.auto_closed
104
+ ? `reviewer_green → loop ${advanced.loop.status}`
105
+ : `verdict recorded → advanced to phase "${advanced.loop.current_phase}" (awaiting fix cycle — PR2)`,
106
+ loop_status: advanced.loop.status,
107
+ };
108
+ },
109
+ });
110
+ }
111
+ catch (err) {
112
+ return noop(`loop close error (harvest not blocked): ${err instanceof Error ? err.message : String(err)}`);
113
+ }
114
+ }
115
+ //# sourceMappingURL=review-loop-close.js.map
@@ -960,8 +960,9 @@ export const RuntimeEventTypeSchema = z.enum([
960
960
  /**
961
961
  * pln#526 — LANE-RESULT convention. A dispatched worker writes a single
962
962
  * `LANE-RESULT.json` at its worktree root as its final step (a fallback that
963
- * works even when bclaw_assignment_update / MCP is unavailable, e.g. sandboxed
964
- * agents). The coordinator ingests it with `brainclaw harvest <assignment_id>`.
963
+ * works even when bclaw_assignment_update / MCP is unavailable in the worker's
964
+ * environment, e.g. a genuinely MCP-less agent). The coordinator ingests it with
965
+ * `brainclaw harvest <assignment_id>`.
965
966
  */
966
967
  export const LaneResultSchema = z.object({
967
968
  assignment_id: z.string(),
@@ -973,6 +974,17 @@ export const LaneResultSchema = z.object({
973
974
  files_changed: z.array(z.string()).optional(),
974
975
  /** Free-form notes (blockers, follow-ups). */
975
976
  notes: z.string().optional(),
977
+ /**
978
+ * pln#628 Focus 4B — review-loop verdict. A worker running a review-loop turn
979
+ * sets this to signal whether the change is good to merge (`approve`) or needs
980
+ * fixes (`request_changes`). The coordinator's harvest maps it onto a loop
981
+ * `verdict` artifact so `reviewer_green` can fire and the loop auto-closes
982
+ * without a human driving complete_turn/advance by hand. Absent on
983
+ * non-review lanes — harvest simply skips the loop-close callback then.
984
+ */
985
+ review_verdict: z.enum(['approve', 'request_changes']).optional(),
986
+ /** One-line rationale accompanying review_verdict (shown in the verdict artifact). */
987
+ review_summary: z.string().optional(),
976
988
  });
977
989
  export const RuntimeEventSchema = z.object({
978
990
  id: z.string(),
@@ -1079,6 +1091,17 @@ export const CloudSyncConfigSchema = z.object({
1079
1091
  enabled: z.boolean().default(false),
1080
1092
  endpoint: z.string().default('https://app.brainclaw.dev'),
1081
1093
  api_key: z.string().optional(),
1094
+ /** Remote project this bridge federates into (scopes signed runtime writes). */
1095
+ project_id: z.string().optional(),
1096
+ /** Approved remote agent identity used to sign runtime writes (pln#100). */
1097
+ agent_id: z.string().optional(),
1098
+ agent_name: z.string().optional(),
1099
+ /**
1100
+ * Fail-closed toggle: when true, the bridge refuses to push a runtime write
1101
+ * unless it can sign it with an approved agent's Ed25519 key. Absent/false
1102
+ * keeps existing API-key-only setups working (signing is additive).
1103
+ */
1104
+ require_signed: z.boolean().optional(),
1082
1105
  });
1083
1106
  export const SessionSnapshotSchema = z.object({
1084
1107
  schema_version: z.number().int().positive().optional(),
@@ -52,7 +52,7 @@ export function runStructuralDetectors(text, disabled) {
52
52
  out.push({
53
53
  detectorId: d.id,
54
54
  label: d.label,
55
- excerpt: truncate(m[0]),
55
+ excerpt: maskSecret(m[0]),
56
56
  });
57
57
  }
58
58
  }
@@ -113,13 +113,42 @@ export function runEntropyDetector(text, options = {}) {
113
113
  const context = text.slice(start, end);
114
114
  if (!SECRET_KEYWORD_CONTEXT.test(context))
115
115
  continue;
116
- out.push({ excerpt: truncate(token), entropy: Math.round(entropy * 100) / 100 });
116
+ out.push({ excerpt: maskSecret(token), entropy: Math.round(entropy * 100) / 100 });
117
117
  }
118
118
  return out;
119
119
  }
120
- function truncate(s, maxLen = 48) {
121
- if (s.length <= maxLen)
122
- return s;
123
- return s.slice(0, Math.max(8, maxLen / 2)) + '…' + s.slice(-Math.max(4, maxLen / 4));
120
+ /**
121
+ * Irreversibly mask a matched secret for display.
122
+ *
123
+ * The previous behavior truncated the match to ~48 chars, which returned
124
+ * short secrets (GitHub PATs are 40 chars, AWS key IDs are 20) verbatim in
125
+ * warning messages and logs. Masking keeps just enough to identify the
126
+ * token family without ever exposing recoverable material. Splitting is
127
+ * done per Unicode code point, so surrogate pairs are never cut in half.
128
+ *
129
+ * - matches of 2 code points or fewer: `***` alone (exposing even the
130
+ * first code point would reveal most or all of the value);
131
+ * - matches of 3–8 code points: first code point + `***`;
132
+ * - longer matches: at most ⌊length/3⌋ code points are exposed, capped
133
+ * at 6, split prefix-heavy (up to 4 leading — enough to identify
134
+ * `ghp_`, `AKIA`, `sk_l` — the remainder trailing) around a fixed
135
+ * `…***…` marker.
136
+ *
137
+ * The exposure budget grows smoothly with the match length (no cliff at
138
+ * the short/long boundary) and never reveals more than a third of a
139
+ * match longer than 8 code points.
140
+ */
141
+ export function maskSecret(s) {
142
+ const cp = Array.from(s);
143
+ if (cp.length === 0)
144
+ return '';
145
+ if (cp.length <= 2)
146
+ return '***';
147
+ if (cp.length <= 8)
148
+ return cp[0] + '***';
149
+ const exposed = Math.min(6, Math.floor(cp.length / 3));
150
+ const lead = Math.min(4, exposed - 1);
151
+ const trail = exposed - lead;
152
+ return cp.slice(0, lead).join('') + '…***…' + cp.slice(cp.length - trail).join('');
124
153
  }
125
154
  //# sourceMappingURL=security-detectors.js.map
@@ -1,14 +1,27 @@
1
- import { runEntropyDetector, runStructuralDetectors } from './security-detectors.js';
1
+ import { maskSecret, runEntropyDetector, runStructuralDetectors } from './security-detectors.js';
2
2
  /**
3
- * Scan a text string for sensitive content. Three signal layers run:
4
- * 1. User-configured regex patterns from `config.redaction.patterns`
5
- * (the legacy MVP behavior).
6
- * 2. Structural detectors — exact token shapes for GitHub PATs, AWS
7
- * access keys, JWTs, etc. High precision; on by default.
8
- * 3. Entropy detector — flags high-entropy token-like substrings near
9
- * a sensitive keyword. Tunable, on by default.
3
+ * Scan a text string for sensitive content. Four independent signal layers
4
+ * run, each with its own enable-gate (S4 semantics, pln#623):
10
5
  *
11
- * In strict mode all signals escalate to `block`; otherwise `warn`.
6
+ * 1. Redaction patterns — user-configured regexes from
7
+ * `config.redaction.patterns`. Gate: `config.redaction.enabled` (whole
8
+ * scan short-circuits off when false). The legacy MVP behavior.
9
+ * 2. Structural detectors — exact token shapes for GitHub PATs, AWS access
10
+ * keys, JWTs, etc. High precision. Gate: `security.token_detection.enabled`
11
+ * (default on); individual detectors via `token_detection.detectors[id]`.
12
+ * 3. Entropy detector — high-Shannon-entropy token-like substrings near a
13
+ * secret keyword. Gate: `security.token_detection.entropy.enabled` (nested
14
+ * under the token_detection gate; default on).
15
+ * 4. Sensitive paths — literal mentions of `config.sensitive_paths` entries
16
+ * (`.env`, `secrets/`, …). Gate: `security.block_sensitive_paths` (default
17
+ * on).
18
+ *
19
+ * LEVEL (uniform across ALL four layers): a match surfaces as `warn`, and
20
+ * escalates to `block` when `security.strict_redaction` is true (mode: strict).
21
+ * Strict mode blocks every signal uniformly — there is no per-layer level
22
+ * override. Detected/redacted excerpts in messages are always irreversibly
23
+ * masked (see maskSecret); the redaction pattern itself is referenced by index
24
+ * and masked, never echoed.
12
25
  */
13
26
  export function scanText(text, config) {
14
27
  const warnings = [];
@@ -16,7 +29,7 @@ export function scanText(text, config) {
16
29
  return warnings;
17
30
  const isStrict = config.security?.strict_redaction ?? false;
18
31
  const level = isStrict ? 'block' : 'warn';
19
- for (const pattern of config.redaction.patterns) {
32
+ for (const [i, pattern] of config.redaction.patterns.entries()) {
20
33
  try {
21
34
  // Strip Python-style inline flags (?i) etc. since we always use 'i' flag
22
35
  const cleanPattern = pattern.replace(/^\(\?[gimsuy]+\)/g, '');
@@ -24,7 +37,9 @@ export function scanText(text, config) {
24
37
  if (re.test(text)) {
25
38
  warnings.push({
26
39
  level,
27
- message: `Possible sensitive content matching pattern '${pattern}' found in text`,
40
+ // The configured pattern may itself be a literal secret value, so
41
+ // it is referenced by index and masked, never echoed verbatim.
42
+ message: `Possible sensitive content matching redaction pattern #${i} ('${maskSecret(pattern)}') found in text`,
28
43
  });
29
44
  }
30
45
  }
@@ -61,7 +76,12 @@ export function scanText(text, config) {
61
76
  for (const sp of config.sensitive_paths) {
62
77
  if (text.includes(sp)) {
63
78
  warnings.push({
64
- level: 'warn',
79
+ // S3 (pln#623): the level is config-derived, not hardcoded. Like the
80
+ // three detector layers above, a sensitive-path match surfaces as a
81
+ // `warn` normally and escalates to `block` under strict_redaction —
82
+ // strict mode blocks EVERY signal, uniformly. `block_sensitive_paths`
83
+ // remains the enable-gate for this layer (default on).
84
+ level,
65
85
  message: `Sensitive path '${sp}' mentioned in text`,
66
86
  });
67
87
  }