@rmyndharis/aimhooman 0.1.0 → 0.1.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.
package/src/hook.mjs CHANGED
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process';
3
3
  import { homedir } from 'node:os';
4
4
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { GIT_TIMEOUT_MS } from './git-environment.mjs';
6
7
  import { newEngineWithDiagnostics } from './scan.mjs';
7
8
  import { openRepo, stagedEntries } from './gitx.mjs';
8
9
  import { applyExclude, patternsForRules } from './exclude.mjs';
@@ -101,7 +102,12 @@ function hookPreToolUse(input) {
101
102
  const executorCommand = command(input, executor);
102
103
  if (executorCommand === null) return unknownExecutorShape(input, executor.name);
103
104
  const rawParsed = parseGit(executorCommand);
104
- if (nonPosixExecutor(executor.name)) rawParsed.uncertainShell = true;
105
+ if (nonPosixExecutor(executor.name)) {
106
+ rawParsed.uncertainShell = true;
107
+ // The benign-pipeline classifier is POSIX-only; never exempt a non-POSIX
108
+ // shell line (pwsh/fish/cmd/nu) from opaque-commit-hiding treatment.
109
+ rawParsed.opaqueCommitHiding = true;
110
+ }
105
111
  if (executorTargetSyntaxUncertain(executor.name, executorCommand, rawParsed)
106
112
  && (rawParsed.addPaths.length > 0 || rawParsed.commands.some(isProtectedMutation))) {
107
113
  return emitDecision(
@@ -325,7 +331,11 @@ function hookPreToolUse(input) {
325
331
  }
326
332
  }
327
333
  let eng;
334
+ // diagnosticWarning is about the rule pack and drives the messages below;
335
+ // hygieneWarning is housekeeping that never touches the decision. Kept apart
336
+ // so the "invalid local pack skipped" wording stays true.
328
337
  let diagnosticWarning = '';
338
+ let hygieneWarning = '';
329
339
  try {
330
340
  const loaded = repo
331
341
  ? engineForPolicy(repo, policy, policy.head)
@@ -338,7 +348,6 @@ function hookPreToolUse(input) {
338
348
  return emitDecision('deny', `aimhooman strict policy could not load local rules: ${diagnosticWarning}`);
339
349
  }
340
350
  }
341
- if (repo) applyExclude(repo.excludeFile, patternsForRules(eng.rules));
342
351
  } catch (e) {
343
352
  const reason = e?.name === 'LocalOverridesError'
344
353
  ? `aimhooman cannot load local overrides: ${e.message}`
@@ -347,6 +356,17 @@ function hookPreToolUse(input) {
347
356
  if (profile === 'strict') return emitDecision('deny', reason);
348
357
  return emitDecision('allow', `${reason}; continuing because profile=${profile}`);
349
358
  }
359
+ // Refreshing the excludes is gitignore hygiene, not part of the verdict:
360
+ // pre-commit never writes them and still answers. Kept out of the block
361
+ // above so a read-only .git/info (CI checkout, read-only volume, a
362
+ // repository owned by another user) cannot decide what is allowed.
363
+ if (repo) {
364
+ try {
365
+ applyExclude(repo.excludeFile, patternsForRules(eng.rules));
366
+ } catch (e) {
367
+ hygieneWarning = `could not refresh ${repo.excludeFile}: ${e.message}`;
368
+ }
369
+ }
350
370
  // A strict policy cannot make a meaningful guarantee if Git's own guards
351
371
  // are explicitly bypassed. Deny before the shell can stage-and-commit in a
352
372
  // single command, when inspecting the future index would be impossible.
@@ -364,7 +384,7 @@ function hookPreToolUse(input) {
364
384
  const prefixBypass = parsed.commands.some((candidate) => (
365
385
  (candidate.verb === 'commit' || candidate.verb === 'unknown')
366
386
  && candidate.prefixRisk
367
- )) || (parsed.uncertainShell && parsed.commands.length === 0 && parsed.prefixRisk);
387
+ )) || (parsed.opaqueCommitHiding && parsed.commands.length === 0 && parsed.prefixRisk);
368
388
  const aliasBypass = parsed.commands.some((candidate) => (
369
389
  (candidate.verb === 'commit' || candidate.verb === 'unknown')
370
390
  && candidate.inlineAliasRisk
@@ -379,7 +399,13 @@ function hookPreToolUse(input) {
379
399
  const unmodelledPrefixReason =
380
400
  'aimhooman cannot verify the final staged snapshot or Git hooks after an earlier unmodelled command; run that command separately, then retry the commit.';
381
401
  const blocks = [];
382
- const potentialCommit = commit || parsed.uncertainShell;
402
+ // potentialCommit treats a command as leading to a commit. uncertainShell
403
+ // was too broad: it flagged any pipe, so a benign read-only pipeline
404
+ // (gh ... | tail) was scanned and denied as if it staged a secret.
405
+ // opaqueCommitHiding keeps every commit-hiding shape (subshells,
406
+ // substitution, script-feeds, code-executing/unlisted pipe segments) while
407
+ // excluding pipelines of known read-only commands.
408
+ const potentialCommit = commit || parsed.opaqueCommitHiding;
383
409
  if (potentialCommit && repo) {
384
410
  try {
385
411
  const entries = stagedEntries(repo);
@@ -457,6 +483,9 @@ function hookPreToolUse(input) {
457
483
  if (diagnosticWarning) {
458
484
  return emitDecision('allow', `aimhooman warning: ${diagnosticWarning}; invalid local pack skipped`);
459
485
  }
486
+ if (hygieneWarning) {
487
+ return emitDecision('allow', `aimhooman warning: ${hygieneWarning}`);
488
+ }
460
489
  return 0;
461
490
  }
462
491
 
@@ -484,10 +513,8 @@ function hookPreToolUse(input) {
484
513
  }
485
514
  const guarded = repo && !bypassed && installedHooks(repo).includes('pre-commit');
486
515
  const advisory = advisoryReason(blocks, guarded, bypassed ? bypassContext : '');
487
- return emitDecision(
488
- 'allow',
489
- diagnosticWarning ? `${advisory} Warning: ${diagnosticWarning}.` : advisory
490
- );
516
+ const notes = [diagnosticWarning, hygieneWarning].filter(Boolean).join('; ');
517
+ return emitDecision('allow', notes ? `${advisory} Warning: ${notes}.` : advisory);
491
518
  }
492
519
 
493
520
  function toolName(i) {
@@ -1055,11 +1082,17 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
1055
1082
  : commands.some((candidate) => candidate.verb === 'commit' && candidate.futureIndex)
1056
1083
  ? 'future-index'
1057
1084
  : 'direct';
1085
+ // opaqueCommitHiding narrows uncertainShell to uncertainty that can hide or
1086
+ // feed a commit (subshells, substitution, script-feeds, code-executing or
1087
+ // unlisted pipe segments). A bare pipeline of known read-only commands
1088
+ // (gh ... | tail) cannot, so it is not treated as a potential commit.
1089
+ const opaqueCommitHiding = uncertainShell && !benignReadOnlyPipeline(cmd);
1058
1090
  return {
1059
1091
  commit,
1060
1092
  noVerify,
1061
1093
  bypassHooks,
1062
1094
  uncertainShell,
1095
+ opaqueCommitHiding,
1063
1096
  classification,
1064
1097
  environmentRisk: unique(commands.flatMap((candidate) => candidate.environmentRisk || [])),
1065
1098
  prefixRisk,
@@ -1196,7 +1229,12 @@ function configuredPushReceiver(repo) {
1196
1229
  return execFileSync(
1197
1230
  'git',
1198
1231
  ['config', '--get-regexp', '^remote\\..*\\.receivepack$'],
1199
- { cwd: repo.root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
1232
+ {
1233
+ cwd: repo.root,
1234
+ encoding: 'utf8',
1235
+ stdio: ['ignore', 'pipe', 'ignore'],
1236
+ timeout: GIT_TIMEOUT_MS,
1237
+ },
1200
1238
  ).trim().length > 0;
1201
1239
  } catch (error) {
1202
1240
  if (error?.status === 1) return false;
@@ -1431,6 +1469,7 @@ function readGitAlias(cwd, name) {
1431
1469
  cwd,
1432
1470
  encoding: 'utf8',
1433
1471
  stdio: ['ignore', 'pipe', 'ignore'],
1472
+ timeout: GIT_TIMEOUT_MS,
1434
1473
  }).trim();
1435
1474
  } catch {
1436
1475
  return null;
@@ -1737,6 +1776,103 @@ function shellUnits(s) {
1737
1776
  return out;
1738
1777
  }
1739
1778
 
1779
+ // SAFE_PIPE_EXECUTABLES are commands permitted in a "benign read-only pipeline":
1780
+ // they take only data arguments, never run a sub-command, never execute code,
1781
+ // never write files, and cannot bypass Git hooks. Anything not listed is treated
1782
+ // as potentially able to hide or feed a commit (fail-closed). Deliberately
1783
+ // excluded: shells and interpreters, awk/sed/ed/vim, sqlite3/psql/dc/bc/octave
1784
+ // and other stdin-as-program readers, git, tee (writes files), env/xargs/find
1785
+ // (run sub-commands), and curl/wget (fetch arbitrary content to a pipe).
1786
+ const SAFE_PIPE_EXECUTABLES = new Set([
1787
+ 'cat', 'tac', 'head', 'tail', 'tr', 'cut', 'paste', 'fold', 'fmt', 'expand',
1788
+ 'unexpand', 'rev', 'nl', 'pr', 'column', 'sort', 'uniq', 'comm', 'tsort',
1789
+ 'shuf', 'wc', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'xxd', 'od',
1790
+ 'hexdump', 'base64', 'base32', 'md5sum', 'sha1sum', 'sha224sum', 'sha256sum',
1791
+ 'sha384sum', 'sha512sum', 'cksum', 'b2sum', 'sum', 'file', 'ls', 'exa', 'eza',
1792
+ 'lsd', 'tree', 'du', 'df', 'stat', 'date', 'whoami', 'id', 'uname',
1793
+ 'hostname', 'uptime', 'printenv', 'pwd', 'basename', 'dirname', 'realpath',
1794
+ 'seq', 'yes', 'echo', 'printf', 'test', 'true', 'false', 'jq', 'yq', 'bat',
1795
+ 'gh', '[',
1796
+ ]);
1797
+
1798
+ // containsOpaquePipeLexeme is a quote-aware scan for shell constructs that can
1799
+ // hide or feed a commit or subcommand: command substitution, subshells, brace
1800
+ // expansion/groups, script-feed redirects, and command separators. Output
1801
+ // redirects (>, >>) and fd-dups such as 2>&1 are intentionally NOT opaque: they
1802
+ // cannot feed code into a pipe sink. A bare & (background) is opaque unless it
1803
+ // is part of an fd-dup (>& / <&) or &&.
1804
+ function containsOpaquePipeLexeme(command) {
1805
+ let quote = '';
1806
+ let escaped = false;
1807
+ for (let i = 0; i < command.length; i += 1) {
1808
+ const char = command[i];
1809
+ const prev = command[i - 1];
1810
+ const next = command[i + 1];
1811
+ if (escaped) { escaped = false; continue; }
1812
+ if (char === '\\' && quote !== "'") { escaped = true; continue; }
1813
+ if (quote) {
1814
+ if (quote === '"' && (char === '`' || (char === '$' && next === '('))) return true;
1815
+ if (char === quote) quote = '';
1816
+ continue;
1817
+ }
1818
+ if (char === "'" || char === '"') { quote = char; continue; }
1819
+ if (char === '`' || char === '(' || char === ')' || char === '{' || char === '}') return true;
1820
+ if (char === '$' && (next === '(' || next === '{')) return true;
1821
+ if (char === '<' || char === ';') return true;
1822
+ if (char === '&' && next !== '&' && prev !== '&' && prev !== '>' && prev !== '<') return true;
1823
+ }
1824
+ return false;
1825
+ }
1826
+
1827
+ // splitPipeSegments splits a command on an unquoted pipe (|) that is not part
1828
+ // of ||. Unlike shellUnits it does not break on &, so 2>&1 stays within a
1829
+ // segment.
1830
+ function splitPipeSegments(command) {
1831
+ const segments = [];
1832
+ let segment = '';
1833
+ let quote = '';
1834
+ let escaped = false;
1835
+ for (let i = 0; i < command.length; i += 1) {
1836
+ const char = command[i];
1837
+ if (escaped) { segment += char; escaped = false; continue; }
1838
+ if (char === '\\' && quote !== "'") { segment += char; escaped = true; continue; }
1839
+ if (quote) { segment += char; if (char === quote) quote = ''; continue; }
1840
+ if (char === "'" || char === '"') { segment += char; quote = char; continue; }
1841
+ if (char === '|' && command[i + 1] !== '|') { segments.push(segment); segment = ''; continue; }
1842
+ segment += char;
1843
+ }
1844
+ segments.push(segment);
1845
+ return segments;
1846
+ }
1847
+
1848
+ // pipelineSegmentExecutable returns the shellExecutable basename of the first
1849
+ // non-environment-assignment token in a pipe segment, or null when the segment
1850
+ // has no command. A leading redirect token (e.g. 2>&1 before the command) is
1851
+ // treated as unlisted -> null -> not benign (fail-closed), which is safe.
1852
+ function pipelineSegmentExecutable(segment) {
1853
+ for (const word of shellWords(segment.trim())) {
1854
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) continue;
1855
+ return shellExecutable(word);
1856
+ }
1857
+ return null;
1858
+ }
1859
+
1860
+ // benignReadOnlyPipeline reports whether a command is a bare pipeline whose
1861
+ // every segment is a known non-code-executing, non-mutating command with no
1862
+ // opaque shell syntax. Such a pipe cannot hide or feed a commit, so it is not a
1863
+ // "potential commit" for the unmodelled-prefix guard. Fail-closed: any opaque
1864
+ // lexeme, unlisted executable, missing executable, or non-pipe returns false.
1865
+ function benignReadOnlyPipeline(command) {
1866
+ if (!command.includes('|')) return false;
1867
+ if (containsOpaquePipeLexeme(command)) return false;
1868
+ const segments = splitPipeSegments(command);
1869
+ if (segments.length < 2) return false;
1870
+ return segments.every((segment) => {
1871
+ const executable = pipelineSegmentExecutable(segment);
1872
+ return executable !== null && SAFE_PIPE_EXECUTABLES.has(executable);
1873
+ });
1874
+ }
1875
+
1740
1876
  function pipedShellCommitInvocations(source, initialCwd) {
1741
1877
  const units = shellUnits(source);
1742
1878
  const commits = [];
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { gitEnvironment } from './git-environment.mjs';
2
+ import { gitEnvironment, GIT_TIMEOUT_MS } from './git-environment.mjs';
3
3
 
4
4
  export const DEFAULT_SCAN_LIMITS = Object.freeze({
5
5
  maxFileBytes: 2 << 20,
@@ -112,6 +112,7 @@ function readObjects(repo, objectIds, expectedBytes = 0) {
112
112
  input: Buffer.from(unique.join('\n') + '\n'),
113
113
  encoding: 'buffer',
114
114
  maxBuffer: Math.max(2 * 1024 * 1024, expectedBytes + unique.length * 256 + 1024),
115
+ timeout: GIT_TIMEOUT_MS,
115
116
  });
116
117
  const objects = new Map();
117
118
  const failures = [];
@@ -49,6 +49,7 @@ export function scanMessage(repo, text, options = {}) {
49
49
  const { policy, head } = resolved;
50
50
  const loaded = engineForPolicy(repo, policy, options.overrideHead || head);
51
51
  const accumulator = createAccumulator(options.limits);
52
+ accumulator.addSkipped(loaded.skipped);
52
53
  accumulator.add(loaded.engine.checkMessage(text).map((finding) => decorate(finding, null, policy)));
53
54
  accumulator.addSkipped(loaded.engine.takeSkipped());
54
55
  return {
@@ -68,6 +69,7 @@ function scanIndex(repo, options) {
68
69
  const { rawPolicy, floor, policy, head, acknowledged } = resolveStagedPolicy(repo, options.explicitProfile);
69
70
  const loaded = engineForPolicy(repo, policy, options.overrideHead || head);
70
71
  const accumulator = createAccumulator(options.limits);
72
+ accumulator.addSkipped(loaded.skipped);
71
73
  let entries = kind === 'tracked' ? trackedEntries(repo) : stagedEntries(repo);
72
74
  const conflicts = unmergedPaths(repo);
73
75
  const diagnostics = [...loaded.diagnostics];
@@ -189,6 +191,7 @@ function scanCommit(repo, options) {
189
191
  reviewContexts,
190
192
  );
191
193
  const accumulator = createAccumulator(options.limits);
194
+ accumulator.addSkipped(loaded.skipped);
192
195
  const diagnostics = [...loaded.diagnostics];
193
196
 
194
197
  if (snapshot.shallowBoundary) {
@@ -261,6 +264,9 @@ function scanRange(repo, options) {
261
264
  // only constructs an Engine and applies overrides (see engineForPolicy).
262
265
  const preloaded = loadRulesWithDiagnostics(repo.stateDir);
263
266
  const preloadedOverrides = loadOverrides(repo.stateDir);
267
+ // Counted once for the range, not once per commit: the same packs back every
268
+ // commit's engine, so the per-commit loaded.skipped would inflate the tally.
269
+ accumulator.addSkipped(packSkipped(preloaded.errors));
264
270
 
265
271
  for (const commit of history.commits) {
266
272
  const changes = commitChanges(repo, commit.commit, commit.commit, commit.parents);
@@ -363,27 +369,38 @@ function scanEntryGroup(repo, engine, entries, policy, accumulator, options = {}
363
369
 
364
370
  function scanReviewPathChanges(engine, entries, policy, accumulator, options = {}) {
365
371
  for (const entry of entries) {
366
- const paths = [];
367
- if (entry.status === 'D' || entry.type === 'deleted') paths.push(entry.path);
368
- if (entry.status === 'R' && entry.sourcePath && entry.sourcePath !== entry.path) {
369
- paths.push(entry.sourcePath);
370
- }
371
- for (const path of paths) {
372
- const context = {
373
- objectId: null,
374
- mode: null,
375
- transition: options.transition ?? entry.commit,
376
- ...(options.allowMissingPolicy && path === '.aimhooman.json'
377
- ? { transientAllowRules: new Set(['generic.project-policy']) }
378
- : {}),
379
- };
380
- const findings = engine.checkPaths([path], context).filter((finding) => (
381
- finding.matchedRuleIds?.some((ruleId) => (
382
- ruleId === 'generic.agent-instructions' || ruleId === 'generic.project-policy'
383
- ))
384
- ));
385
- accumulator.add(findings.map((finding) => decorate(finding, entry, policy)));
386
- }
372
+ // A deleted path is gone, so only structural policy rules (agent
373
+ // instructions, project policy) can still matter: deleting a secret or a
374
+ // session file is hygiene, not a violation. A renamed-away path is
375
+ // different — its content survives under a new name, so a path-only
376
+ // secret (e.g. .env) must still fire or a `git mv` to a neutral name
377
+ // would slip past the destination scan, which only catches content-shaped
378
+ // secrets. That finding is reported on the destination path (where the
379
+ // bytes now live) so clean-profile repair unstages the blob that carries
380
+ // the secret rather than the old name.
381
+ const deleted = entry.status === 'D' || entry.type === 'deleted';
382
+ const renamed = entry.status === 'R' && entry.sourcePath && entry.sourcePath !== entry.path;
383
+ if (!deleted && !renamed) continue;
384
+ const sourcePath = deleted ? entry.path : entry.sourcePath;
385
+ const context = {
386
+ objectId: null,
387
+ mode: null,
388
+ transition: options.transition ?? entry.commit,
389
+ ...(options.allowMissingPolicy && sourcePath === '.aimhooman.json'
390
+ ? { transientAllowRules: new Set(['generic.project-policy']) }
391
+ : {}),
392
+ };
393
+ const findings = engine.checkPaths([sourcePath], context).filter((finding) => {
394
+ if (finding.matchedRuleIds?.some((ruleId) => (
395
+ ruleId === 'generic.agent-instructions' || ruleId === 'generic.project-policy'
396
+ ))) return true;
397
+ return renamed && finding.category === 'secret';
398
+ });
399
+ accumulator.add(findings.map((finding) => decorate(
400
+ renamed && finding.category === 'secret' ? { ...finding, path: entry.path } : finding,
401
+ entry,
402
+ policy,
403
+ )));
387
404
  }
388
405
  }
389
406
 
@@ -441,7 +458,14 @@ export function engineForPolicy(
441
458
  [...reviewedInstructions, ...reviewedPolicies],
442
459
  );
443
460
  const diagnostics = loaded.errors.map((error) => ({ level: 'warning', message: `${error.message}; pack skipped` }));
444
- return { engine: loaded.engine, diagnostics };
461
+ return { engine: loaded.engine, diagnostics, skipped: packSkipped(loaded.errors) };
462
+ }
463
+
464
+ // packSkipped turns rule-pack load failures into a counted skip reason. A pack
465
+ // that never loaded is a hole in coverage, not an empty result: strict throws
466
+ // above, and clean/compliance proceed but must not report a complete scan.
467
+ function packSkipped(errors) {
468
+ return errors.length ? { 'local-pack-error': errors.length } : {};
445
469
  }
446
470
 
447
471
  function reviewedEngineEntry(entry, ruleId, transition) {
@@ -578,6 +602,10 @@ function rangeReportPolicy(basePolicy, policies, usedStrictFloor, explicitProfil
578
602
  };
579
603
  }
580
604
 
605
+ // Skip reasons that mean a rule never ran, as opposed to a rule running and
606
+ // matching nothing. Either way the scan cannot claim to have covered its input.
607
+ const INCOMPLETE_SKIP_REASONS = new Set(['local-input-limit', 'local-pack-error']);
608
+
581
609
  function createAccumulator(limits = {}) {
582
610
  const effectiveLimits = { ...DEFAULT_SCAN_LIMITS, ...limits };
583
611
  const findings = [];
@@ -633,7 +661,7 @@ function createAccumulator(limits = {}) {
633
661
  addSkipped(skipped = {}) {
634
662
  for (const [reason, count] of Object.entries(skipped)) {
635
663
  stats.skipped[reason] = (stats.skipped[reason] || 0) + count;
636
- if (reason === 'local-input-limit') complete = false;
664
+ if (INCOMPLETE_SKIP_REASONS.has(reason)) complete = false;
637
665
  }
638
666
  },
639
667
  markIncomplete() {