wendkeep 0.68.1 → 0.68.6

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.
@@ -1,86 +1,20 @@
1
1
  #!/usr/bin/env node
2
- // PreToolUse hook (matcher: Bash). O gate mecânico do loop a2 no ponto de execução:
3
- // R1 `wendkeep|wk change archive --force` vindo do AGENTE é negado (deny). Gate vermelho
4
- // significa trabalho pendente; --force é decisão do usuário (escape: WENDKEEP_ALLOW_FORCE=1
5
- // no ambiente do processo env inline no texto do comando NÃO conta).
6
- // R2 — `git commit` com change ativa E (--no-verify OU sensor crítico vermelho) vira `ask`
7
- // (o usuário decide com 1 clique; falso-positivo custa pouco).
8
- // Fast-path: comando sem wendkeep/wk/git sai sem NENHUM I/O. Ausência normal continua
9
- // fail-open; corrupção do binding é diagnóstico visível e fail-closed.
2
+ // PreToolUse guard. R1 protects the explicit archive --force decision. The project-scope
3
+ // policy protects mutable Git/filesystem tools before they run, including Codex's raw-string
4
+ // and object-shaped tool_input variants. Host adapters never emit `ask` for Codex: the Codex
5
+ // contract accepts `deny` in PreToolUse, while Claude may still use `ask` for its own approval UI.
10
6
  import { pathToFileURL } from 'node:url';
11
- import { readHookInput, writeHookOutput } from './obsidian-common.mjs';
7
+ import { readHookInput, readSessionRegistry, writeHookOutput } from './obsidian-common.mjs';
12
8
  import { activeChange, quickGateState } from './change-core.mjs';
13
9
  import { hookProfilePolicy, resolveHookOperatingProfile } from './operating-profile-runtime.mjs';
14
10
  import { isProjectVaultIntegrityError } from '../src/project-vault.mjs';
