phewsh 0.15.64 → 0.15.65

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.
@@ -27,6 +27,11 @@ const CLAUDE_SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
27
27
 
28
28
  const HOOK_START = { type: 'command', command: 'phewsh hook session-start' };
29
29
  const HOOK_END = { type: 'command', command: 'phewsh hook session-end' };
30
+ // Opt-in Decision Gate enforcement (separate from ambient context sync). The
31
+ // matcher scopes the hook to the tools the policy actually judges, so it never
32
+ // fires on harmless reads.
33
+ const HOOK_PRETOOL = { type: 'command', command: 'phewsh hook pre-tool' };
34
+ const PRETOOL_MATCHER = 'Write|Edit|MultiEdit|NotebookEdit|Bash';
30
35
 
31
36
  // ANSI helpers (256-color per cli/lib/ui.js palette rules)
32
37
  const b = (s) => `\x1b[1m${s}\x1b[0m`;
@@ -97,6 +102,35 @@ function removeClaudeHooks() {
97
102
  return removed;
98
103
  }
99
104
 
105
+ // ── Decision Gate enforcement (PreToolUse) — opt-in, reversible ──────────────
106
+ function preToolGateApplied() {
107
+ const s = loadClaudeSettings();
108
+ return !!s && hasHook(s, 'PreToolUse', HOOK_PRETOOL.command);
109
+ }
110
+
111
+ function enablePreToolGate() {
112
+ const settings = loadClaudeSettings() || {};
113
+ settings.hooks = settings.hooks || {};
114
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse || [];
115
+ if (hasHook(settings, 'PreToolUse', HOOK_PRETOOL.command)) return false;
116
+ settings.hooks.PreToolUse.push({ matcher: PRETOOL_MATCHER, hooks: [HOOK_PRETOOL] });
117
+ fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2));
118
+ return true;
119
+ }
120
+
121
+ function disablePreToolGate() {
122
+ const settings = loadClaudeSettings();
123
+ if (!settings?.hooks?.PreToolUse) return false;
124
+ const before = settings.hooks.PreToolUse.length;
125
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse
126
+ .map(e => ({ ...e, hooks: (e.hooks || []).filter(h => h.command !== HOOK_PRETOOL.command) }))
127
+ .filter(e => e.hooks.length > 0);
128
+ if ((settings.hooks.PreToolUse || []).length === 0) delete settings.hooks.PreToolUse;
129
+ const changed = before !== (settings.hooks.PreToolUse?.length ?? 0);
130
+ if (changed) fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2));
131
+ return changed;
132
+ }
133
+
100
134
  function showConsentScreen(harnesses) {
101
135
  console.log('');
102
136
  console.log(` ${b(cream('PHEWSH ambient'))} ${sage('— continuity without launching phewsh')}`);
@@ -365,3 +399,6 @@ async function main() {
365
399
 
366
400
  module.exports = main;
367
401
  module.exports.ensureAuto = ensureAuto;
402
+ module.exports.enablePreToolGate = enablePreToolGate;
403
+ module.exports.disablePreToolGate = disablePreToolGate;
404
+ module.exports.preToolGateApplied = preToolGateApplied;
package/commands/gate.js CHANGED
@@ -373,6 +373,7 @@ async function main() {
373
373
  if (subcommand === 'update' || subcommand === 'edit') { await update(); return; }
374
374
  if (subcommand === 'pause') { pause(); return; }
375
375
  if (subcommand === 'resume') { resume(); return; }
376
+ if (subcommand === 'enforce') { enforce(args[1]); return; }
376
377
  if (subcommand === 'reset') {
377
378
  let removed = false;
378
379
  const project = loadProjectJson();
@@ -391,6 +392,34 @@ async function main() {
391
392
  console.log(`\n Unknown: ${subcommand}. Run \`phewsh gate --help\`.\n`);
392
393
  }
393
394
 
395
+ // Opt-in deterministic enforcement: register/unregister the Claude Code
396
+ // PreToolUse hook that makes the Decision Gate act BEFORE a tool runs. Reversible,
397
+ // Claude-Code-only for now (it's the one provider with a real veto hook).
398
+ function enforce(action) {
399
+ let amb;
400
+ try { amb = require('./ambient'); } catch { console.log('\n Enforcement unavailable.\n'); return; }
401
+ const on = (action || 'status').toLowerCase();
402
+ if (on === 'on' || on === 'enable') {
403
+ const changed = amb.enablePreToolGate();
404
+ console.log(changed
405
+ ? `\n ${green('●')} Gate enforcement ${green('ON')} — Claude Code asks/denies on protected-path writes and high-blast-radius commands before they run.\n ${g('Reversible:')} phewsh gate enforce off\n`
406
+ : `\n Gate enforcement already on.\n`);
407
+ return;
408
+ }
409
+ if (on === 'off' || on === 'disable') {
410
+ const changed = amb.disablePreToolGate();
411
+ console.log(changed ? `\n Gate enforcement ${yellow('OFF')} — PreToolUse hook removed.\n` : `\n Gate enforcement was not on.\n`);
412
+ return;
413
+ }
414
+ // status
415
+ const applied = amb.preToolGateApplied();
416
+ console.log(`\n Gate enforcement: ${applied ? green('ON') : g('off')} ${g('(Claude Code PreToolUse)')}`);
417
+ console.log(` ${g('Turn on:')} phewsh gate enforce on ${g('· off:')} phewsh gate enforce off`);
418
+ console.log(` ${g('What it does: deny writes to protected paths (.env, keys, .git/…),')}`);
419
+ console.log(` ${g('ask before high-blast-radius shell (rm -rf, force-push, sudo…).')}`);
420
+ console.log(` ${g('Opt-in, local-only, fail-open. Other tools: advisory only for now.')}\n`);
421
+ }
422
+
394
423
  module.exports = main;
395
424
 
396
425
  main().catch(err => {
package/commands/hook.js CHANGED
@@ -163,10 +163,63 @@ function sessionEnd() {
163
163
  setTimeout(() => { appendBreadcrumb('session-end'); process.exit(0); }, 1500);
164
164
  }
165
165
 
166
+ // Load the Decision Gate context (constraints + protected files) for the project
167
+ // being worked in. Best-effort: missing/garbled → empty context (gate allows).
168
+ function loadGateContext(cwd) {
169
+ try {
170
+ const pj = JSON.parse(fs.readFileSync(path.join(cwd, '.intent', 'project.json'), 'utf-8'));
171
+ const dg = pj.decisionGate || {};
172
+ return {
173
+ constraints: dg.constraints || {},
174
+ protectedFiles: dg.protectedFiles || pj.protectedFiles || [],
175
+ };
176
+ } catch { return { constraints: {}, protectedFiles: [] }; }
177
+ }
178
+
179
+ // PreToolUse adapter — the Decision Gate acting BEFORE a tool runs. Reads Claude
180
+ // Code's PreToolUse JSON from stdin, evaluates against the project's constraints
181
+ // + protected files, and emits a permission decision. FAIL-SAFE: any error, no
182
+ // stdin, or no policy hit → exit 0 (allow). A broken gate must never trap you.
183
+ // It logs only a redacted decision line (never the tool payload).
184
+ function preTool() {
185
+ try {
186
+ if (process.stdin.isTTY) process.exit(0); // not a real hook invocation
187
+ const { evaluateAction, auditLine } = require('../lib/gate-policy');
188
+ let raw = '';
189
+ try { raw = fs.readFileSync(0, 'utf-8'); } catch { process.exit(0); }
190
+ let payload = {};
191
+ try { payload = JSON.parse(raw || '{}'); } catch { process.exit(0); }
192
+
193
+ const cwd = payload.cwd || process.cwd();
194
+ const envelope = {
195
+ toolName: payload.tool_name || payload.toolName,
196
+ toolInput: payload.tool_input || payload.toolInput || {},
197
+ ...loadGateContext(cwd),
198
+ };
199
+ let result = { decision: 'allow', reason: '' };
200
+ try { result = evaluateAction(envelope); } catch { process.exit(0); }
201
+
202
+ if (result.decision !== 'allow') {
203
+ try { appendBreadcrumb('pre-tool', { decision: result.decision, action: auditLine(envelope, result) }); } catch { /* audit is best-effort */ }
204
+ process.stdout.write(JSON.stringify({
205
+ hookSpecificOutput: {
206
+ hookEventName: 'PreToolUse',
207
+ permissionDecision: result.decision, // 'deny' | 'ask'
208
+ permissionDecisionReason: result.reason,
209
+ },
210
+ }));
211
+ }
212
+ process.exit(0);
213
+ } catch {
214
+ process.exit(0); // fail open, always
215
+ }
216
+ }
217
+
166
218
  function main() {
167
219
  const event = process.argv[3];
168
220
  if (event === 'session-start') return sessionStart();
169
221
  if (event === 'session-end') return sessionEnd();
222
+ if (event === 'pre-tool') return preTool();
170
223
  // Unknown event: exit silently — hooks must never error the host tool.
171
224
  process.exit(0);
172
225
  }
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ // Deterministic pre-action policy — the enforcement core of the Decision Gate.
4
+ //
5
+ // Pure and provider-neutral: given an action envelope it returns a decision.
6
+ // No I/O, no provider coupling — the hook adapter (commands/hook.js pre-tool)
7
+ // feeds it the action and renders the provider-specific response. This is the
8
+ // "deterministic enforcement" layer from cli/docs/pre-action-architecture.md;
9
+ // it does NOT replace the Decision Gate, it is how the gate acts before a tool
10
+ // runs. Decisions: 'allow' | 'ask' (require human) | 'deny' (block).
11
+ //
12
+ // Design rules: fail OPEN (an empty/garbled envelope returns allow — a policy
13
+ // must never trap the user), and never inspect file CONTENTS (we judge the
14
+ // action's target/shape, not your code or secrets).
15
+
16
+ const path = require('path');
17
+
18
+ // Tools that write/modify the filesystem — common names across providers.
19
+ const WRITE_TOOLS = new Set([
20
+ 'Write', 'Edit', 'MultiEdit', 'NotebookEdit',
21
+ 'create_file', 'apply_patch', 'str_replace_editor', 'edit_file',
22
+ ]);
23
+
24
+ // Shell/command tools.
25
+ const SHELL_TOOLS = new Set(['Bash', 'shell', 'run_command', 'run_terminal_cmd']);
26
+
27
+ // Paths an agent should never silently write — credentials, keys, VCS internals.
28
+ const DEFAULT_PROTECTED = [
29
+ '.env', '.env.*', '*.pem', '*.key', 'id_rsa', 'id_ed25519',
30
+ '.git/', '.npmrc', '.aws/', '.ssh/', '.phewsh/config.json',
31
+ ];
32
+
33
+ // High-blast-radius shell patterns that warrant a human OK.
34
+ const DESTRUCTIVE = [
35
+ { re: /\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r/, label: 'recursive force delete' },
36
+ { re: /\bgit\s+push\b[^\n]*--force/, label: 'git force-push' },
37
+ { re: /\bgit\s+reset\s+--hard\b/, label: 'git hard reset' },
38
+ { re: /\bDROP\s+(TABLE|DATABASE)\b/i, label: 'SQL DROP' },
39
+ { re: /\bsudo\b/, label: 'sudo' },
40
+ { re: /\bchmod\s+-R\b/, label: 'recursive chmod' },
41
+ { re: /\bmkfs\b|\bdd\s+if=/, label: 'disk write' },
42
+ { re: /\bcurl\b[^\n]*\|\s*(sh|bash)\b|\bwget\b[^\n]*\|\s*(sh|bash)\b/, label: 'pipe-to-shell' },
43
+ ];
44
+
45
+ function globToRegExp(glob) {
46
+ const esc = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
47
+ return new RegExp('^' + esc + '$');
48
+ }
49
+
50
+ // Does `target` match any protected entry? Directory entries (trailing '/')
51
+ // match anywhere in the path; file globs match the basename or full path.
52
+ function isProtected(target, protectedList) {
53
+ if (!target || typeof target !== 'string') return false;
54
+ const norm = target.replace(/^\.\//, '');
55
+ const base = path.basename(norm);
56
+ return protectedList.some(p => {
57
+ if (p.endsWith('/')) return norm.split('/').includes(p.slice(0, -1)) || norm.startsWith(p);
58
+ const re = globToRegExp(p);
59
+ return re.test(base) || re.test(norm);
60
+ });
61
+ }
62
+
63
+ function writeTarget(toolInput = {}) {
64
+ return toolInput.file_path || toolInput.path || toolInput.notebook_path
65
+ || toolInput.filename || toolInput.target_file || null;
66
+ }
67
+
68
+ // envelope: { toolName, toolInput, constraints, protectedFiles }
69
+ function evaluateAction(envelope = {}) {
70
+ try {
71
+ const { toolName, toolInput = {}, constraints = {}, protectedFiles = [] } = envelope;
72
+ if (!toolName) return { decision: 'allow', reason: '' };
73
+ const protectedList = [...DEFAULT_PROTECTED, ...(Array.isArray(protectedFiles) ? protectedFiles : [])];
74
+
75
+ // 1. Writes to a protected path → deny (only a deliberate human act should).
76
+ if (WRITE_TOOLS.has(toolName)) {
77
+ const target = writeTarget(toolInput);
78
+ if (isProtected(target, protectedList)) {
79
+ return { decision: 'deny', reason: `phewsh gate: ${toolName} targets a protected path (${target}). Credentials/keys/VCS internals are off-limits to automated edits.` };
80
+ }
81
+ }
82
+
83
+ // 2. High-blast-radius shell → ask (require human confirmation).
84
+ if (SHELL_TOOLS.has(toolName)) {
85
+ const cmd = String(toolInput.command || toolInput.cmd || '');
86
+ const hit = DESTRUCTIVE.find(d => d.re.test(cmd));
87
+ if (hit) {
88
+ return { decision: 'ask', reason: `phewsh gate: high-blast-radius command (${hit.label}) — confirm before it runs.` };
89
+ }
90
+ }
91
+
92
+ // 3. Strict autonomy asks before any write. "delegated"/"guided" do not —
93
+ // that would turn every edit into ceremony.
94
+ const autonomy = String(constraints.autonomy || '').toLowerCase();
95
+ if ((autonomy === 'manual' || autonomy === 'review') && WRITE_TOOLS.has(toolName)) {
96
+ return { decision: 'ask', reason: `phewsh gate: your autonomy is "${autonomy}" — confirm this ${toolName} before it runs.` };
97
+ }
98
+
99
+ return { decision: 'allow', reason: '' };
100
+ } catch {
101
+ return { decision: 'allow', reason: '' }; // fail OPEN: never trap the user
102
+ }
103
+ }
104
+
105
+ // A redacted one-line audit record — decision + target shape only, never the
106
+ // tool payload (which may contain code or secrets).
107
+ function auditLine(envelope = {}, result = {}) {
108
+ const target = WRITE_TOOLS.has(envelope.toolName)
109
+ ? writeTarget(envelope.toolInput || {})
110
+ : (SHELL_TOOLS.has(envelope.toolName) ? '<command>' : '');
111
+ return `${result.decision || 'allow'} ${envelope.toolName || '?'}${target ? ' ' + target : ''}`;
112
+ }
113
+
114
+ module.exports = {
115
+ evaluateAction, isProtected, auditLine, writeTarget,
116
+ WRITE_TOOLS, SHELL_TOOLS, DEFAULT_PROTECTED, DESTRUCTIVE,
117
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.15.64",
3
+ "version": "0.15.65",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"