15
-
16
- const WK_EXECUTABLES = new Set([
17
- 'wendkeep', 'wendkeep.cmd', 'wendkeep.exe', 'wendkeep.ps1', 'wendkeep.mjs',
18
- 'wk', 'wk.cmd', 'wk.exe', 'wk.ps1',
19
- ]);
20
- const NODE_EXECUTABLES = new Set(['node', 'node.exe']);
21
- const NPX_EXECUTABLES = new Set(['npx', 'npx.cmd', 'npx.exe']);
22
- const GIT_EXECUTABLES = new Set(['git', 'git.exe', 'git.cmd']);
23
-
24
- function shellSegments(command) {
25
- const tokens = String(command || '').match(/"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|&&|\|\||[;|\n]|&|[^\s;&|]+/g) || [];
26
- const segments = [];
27
- let current = [];
28
- const flush = () => {
29
- if (current.length) segments.push(current);
30
- current = [];
31
- };
32
- for (const token of tokens) {
33
- if (['&&', '||', ';', '|', '\n'].includes(token) || (token === '&' && current.length)) {
34
- flush();
35
- continue;
36
- }
37
- current.push(token);
38
- }
39
- flush();
40
- return segments;
41
- }
42
-
43
- function unquote(token) {
44
- const value = String(token || '');
45
- if (value.length >= 2 && ((value[0] === '"' && value.at(-1) === '"')
46
- || (value[0] === "'" && value.at(-1) === "'"))) return value.slice(1, -1);
47
- return value;
48
- }
49
-
50
- function executableName(token) {
51
- return unquote(token).replaceAll('\\', '/').split('/').at(-1).toLowerCase();
52
- }
53
-
54
- function invocationOf(segment) {
55
- let index = 0;
56
- while (segment[index] === '&' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(segment[index] || '')) index += 1;
57
- const executable = executableName(segment[index]);
58
- if (WK_EXECUTABLES.has(executable)) {
59
- return { kind: 'wendkeep', args: segment.slice(index + 1).map(unquote) };
60
- }
61
- if (NODE_EXECUTABLES.has(executable)) {
62
- let scriptIndex = index + 1;
63
- while (String(segment[scriptIndex] || '').startsWith('-')) scriptIndex += 1;
64
- if (executableName(segment[scriptIndex]) === 'wendkeep.mjs') {
65
- return { kind: 'wendkeep', args: segment.slice(scriptIndex + 1).map(unquote) };
66
- }
67
- }
68
- if (NPX_EXECUTABLES.has(executable)) {
69
- let packageIndex = index + 1;
70
- while (String(segment[packageIndex] || '').startsWith('-')) packageIndex += 1;
71
- if (WK_EXECUTABLES.has(executableName(segment[packageIndex]))) {
72
- return { kind: 'wendkeep', args: segment.slice(packageIndex + 1).map(unquote) };
73
- }
74
- }
75
- if (GIT_EXECUTABLES.has(executable)) {
76
- return { kind: 'git', args: segment.slice(index + 1).map(unquote) };
77
- }
78
- return null;
79
- }
80
-
81
- function commandInvocations(command) {
82
- return shellSegments(command).map(invocationOf).filter(Boolean);
83
- }
11
+ import {
12
+ captureProjectScope,
13
+ commandHasUnprovenTarget,
14
+ extractToolCommand,
15
+ scopeDecision,
16
+ scopeForRegistry,
17
+ } from './project-scope.mjs';
84
18
 
85
19
  function bindingFailureDecision(diagnostic) {
86
20
  const code = diagnostic?.code || 'WENDKEEP_VAULT_CONFIG_INVALID';
@@ -92,54 +26,150 @@ function bindingFailureDecision(diagnostic) {
92
26
  };
93
27
  }
94
28
 
95
- export function guardDecision(command, { vaultBase, env = process.env, profile = 'GOVERN' } = {}) {
29
+ function hostFor({ host, provider } = {}) {
30
+ if (host) return host;
31
+ if (provider === 'codex') return 'codex';
32
+ // Pure callers historically represented Claude's approval UI without a provider field.
33
+ return 'claude';
34
+ }
35
+
36
+ function scopeGuardDecision(command, {
37
+ input = {},
38
+ host,
39
+ provider,
40
+ expectedScope,
41
+ actualScope,
42
+ activeSessions = [],
43
+ projectRoot = '',
44
+ projectId = '',
45
+ sessionId = '',
46
+ } = {}) {
47
+ if (!expectedScope && input?.project_scope) expectedScope = input.project_scope;
48
+ if (!expectedScope && input?.projectScope) expectedScope = input.projectScope;
49
+ const actual = actualScope || captureProjectScope({
50
+ input,
51
+ projectRoot,
52
+ projectId,
53
+ provider,
54
+ sessionId,
55
+ });
56
+ const commandTargetKnown = !commandHasUnprovenTarget(command);
57
+ return scopeDecision({
58
+ command,
59
+ input,
60
+ expectedScope,
61
+ actualScope: actual,
62
+ host: hostFor({ host, provider }),
63
+ commandTargetKnown,
64
+ activeSessions,
65
+ currentSessionId: sessionId,
66
+ });
67
+ }
68
+
69
+ export function guardDecision(command, {
70
+ vaultBase,
71
+ env = process.env,
72
+ profile = 'GOVERN',
73
+ input = {},
74
+ host,
75
+ provider,
76
+ expectedScope,
77
+ actualScope,
78
+ projectRoot = '',
79
+ projectId = '',
80
+ sessionId = '',
81
+ } = {}) {
96
82
  if (!hookProfilePolicy(profile).harness) return null;
97
83
  const cmd = String(command || '');
98
- const invocations = commandInvocations(cmd);
99
- if (!invocations.length) return null; // fast-path: parsing puro, zero I/O para o caso comum
100
84
 
101
- // R1: archive --force — parser puro, ainda sem I/O. Reason fala com o AGENTE (deny).
102
- const forcedArchive = invocations.find(({ kind, args }) => kind === 'wendkeep'
103
- && args[0]?.toLowerCase() === 'change'
104
- && args[1]?.toLowerCase() === 'archive'
105
- && args.some((arg) => /^--force(?:=|$)/i.test(arg)));
106
- if (forcedArchive) {
107
- if (env.WENDKEEP_ALLOW_FORCE === '1') return null;
108
- return {
109
- permissionDecision: 'deny',
110
- permissionDecisionReason: '`change archive --force` é decisão do usuário, não sua. Gate vermelho = trabalho pendente: rode `wendkeep change status` e conclua as tarefas, ou `wendkeep change abandon <slug>` se a change não vai adiante. Se o usuário pediu o force explicitamente, peça a ele para rodar com WENDKEEP_ALLOW_FORCE=1.',
111
- };
85
+ // R1: archive --force — parser/policy only, no scope is needed to deny the bypass.
86
+ if (/\b(?:wendkeep|wk)\b.*\bchange\s+archive\b.*--force(?:=|\b)/i.test(cmd)
87
+ || /(?:wendkeep\.mjs|wendkeep\.cmd)\s+change\s+archive\b.*--force(?:=|\b)/i.test(cmd)) {
88
+ if (env.WENDKEEP_ALLOW_FORCE !== '1') {
89
+ return {
90
+ permissionDecision: 'deny',
91
+ permissionDecisionReason: '`change archive --force` é decisão do usuário, não sua. Gate vermelho = trabalho pendente: rode `wendkeep change status` e conclua as tarefas, ou `wendkeep change abandon <slug>` se a change não vai adiante. Se o usuário pediu o force explicitamente, peça a ele para rodar com WENDKEEP_ALLOW_FORCE=1.',
92
+ };
93
+ }
112
94
  }
113
95
 
114
- // R2: git commit — 1ª leitura de fs só acontece aqui. Reason fala com o USUÁRIO (ask).
115
- const gitCommit = invocations.find(({ kind, args }) => kind === 'git'
116
- && args.some((arg) => arg.toLowerCase() === 'commit'));
117
- if (gitCommit) {
96
+ const scoped = scopeGuardDecision(cmd, {
97
+ input,
98
+ host,
99
+ provider,
100
+ expectedScope,
101
+ actualScope,
102
+ projectRoot,
103
+ projectId,
104
+ sessionId,
105
+ });
106
+ if (scoped) return scoped;
107
+
108
+ // R2: commit with an active change and a bypass/red sensor. This branch is reached only
109
+ // after the project scope is proven. Codex gets deny because PreToolUse cannot ask.
110
+ if (/\bgit(?:\.exe|\.cmd)?\b[^;&|\n]*\bcommit\b/i.test(cmd)) {
118
111
  const slug = activeChange(vaultBase);
119
112
  if (!slug) return null;
120
- const noVerify = gitCommit.args.some((arg) => /^--no-verify(?:=|$)/i.test(arg));
113
+ const noVerify = /(?:^|\s)--no-verify(?:=|\s|$)/i.test(cmd);
121
114
  const gate = noVerify ? null : quickGateState(vaultBase);
122
115
  if (noVerify || (gate && gate.redCritical)) {
116
+ const selectedHost = hostFor({ host, provider });
117
+ const reason = noVerify
118
+ ? `git commit --no-verify com a change "${slug}" ativa — commitar pulando os hooks?`
119
+ : `A change ativa "${slug}" tem sensor crítico vermelho (wendkeep verify falhou). Commitar mesmo assim?`;
123
120
  return {
124
- permissionDecision: 'ask',
125
- permissionDecisionReason: noVerify
126
- ? `git commit --no-verify com a change "${slug}" ativa commitar pulando os hooks?`
127
- : `A change ativa "${slug}" tem sensor crítico vermelho (wendkeep verify falhou). Commitar mesmo assim?`,
121
+ permissionDecision: selectedHost === 'codex' ? 'deny' : 'ask',
122
+ permissionDecisionReason: selectedHost === 'codex'
123
+ ? `${reason} O Codex exige uma autorização explícita fora do PreToolUse; execute a ação conscientemente após corrigir o gate.`
124
+ : reason,
128
125
  };
129
126
  }
130
127
  }
131
128
  return null;
132
129
  }
133
130
 
131
+ function runtimeScope(runtime, input) {
132
+ const identity = runtime.identity || {};
133
+ const sessionId = identity.canonicalConversationId || input.session_id || input.sessionId || '';
134
+ const expected = runtime.entry?.project_scope
135
+ ? {
136
+ ...runtime.entry.project_scope,
137
+ conflict: runtime.entry.project_scope_conflict === true,
138
+ }
139
+ : null;
140
+ const actual = captureProjectScope({
141
+ input,
142
+ projectRoot: runtime.projectRoot || runtime.resolution?.projectRoot || '',
143
+ projectId: runtime.resolution?.projectId || '',
144
+ provider: identity.provider || 'codex',
145
+ sessionId,
146
+ });
147
+ const registry = readSessionRegistry(runtime.vaultBase);
148
+ const activeSessions = Object.entries(registry.sessions || {})
149
+ .filter(([, entry]) => entry?.status === 'active');
150
+ return { expectedScope: expected, actualScope: actual, activeSessions, sessionId };
151
+ }
152
+
134
153
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
135
154
  try {
136
155
  const input = readHookInput();
137
156
  const runtime = resolveHookOperatingProfile({ input });
157
+ const command = extractToolCommand(input);
158
+ const scope = runtime.bindingError ? null : runtimeScope(runtime, input);
138
159
  const d = runtime.bindingError
139
160
  ? bindingFailureDecision(runtime.bindingError)
140
- : guardDecision(input.tool_input?.command, {
161
+ : guardDecision(command, {
141
162
  vaultBase: runtime.vaultBase,
142
163
  profile: runtime.profile,
164
+ input,
165
+ provider: runtime.identity?.provider,
166
+ host: runtime.identity?.provider,
167
+ expectedScope: scope.expectedScope,
168
+ actualScope: scope.actualScope,
169
+ activeSessions: scope.activeSessions,
170
+ projectRoot: runtime.projectRoot,
171
+ projectId: runtime.resolution?.projectId,
172
+ sessionId: scope.sessionId,
143
173
  });
144
174
  if (d) writeHookOutput({ hookSpecificOutput: { hookEventName: 'PreToolUse', ...d } });
145
175
  // allow implícito: exit 0 sem output
@@ -152,7 +182,16 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
152
182
  },
153
183
  });
154
184
  } else {
155
- writeHookOutput({}); // ausência/erro não-corrupto preserva compatibilidade fail-open
185
+ // A malformed hook envelope cannot prove the target. Return a visible deny rather than
186
+ // silently permitting a mutable tool with unknown project scope.
187
+ writeHookOutput({
188
+ hookSpecificOutput: {
189
+ hookEventName: 'PreToolUse',
190
+ ...bindingFailureDecision({ code: 'WENDKEEP_SCOPE_INPUT_INVALID', message: error.message }),
191
+ },
192
+ });
156
193
  }
157
194
  }
158
195
  }
196
+
197
+ export { scopeForRegistry };
@@ -31,6 +31,16 @@ function gitOptional(cwd, args, { spawn = spawnSync } = {}) {
31
31
  return result.status === 0 ? String(result.stdout || '').trim() : '';
32
32
  }
33
33
 
34
+ function parseGitConfigPaths(output) {
35
+ const values = new Map();
36
+ for (const line of String(output || '').split(/\r?\n/).filter(Boolean)) {
37
+ const separator = line.search(/\s/);
38
+ if (separator < 0) continue;
39
+ values.set(line.slice(0, separator).toLowerCase(), line.slice(separator + 1).trim());
40
+ }
41
+ return values;
42
+ }
43
+
34
44
  function fingerprintFsEntry(path, unsafePaths = [], label = '') {
35
45
  let stat;
36
46
  try {
@@ -83,10 +93,16 @@ function fingerprintGitIndirection(path, unsafePaths) {
83
93
  }
84
94
 
85
95
  function gitMetadataSnapshot(root, options = {}) {
86
- const gitDir = resolveGitPath(root, git(root, ['rev-parse', '--git-dir'], options));
87
- const commonDir = resolveGitPath(root, git(root, ['rev-parse', '--git-common-dir'], options));
88
- const configuredHooks = gitOptional(root, ['config', '--path', '--get', 'core.hooksPath'], options);
89
- const configuredExcludes = gitOptional(root, ['config', '--path', '--get', 'core.excludesFile'], options);
96
+ const [gitDirValue, commonDirValue] = String(git(root, [
97
+ 'rev-parse', '--git-dir', '--git-common-dir',
98
+ ], options)).trim().split(/\r?\n/);
99
+ const gitDir = resolveGitPath(root, gitDirValue);
100
+ const commonDir = resolveGitPath(root, commonDirValue);
101
+ const configuredPaths = parseGitConfigPaths(gitOptional(root, [
102
+ 'config', '--path', '--get-regexp', '^core\\.(hooksPath|excludesFile)$',
103
+ ], options));
104
+ const configuredHooks = configuredPaths.get('core.hookspath') || '';
105
+ const configuredExcludes = configuredPaths.get('core.excludesfile') || '';
90
106
  const hooksPath = configuredHooks ? resolveGitPath(root, configuredHooks) : join(commonDir, 'hooks');
91
107
  const unsafePaths = [];
92
108
  const targets = [
@@ -508,7 +524,10 @@ function worktreeFingerprint(root, relPath, options = {}, diagnostics = {
508
524
 
509
525
  export function captureGitSnapshot(projectRoot, options = {}) {
510
526
  const start = resolve(projectRoot);
511
- const root = realpathSync.native(resolve(String(git(start, ['rev-parse', '--show-toplevel'], options)).trim()));
527
+ const [rootValue, headValue] = String(git(start, [
528
+ 'rev-parse', '--show-toplevel', '--verify', 'HEAD',
529
+ ], options)).trim().split(/\r?\n/);
530
+ const root = realpathSync.native(resolve(rootValue));
512
531
  const canonicalRoot = canonicalFsPath(root);
513
532
  const expectedRoot = options._expectedGitlinkRoot ? canonicalFsPath(options._expectedGitlinkRoot) : '';
514
533
  if (expectedRoot && canonicalRoot !== expectedRoot) {
@@ -525,7 +544,7 @@ export function captureGitSnapshot(projectRoot, options = {}) {
525
544
  }
526
545
  seen.add(canonicalRoot);
527
546
  options = { ...options, _gitlinkDepth: depth, _gitlinkSeen: [...seen] };
528
- const head = String(git(root, ['rev-parse', '--verify', 'HEAD'], options)).trim();
547
+ const head = String(headValue || '').trim();
529
548
  const status = git(root, ['status', '--porcelain=v2', '-z', '--untracked-files=all'], { ...options, binary: true });
530
549
  const fingerprints = {};
531
550
  const dirtyPaths = new Set();
@@ -565,6 +565,46 @@ export function applyStopActivation(registry, stop = {}) {
565
565
  return stopResult(next, 'applied', true);
566
566
  }
567
567
 
568
+ // Finalization is a second causal transition: Stop first acknowledges the turn and publishes
569
+ // observability while the activation is still addressable, then closes that same activation. The
570
+ // explicit id check prevents a late Stop from closing a newer activation opened meanwhile.
571
+ export function closeSessionActivation(registry, stop = {}) {
572
+ const next = cloneRegistry(registry);
573
+ const sessionId = stop.session_id || stop.canonical_session_id || '';
574
+ const current = next.sessions[sessionId];
575
+ const requestedActivationId = String(stop.activation_id || '');
576
+ const activeId = String(current?.active_activation_id || '');
577
+ const stopTurnId = String(stop.turn_id || '');
578
+ if (!current || !requestedActivationId) return stopResult(next, 'ambiguous');
579
+ if (!activeId && current.status === 'done' && current.last_turn_id === stopTurnId) {
580
+ return stopResult(next, 'duplicate');
581
+ }
582
+ if (!activeId || activeId !== requestedActivationId) return stopResult(next, 'superseded');
583
+
584
+ const active = current.activations?.[activeId];
585
+ if (!active || active.status !== 'active') return stopResult(next, 'ambiguous');
586
+
587
+ const endedAt = String(stop.ended_at || '');
588
+ const activations = {
589
+ ...(current.activations || {}),
590
+ [activeId]: {
591
+ ...active,
592
+ status: 'done',
593
+ ...(endedAt ? { ended_at: endedAt } : {}),
594
+ ...(stopTurnId ? { last_stop_turn_id: stopTurnId } : {}),
595
+ },
596
+ };
597
+ next.sessions[sessionId] = {
598
+ ...current,
599
+ status: 'done',
600
+ active_activation_id: '',
601
+ ...(endedAt ? { ended_at: endedAt } : {}),
602
+ ...(stopTurnId ? { last_turn_id: stopTurnId } : {}),
603
+ activations,
604
+ };
605
+ return stopResult(next, 'finalized', true);
606
+ }
607
+
568
608
  // Remove one registry entry, but ONLY when its transcript matches the given path — this is
569
609
  // self-healing for entries wendkeep itself mis-wrote (a subagent rollout registered as a
570
610
  // top-level session by import <=0.46.1), never generic registry cleanup. An entry with the