tillerhand 3.49.10 → 3.49.12

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.
@@ -11,11 +11,14 @@ import { readInput, decide, isMain, findRoot } from './shared.mjs';
11
11
  import { t } from './lang.mjs';
12
12
  import { isTestFile, isTestConfig } from './test-guard.mjs';
13
13
  import { readLock, readPlan, allowedInLock, inScope, realPath, relPath, protectedRule } from './freeze-guard.mjs';
14
+ import { clutterIssue } from './structure-guard.mjs';
14
15
 
15
16
  // Texts of ask decisions go to the user (message keys); texts of deny decisions are plain English for the AI.
16
17
  // Helpers here have no root argument: the project root comes from findRoot().
17
18
  const tr = (k, vars) => t(findRoot(), k, vars);
18
19
 
20
+ // _G_: options between command words (bounded, so a long repeated input stays linear); _L_: the rest of the command part
21
+ const cmdRe = (src) => new RegExp(src.replaceAll('_G_', String.raw`(?:[^;&|\n]{0,100}\s)?`).replaceAll('_L_', String.raw`[^;&|\n]{0,300}`), 'i');
19
22
  const DESTRUCTIVE = [
20
23
  [/\bdrop\s+(table|database|schema|index|view|collection)\b/i, 'DROP deletes a data structure'],
21
24
  [/\btruncate\s+(table\s+)?\w/i, 'TRUNCATE empties a table'],
@@ -29,6 +32,8 @@ const DESTRUCTIVE = [
29
32
  [/\bfirebase\s+firestore:delete\b/i, 'Firestore data is being deleted'],
30
33
  [/\bheroku\s+pg:reset\b|\bdrop(Database|Collection)\s*\(|\bdeleteMany\s*\(\s*\{\s*\}\s*\)|\bartisan\s+migrate:(fresh|refresh|reset)\b|\btypeorm\b[^;]*\bschema:drop\b|\bknex\b[^;]*\bmigrate:rollback\b[^;]*--all\b|\bprisma\s+db\s+push\b[^;]*--accept-data-loss\b/i, 'Database data is being deleted or reset'],
31
34
  [/\b(terraform|tofu|pulumi)\s+destroy\b/i, 'Cloud infrastructure is being destroyed'],
35
+ // Options may come before the service and between it and the subcommand (aws --profile x s3 --region r rm, heroku -a x apps:destroy)
36
+ [cmdRe(String.raw`\baws(\.exe)?\s_G_(s3\s_G_rm\b(?=_L_\s--recursive\b)|s3\s_G_rb\b(?=_L_\s--force\b)|s3api\s_G_delete-bucket\b|rds\s_G_delete-db-(instance|cluster)\b|dynamodb\s_G_delete-table\b|ec2\s_G_terminate-instances\b)|\bgcloud\s_G_((sql|compute)\s_G_instances\s_G_delete|projects\s_G_delete)\b|\b(gsutil\s_G_rm|gcloud\s_G_storage\s_G_rm)\b(?=_L_\s(-[a-zA-Z]*r|--recursive)\b)|\baz\s_G_(group|resource)\s_G_delete\b|\bheroku\s_G_apps:destroy\b|\b(fly|flyctl)\s_G_(apps|volumes)\s_G_destroy\b|\bgh\s_G_repo\s_G_delete\b|\bsupabase\s_G_projects\s_G_delete\b|\bnetlify(-cli)?\s_G_sites:delete\b`), 'Cloud data, an app or a repository is being deleted for good'],
32
37
  ];
33
38
 
34
39
  // Database addresses other than localhost count as prod (or a shared environment)
@@ -50,6 +55,11 @@ const NEEDS_APPROVAL = [
50
55
  // A package spelling (npx netlify-cli, firebase-tools, wrangler@3) deploys the same way
51
56
  [/\b(fly|flyctl)\s+deploy\b|\brailway\s+up\b|\bnetlify(-cli)?(@[\w.-]+)?\s+deploy\b[^;]*--prod|\bfirebase(-tools)?(@[\w.-]+)?\s+deploy\b|\bwrangler(@[\w.-]+)?\s+(deploy|publish|pages\s+deploy)\b|\bgcloud\s+app\s+deploy\b|\b(serverless|sls)\s+deploy\b|\bcdk\s+deploy\b|\bpulumi\s+up\b|\bamplify\s+(push|publish)\b|\b(terraform|tofu)\s+apply\b|\bexpo\s+publish\b/i, 'guard.destructive.serverDeploy'],
52
57
  [/\beas(-cli(@[\w.-]+)?)?\s+(submit\b|build\b(?=[^;\n]*--auto-submit\b)|update:(rollback|republish)\b|update\b(?=[^;\n]*--(branch|channel|environment)(=|\s+)['"]?prod(uction)?\b)|deploy\b(?=[^;\n]*--prod\b))/i, 'guard.destructive.storeSubmit'],
58
+ // vercel/vc only as a command word (vercel.json in a path is not one); delete as a whole word (delete.yaml is not)
59
+ [cmdRe(String.raw`\bkubectl(\.exe)?\s_G_delete(?=\s|$)|\bhelm(\.exe)?\s_G_(uninstall|delete)(?=\s|$)|\brailway\s_G_(down|delete)(?=\s|$)|(?<![\w./-])(vercel|vc)(@[\w.-]+)?(\.cmd|\.exe)?\s_G_(rm|remove)(?=\s|$)`), 'guard.destructive.resourceDelete'],
60
+ [cmdRe(String.raw`\b(npm|pnpm|bun|yarn)(\.cmd|\.exe)?\s_G_(npm\s+)?(publish|unpublish|deprecate|dist-tag)(?=\s|$)(?!_L_--dry-run(?!=false)(?!_L_--no-dry-run))|\bgh\s_G_release\s_G_(create|delete)\b|\bcargo\s_G_publish\b(?!_L_--dry-run)|\btwine\s_G_upload\b|\b(lerna|changeset)\s_G_publish\b`), 'guard.destructive.publish'],
61
+ [cmdRe(String.raw`\bgit(\.exe)?\s_G_branch\b(?=_L_\s(-[a-zA-Z]*d[a-zA-Z]*|--delete)(?=\s|$))(?=_L_\s(refs/heads/)?(main|master|production)(?=\s|$))`), 'guard.destructive.branchDelete'],
62
+ [cmdRe(String.raw`\bdocker(\.exe)?\s_G_(system|image)\s+prune\b(?=_L_\s(-[a-zA-Z]*a[a-zA-Z]*|--all)(?=\s|$))`), 'guard.destructive.dockerPrune'],
53
63
  [/\bgit\s+push\b[^;]*\b(main|master|production)\b/i, 'guard.destructive.pushMain'],
54
64
  [/\bgit\s+(rebase|filter-branch|filter-repo)\b/i, 'guard.destructive.rewriteHistory'],
55
65
  [/\b(alembic\s+upgrade|prisma\s+migrate\s+deploy|supabase\s+db\s+push)\b/i, 'guard.destructive.migration'],
@@ -395,7 +405,24 @@ const INLINE = /\b(node|deno|bun)\s+(-e|--eval|-p|--print)\b|\bpython[\d.]*\s+-c
395
405
  const TEST_WRITER_ONLY = 'Only the test-writer agent writes to test files: whoever writes the code cannot change tests (AGENTS.md rule 7). If a test must be added or changed, call the test-writer agent (continue it with SendMessage if it exists).';
396
406
  // ctx: { tool, agent } of the call; only Claude Code tells which subagent runs it. Test files are written by the
397
407
  // test-writer agent only; test config files stay a question (evaluateOne).
408
+ // Not every older rule is linear in the length: a command over MAX_COMMAND is not matched as a whole. Its short lines
409
+ // (up to 50 000 characters of them) still are, so a deny stays a deny; otherwise it is asked, naming a guarded word.
410
+ export const MAX_COMMAND = 10000;
411
+ export const shortLines = (command) => {
412
+ let total = 0;
413
+ return command.split(/\r?\n/).filter((l) => l.length <= 1000 && (total += l.length) <= 50000);
414
+ };
415
+ const GUARDED_WORD = /\b(drop|truncate|destroy|delete|reset|prune|flush|publish|deploy|push|rm|remove|rollback|promote|submit)\b/i;
416
+ function longCommand(command) {
417
+ const deny = shortLines(command).map((l) => strictest(evaluateOne(l), evaluateOne(unquote(l)), evaluateOne(blankQuoted(l))))
418
+ .find((r) => r?.decision === 'deny');
419
+ if (deny) return deny;
420
+ const word = command.match(GUARDED_WORD)?.[0];
421
+ return { decision: 'ask', reason: word ? tr('guard.destructive.tooLongWord', { length: command.length, word })
422
+ : tr('guard.destructive.tooLong', { length: command.length }) };
423
+ }
398
424
  export function evaluate(command, ctx = {}) {
425
+ if (command.length > MAX_COMMAND) return longCommand(command);
399
426
  const testWrite = ctx.tool === 'claude' && ctx.agent !== 'test-writer' && writesTest(quotedTextOff(command).replace(/\s+/g, ' '), isTestCode)
400
427
  ? { decision: 'deny', reason: TEST_WRITER_ONLY } : null;
401
428
  // An inline script (node -e, python -c, bash -c) that names a test path may write it; the shell rules cannot see inside
@@ -442,7 +469,8 @@ if (isMain(import.meta.url)) {
442
469
  // Of the lists, the stricter wins (deny > ask). A guard that throws would let the command run: asked instead
443
470
  let results;
444
471
  try {
445
- results = g ? [evaluate(g.command, g), g.tool !== 'claude' ? permissionEquivalent(g.command) : null, scopeIssue(g.command, g.dir, g.root)].filter(Boolean) : [];
472
+ results = !g ? [] : g.command.length > MAX_COMMAND
473
+ ? [evaluate(g.command, g), g.tool !== 'claude' ? strictest(...shortLines(g.command).map(permissionEquivalent)) : null].filter(Boolean) : [evaluate(g.command, g), g.tool !== 'claude' ? permissionEquivalent(g.command) : null, scopeIssue(g.command, g.dir, g.root), clutterIssue(g.command, g.dir, g.root)].filter(Boolean);
446
474
  } catch (e) {
447
475
  results = [{ decision: 'ask', reason: tr('guard.destructive.internalError', { error: String(e?.message ?? e).slice(0, 200) }) }];
448
476
  }
@@ -5,7 +5,10 @@
5
5
  // 2) Git pre-commit: `node .claude/hooks/guard-secrets.mjs --staged` scans the staged changes.
6
6
  // High-confidence patterns only; no generic "password=" search, to avoid false alarms.
7
7
  import { execSync } from 'node:child_process';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
8
10
  import { readInput, decide, isMain } from './shared.mjs';
11
+ import { boardAfter } from './freeze-guard.mjs';
9
12
 
10
13
  // (?<![A-Za-z0-9]) instead of \b: a key right after _ (OPENAI_sk-…, prod_sk_live_…) is found too.
11
14
  // Lookaheads and runs are bounded: an unbounded one is quadratic on a long run (-sb_secret_ × 50 000) and a hook
@@ -31,6 +34,19 @@ export function findSecrets(text) {
31
34
  return PATTERNS.filter(([, re]) => re.test(text)).map(([name]) => name);
32
35
  }
33
36
 
37
+ // A key split across edits (MultiEdit, Antigravity chunks) or joined to the text around an edit only exists in the file
38
+ // after the write: the result is rebuilt and the keys it has that the file on disk did not have are returned.
39
+ function addedSecrets(f, dir) {
40
+ if (!f.edits) return [];
41
+ let disk;
42
+ try { disk = fs.readFileSync(path.resolve(dir, f.path), 'utf8'); } catch { return []; }
43
+ const after = boardAfter(disk, f);
44
+ if (after === null) return [];
45
+ const all = (s) => new Set(PATTERNS.flatMap(([name, re]) => [...s.matchAll(new RegExp(re.source, re.flags.replace('g', '') + 'g'))].map(([m]) => `${name}\0${m}`)));
46
+ const before = all(disk);
47
+ return [...all(after)].filter((k) => !before.has(k)).map((k) => k.split('\0')[0]);
48
+ }
49
+
34
50
  function hookMode() {
35
51
  const g = readInput();
36
52
  if (!g) process.exit(0); // unreadable input: do not block the work
@@ -38,11 +54,16 @@ function hookMode() {
38
54
  if (g.tool !== 'claude' && findSecrets(g.command).length) {
39
55
  decide(g.tool, 'deny', `BLOCKED: the command contains a secret (${findSecrets(g.command).join(', ')}). Do not write the secret into a command or a file; read it from an environment variable.`);
40
56
  }
41
- for (const { path, text } of g.files) {
42
- const found = findSecrets(text);
57
+ // Antigravity: every tool's string arguments, so a write tool the parser does not know (create_file…) is scanned too
58
+ if (g.tool === 'antigravity' && findSecrets(g.reads.join('\n')).length) {
59
+ decide(g.tool, 'deny', `BLOCKED: the tool call contains a secret (${findSecrets(g.reads.join('\n')).join(', ')}). Do not write the secret into a file; read it from an environment variable.`);
60
+ }
61
+ for (const f of g.files) {
62
+ const { path: file, text } = f;
63
+ const found = [...new Set([...findSecrets(text), ...addedSecrets(f, g.dir)])];
43
64
  if (found.length) {
44
65
  decide(g.tool, 'deny',
45
- `BLOCKED: a secret is being written to ${path} (${found.join(', ')}). ` +
66
+ `BLOCKED: a secret is being written to ${file} (${found.join(', ')}). ` +
46
67
  'Do not put the secret in code; read it from an environment variable and add the variable name to .env.example with an empty value.');
47
68
  }
48
69
  }
@@ -391,6 +391,12 @@ export const MESSAGES = {
391
391
  'guard.destructive.pushMain': { en: 'Direct push to the main branch' },
392
392
  'guard.destructive.rewriteHistory': { en: 'Git history is being rewritten' },
393
393
  'guard.destructive.migration': { en: 'A migration is being applied (check the target environment)' },
394
+ 'guard.destructive.resourceDelete': { en: 'A cluster resource, deployment, domain or environment variable is being deleted' },
395
+ 'guard.destructive.publish': { en: 'A package or a release is being published (or unpublished)' },
396
+ 'guard.destructive.branchDelete': { en: 'The main branch is being deleted' },
397
+ 'guard.destructive.dockerPrune': { en: 'Every unused Docker image is being deleted' },
398
+ 'guard.destructive.tooLong': { en: 'The command is too long for the shield to check ({length} characters)' },
399
+ 'guard.destructive.tooLongWord': { en: 'The command is too long for the shield to check ({length} characters) and contains "{word}"' },
394
400
  'guard.destructive.remoteDb': { en: 'The command connects to a database other than localhost (could be prod). User approval required; the AI must not use prod access credentials.' },
395
401
  'guard.destructive.testShell': { en: 'A test file is being changed, moved or deleted with a shell command. Weakening a test to make it pass is forbidden (AGENTS.md rule 7); edit tests with a file tool. User approval required.' },
396
402
  'guard.destructive.inlineTest': { en: 'An inline script names a test path; it may write to a test file. Only the test-writer agent writes to test files (AGENTS.md rule 7). User approval required.' },
@@ -446,10 +452,17 @@ export const MESSAGES = {
446
452
  'guard.package.timeout': { en: 'could not be verified (timeout)' },
447
453
  'guard.package.network': { en: 'could not be verified (network error)' },
448
454
  'guard.package.tooMany': { en: 'Package gate: {count} packages in one command (more than {max}) are not checked one by one. User approval required.' },
455
+ 'guard.package.depFileUnclear': { en: 'Package gate: the new dependencies in {path} could not be read (the file does not parse or the edit cannot be placed). User approval required.' },
449
456
  'guard.package.error': { en: 'The package gate could not check this command ({error}). User approval required.' },
450
457
  'guard.package.ask': { en: 'Package gate warning: {list}. Before installing, make sure the package is really the one you want.' },
458
+ 'guard.package.notRegistry': { en: 'not from the public registry ({source}); the gate cannot check it' },
459
+ 'guard.package.otherEco': { en: 'the gate does not check the {eco} registry' },
460
+ 'guard.package.configFile': { en: 'Package gate: {path} sets where packages are installed from (registry, index or credentials). User approval required.' },
451
461
  'guard.structure.offMap': { en: '"{rel}" is not in the structure map (.claude/structure.json). The right place is probably one of: {top}. If it is temporary or a trial, write it under sandbox/. If a new permanent place is needed, ask it as a technical decision and update the map (docs/architecture/structure.md).' },
452
462
  'guard.structure.prefix': { en: 'Structure: {reason}' },
463
+ 'guard.structure.longPath': { en: 'A path of {length} characters is not checked against the structure map (over 1024). User approval required.' },
464
+ 'guard.structure.brokenMap': { en: '.claude/structure.json does not parse, so "{rel}" cannot be checked against the map. Fix the map (it is JSON) or approve this file.' },
465
+ 'guard.structure.clutterShell': { en: 'The command creates "{rel}", which matches a clutter pattern (backup, old version, copy or temp file). Git history is already the backup; use sandbox/ for experiments. User approval required.' },
453
466
  'guard.test.skip': { en: 'a test is skipped or focused alone (.skip / .only / skipIf / xit / pytest skip)' },
454
467
  'guard.test.expectRemoved': { en: 'an expectation (expect/assert) is deleted or commented out' },
455
468
  'guard.test.expectWeak': { en: 'an expectation is loosened (toBeTruthy, toBeDefined, expect.anything, expect without a matcher, assert True)' },
@@ -8,6 +8,7 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { readInput, decide, isMain, findRoot } from './shared.mjs';
11
+ import { boardAfter } from './freeze-guard.mjs';
11
12
  import { t } from './lang.mjs';
12
13
 
13
14
  export const THRESHOLD = { newDays: 30, npmWeeklyDownloads: 500 };
@@ -29,9 +30,57 @@ const COMMANDS = [
29
30
  [/^(npm|pnpm|bun)\s+(i|in|ins|inst|insta|instal|install|isnt|isnta|isntal|isntall|it|add)\b/i, 'npm'], [/^yarn\s+(global\s+)?add\b/i, 'npm'], [/^(npx\s+)?expo\s+install\b/i, 'npm'],
30
31
  // create/init <name> downloads and runs create-<name>; deno takes npm:<name> specifiers
31
32
  [/^(npm|pnpm|yarn|bun)\s+(create|init)\b/i, 'npm', 'create'], [/^deno\s+(add|install|run|x)\b/i, 'npm', 'deno'],
32
- [/^uv\s+(add|pip\s+install|tool\s+install)\b/i, 'pypi'], [/^((python[\d.]*|py)(\s+-\d[\d.]*)?\s+-m\s+)?pip[\d.]*(\s+-\S+)*\s+install\b/i, 'pypi'],
33
+ // pip's own options can come before install, with a value (pip --index-url URL install x, pip --proxy P install x)
34
+ [/^uv\s+(add|pip\s+install|tool\s+install)\b/i, 'pypi'], [/^((python[\d.]*|py)(\s+-\d[\d.]*)?\s+-m\s+)?pip[\d.]*(\s+-\S+(\s+(?!install\b)[^\s-]\S*)?)*\s+install\b/i, 'pypi'],
33
35
  [/^(poetry|pdm)\s+add\b|^(pipenv|pipx)\s+install\b/i, 'pypi'],
36
+ // Registries the gate does not query: every package named there is asked (user decision 2026-10-30)
37
+ [/^cargo(\s+-\S+)*\s+(\+\S+\s+)?(add|install)\b/i, 'cargo'], [/^go\s+(get|install)\b/i, 'go'], [/^(gem\s+install|bundle\s+add)\b/i, 'gem'],
38
+ [/^composer\s+(global\s+)?require\b/i, 'composer'],
34
39
  ];
40
+ const OTHER_VALUE = /^(--path|--version|--vers|--features|-F|--rename|--package|-p|--branch|--tag|--rev|--registry|--index|--root|--target|--profile|-C|--manifest-path|-v|-i|--install-dir|-n|--bindir|--source|-s|--git|-j|--jobs)$/;
41
+
42
+ // Where an install argument comes from: 'local' (a path or file: link, already on this machine), 'remote' (git, a URL, a
43
+ // GitHub shorthand user/repo: not the registry the gate checks, user decision 2026-10-30), or null (a registry name).
44
+ // ~ is a path only as ~/ or ~\ (~1.2.3 is a semver range)
45
+ const LOCAL = /^(\.{1,2}([\\/]|$)|[\\/]|~([\\/]|$)|[A-Za-z]:[\\/]|file:|link:|workspace:|portal:)/i;
46
+ const REMOTE = /^(git\+|git:|ssh:|https?:|github:|gitlab:|bitbucket:|gist:)/i;
47
+ function npmSource(arg) {
48
+ for (;;) {
49
+ if (LOCAL.test(arg)) return 'local';
50
+ if (REMOTE.test(arg)) return 'remote';
51
+ const at = arg.indexOf('@', 1), spec = at > 0 ? arg.slice(at + 1) : '';
52
+ if (spec.startsWith('npm:')) { arg = spec.slice(4); continue; }
53
+ if (LOCAL.test(spec)) return 'local';
54
+ if (/[:/]/.test(spec)) return 'remote';
55
+ return !arg.startsWith('@') && arg.includes('/') ? 'remote' : null;
56
+ }
57
+ }
58
+ function pypiSource(arg) {
59
+ if (LOCAL.test(arg)) return 'local';
60
+ if (REMOTE.test(arg)) return 'remote';
61
+ const ref = arg.match(/^[^@;]*@\s*(\S+)/)?.[1];
62
+ if (ref) return LOCAL.test(ref) ? 'local' : 'remote';
63
+ return /\.(whl|tar\.gz|zip)$/i.test(arg) ? 'local' : null;
64
+ }
65
+ // A registry other than the public one: what gets installed cannot be checked. Read on the whole command, so a variable
66
+ // exported in an earlier statement (export PIP_INDEX_URL=…; pip install x) or a bare npm ci --registry … counts too.
67
+ // FLAG counts when the command runs a package manager; CONFIG (config set, a write to a registry config file) always.
68
+ // npm accepts an unambiguous prefix of a flag (--regist=…). Gaps are bounded: the text is linear to scan.
69
+ const MANAGER = /\b(npm|pnpm|yarn|bun|npx|bunx|pnpx|pip[\d.]*|uv|uvx|poetry|pdm|pipenv|pipx)\b/i;
70
+ // Registry config files: writing one points installs elsewhere (the file tools ask too, see gate())
71
+ export const REGISTRY_CONFIG = /(^|[\\/])(\.npmrc|\.yarnrc(\.yml)?|\.pnpmrc|bunfig\.toml|pip\.conf|pip\.ini|uv\.toml|poetry\.toml|\.pypirc)$/i;
72
+ const CONFIG_WRITE = /(>{1,2}|\b(tee|cp|copy|mv|move|ln|Set-Content|Add-Content|Out-File|Copy-Item|Move-Item|New-Item)\b[^;&|\n]{0,1000}?)\s*(-\w+\s+)?['"]?[^\s'";&|<>]*(\.npmrc|\.yarnrc(\.yml)?|\.pnpmrc|bunfig\.toml|pip\.conf|pip\.ini|uv\.toml|poetry\.toml|\.pypirc)(?![\w.-])/i;
73
+ const OTHER_REGISTRY = {
74
+ npm: {
75
+ FLAG: /(^|[\s'"])--((@[\w.-]+:)?reg[a-z]*|userconfig)(?:(?:=|\s+)\S*|$)|\b(npm_config_(@[\w.-]+:)?registry|npm_config_userconfig|YARN_NPM_REGISTRY_SERVER|YARN_REGISTRY)\s*=\S*/i,
76
+ CONFIG: new RegExp(`\\b(npm|pnpm|yarn|bun)\\b[^;&|\\n]{0,200}\\bset\\b[^;&|\\n]{0,200}(registry|RegistryServer)\\S*(\\s+\\S+)?|${CONFIG_WRITE.source}`, 'i'),
77
+ },
78
+ pypi: {
79
+ FLAG: /(^|[\s'"])(--(extra-)?index-url|--index|--default-index|--find-links)(?:(?:=|\s+)\S*|$)|\b(PIP_(EXTRA_)?INDEX_URL|PIP_FIND_LINKS|PIP_CONFIG_FILE|UV_(EXTRA_)?INDEX(_URL)?|UV_DEFAULT_INDEX|UV_FIND_LINKS|UV_CONFIG_FILE)\s*=\S*/i,
80
+ CONFIG: /\bpip[\d.]*\b[^;&|\n]{0,200}\bconfig\b[^;&|\n]{0,200}\bset\b[^;&|\n]{0,200}(index-url|find-links|trusted-host)\S*(\s+\S+)?|\bpoetry\s+(source\s+add|config\s+(repositories|http-basic|certificates)\.)\S*(\s+\S+)?/i,
81
+ },
82
+ };
83
+ const remote = (eco, spec) => ({ eco, name: '', spec, unverifiable: true });
35
84
  // create-vite for vite, @scope/create for @scope, @scope/create-x for @scope/x (npm's own mapping)
36
85
  function createName(arg) {
37
86
  const n = npmName(arg);
@@ -102,7 +151,7 @@ export function normalize(part) {
102
151
  return s;
103
152
  }
104
153
 
105
- const NOT_REGISTRY = /^(\.|\/|file:|link:|git\+|git:|https?:|github:|workspace:)/;
154
+ const NOT_REGISTRY = /^(\.|\/|~[\\/]|file:|link:|git\+|git:|https?:|github:|workspace:)/;
106
155
  function npmName(arg) {
107
156
  // name@npm:other installs "other": the alias target is checked (a loop: a deep a@npm:a@npm:… chain must not overflow
108
157
  // the stack). name@github:x/y, @https:… are not from the registry (like a bare URL, not checked here).
@@ -122,18 +171,37 @@ function pypiName(arg) {
122
171
 
123
172
  export function findPackages(command) {
124
173
  const result = [];
174
+ for (const [eco, { FLAG, CONFIG }] of Object.entries(OTHER_REGISTRY)) {
175
+ const m = command.match(CONFIG) ?? (MANAGER.test(command) ? command.match(FLAG) : null);
176
+ if (m) result.push(remote(eco, m[0].trim().replace(/^['"]+|['";]+$/g, '')));
177
+ }
125
178
  // Every place a new command can start: chains, a newline, a background &, $( ), backticks, ( ) and { } groups.
126
- // A redirect's & (2>&1, &>) is not a separator.
127
- for (const part of command.split(/\n|&&|\|\||;|\||(?<![>&])&(?![>&])|\$\(|`|[(){}]/)) {
179
+ // A redirect's & (2>&1, &>) is not a separator. A # comment is not an argument.
180
+ for (const raw of command.split(/\n|&&|\|\||;|\||(?<![>&])&(?![>&])|\$\(|`|[(){}]/)) {
181
+ // A # comment ends the part only outside quotes and after an unescaped blank (--prefix "a #b", a\ #b are values)
182
+ const cut = raw.replace(/"(?:[^"\\]|\\.)*"?|'[^']*'?|\\[\s\S]/g, (q) => '_'.repeat(q.length)).search(/(^|\s)#/);
183
+ const part = cut < 0 ? raw : raw.slice(0, cut);
128
184
  const s = normalize(part);
129
185
  const match = COMMANDS.find(([re]) => re.test(s));
130
186
  const exec = match ? null : EXEC.find((e) => e.re.test(s));
131
187
  if (!match && !exec) continue;
132
188
  const re = match?.[0] ?? exec.re, eco = match?.[1] ?? exec.eco;
133
- // Redirects (2>&1, > log, 2>/dev/null, &> x) are not arguments; a quoted "pkg>=1" has no space before ">" and stays
134
- const args = s.replace(re, '').replace(/\s+(\d*|&)(>>?|<)(&\d+|\s*[^\s'"]+)/g, '').trim().split(/\s+/).map((x) => x.replace(/^['"(]+|['")]+$/g, '')).filter(Boolean);
189
+ // Redirects (2>&1, > log, 2>/dev/null, &> x) are not arguments; a quoted "pkg>=1" has no space before ">" and stays.
190
+ // Words are split like the shell does: a quoted value with spaces ("a #b") is one word
191
+ const args = (s.replace(re, '').replace(/\s+(\d*|&)(>>?|<)(&\d+|\s*[^\s'"]+)/g, '').trim().match(/(?:[^\s"']+|"[^"]*"?|'[^']*'?)+/g) ?? [])
192
+ .map((x) => x.replace(/["']/g, '').replace(/^\(+|\)+$/g, '')).filter(Boolean);
193
+ if (!POPULAR[eco]) {
194
+ for (let i = 0; i < args.length; i++) {
195
+ const a = args[i];
196
+ if (a.startsWith('-')) { if (OTHER_VALUE.test(a) && args[++i] && a === '--git') result.push(remote(eco, args[i])); continue; }
197
+ if (!LOCAL.test(a)) result.push({ eco, name: a, unverifiable: true });
198
+ }
199
+ continue;
200
+ }
201
+ const source = eco === 'npm' ? npmSource : pypiSource;
135
202
  if (exec) {
136
203
  for (const { arg, fromFlag, unsure } of execArgs(args, eco, exec.positional)) {
204
+ if (source(arg) === 'remote') { result.push(remote(eco, arg)); continue; }
137
205
  const name = eco === 'npm' ? npmName(arg) : pypiName(arg);
138
206
  if (name) result.push({ eco, name, exec: true, spec: arg, ...(exec.local && !fromFlag && { local: true }), ...(unsure && { unsure: true }) });
139
207
  }
@@ -151,9 +219,23 @@ export function findPackages(command) {
151
219
  for (let i = 0; i < args.length; i++) {
152
220
  const a = args[i];
153
221
  if (a.startsWith('-')) {
154
- if (/^(-r|--requirement|-e|--editable|-c|--constraint|--index-url|-i|--extra-index-url|--filter|--prefix|--group)$/.test(a)) i++; // flag that takes a value
222
+ if (eco === 'pypi') {
223
+ // pip short options take their value attached too: -ihttps://…, -fhttps://…, -egit+https://…, --editable=git+…
224
+ if (/^-[if]./.test(a)) result.push(remote(eco, a));
225
+ const v = a.match(/^(?:-[erc]|--(?:editable|requirement|constraint)=)(.+)$/)?.[1];
226
+ if (v && pypiSource(v) === 'remote') result.push(remote(eco, v));
227
+ }
228
+ // npm's -f is --force (no value): the pip-only flags count only for pip
229
+ if (/^(-r|--requirement|-e|--editable|-c|--constraint|--index-url|-i|--extra-index-url|--filter|--prefix|--group)$/.test(a)
230
+ || (eco === 'pypi' ? /^(-f|--find-links|--index|--default-index|--trusted-host|--source|-t|--target|--root|--platform|--python-version)$/ : /^--registry$/).test(a)) {
231
+ const v = args[i + 1];
232
+ // -e git+https://…, -r https://… install from a source the gate cannot check; so do -i/-f
233
+ if (eco === 'pypi' && v && (/^(-[if])$/.test(a) || (/^(-e|--editable|-r|--requirement|-c|--constraint)$/.test(a) && pypiSource(v) === 'remote'))) result.push(remote(eco, `${a} ${v}`));
234
+ i++; // flag that takes a value
235
+ }
155
236
  continue;
156
237
  }
238
+ if (source(a) === 'remote') { result.push(remote(eco, a)); continue; }
157
239
  const name = eco === 'npm' ? npmName(a) : pypiName(a);
158
240
  if (name) result.push({ eco, name });
159
241
  }
@@ -161,6 +243,144 @@ export function findPackages(command) {
161
243
  return result;
162
244
  }
163
245
 
246
+ // Dependency files written with a file tool: the names the write ADDS are checked like an install (user decision
247
+ // 2026-10-30), so "edit package.json, then a bare npm ci" cannot skip the gate. null: the result does not parse (asked).
248
+ const DEP_FILE = /(^|\/)(package\.json|pyproject\.toml|requirements[^/]*\.txt|requirements\/[^/]+\.txt)$/i;
249
+ const NPM_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
250
+ const obj = (x) => (x && typeof x === 'object' && !Array.isArray(x) ? x : {});
251
+ // A package name without its version (foo@1.2 → foo, @s/x@1 → @s/x)
252
+ const bare = (n) => n.replace(/(?<=.)@.*$/, '');
253
+ // Override keys: npm overrides nest ({ foo: { ".": "1", bar: "2" } }), yarn resolutions are paths (**/bar, @s/a/b),
254
+ // pnpm.overrides chain names (foo@<2>bar, foo>bar)
255
+ function overrideDeps(o, out) {
256
+ for (const [k, v] of Object.entries(obj(o))) {
257
+ if (k === '.') continue;
258
+ out.push([bare(k), typeof v === 'string' ? v : typeof obj(v)['.'] === 'string' ? v['.'] : '']);
259
+ if (v && typeof v === 'object') overrideDeps(v, out);
260
+ }
261
+ }
262
+ function resolutionName(k) {
263
+ const parts = k.split('/').filter((p) => p && p !== '**');
264
+ let last = parts.pop() ?? '';
265
+ if (parts.at(-1)?.startsWith('@')) last = `${parts.pop()}/${last}`;
266
+ return bare(last);
267
+ }
268
+ // Every npm dependency the file declares, as { name, spec, remote }: an npm: alias names its target ("x": "npm:evil@1" adds evil);
269
+ // a local spec (file:, link:, workspace:) is left out; a git, URL or user/repo spec is remote
270
+ function npmDeps(j) {
271
+ const pairs = NPM_SECTIONS.flatMap((k) => Object.entries(obj(j?.[k])));
272
+ overrideDeps(j?.overrides, pairs);
273
+ for (const [k, v] of Object.entries(obj(j?.resolutions))) pairs.push([resolutionName(k), v]);
274
+ for (const [k, v] of Object.entries(obj(j?.pnpm?.overrides))) for (const n of k.split('>')) pairs.push([bare(n), n === k.split('>').at(-1) ? v : '']);
275
+ const out = [];
276
+ for (const [n, v] of pairs) {
277
+ const spec = typeof v === 'string' ? v : '';
278
+ // pnpm catalog: and yarn patch: resolve to the registry package of the same name
279
+ const arg = /^npm:/i.test(spec) ? spec.slice(4) : spec && !/^(catalog|patch):/i.test(spec) ? `${n}@${spec}` : n;
280
+ const source = npmSource(arg);
281
+ if (source === 'remote') out.push({ name: n, spec, remote: true });
282
+ else if (!source && !/^\$/.test(spec)) { const name = npmName(arg); if (name) out.push({ name, spec }); }
283
+ }
284
+ return out;
285
+ }
286
+ // requirements lines: a name, a git/URL line or an index/find-links option (remote), local paths and -r/-c are left out
287
+ function requirementDeps(text) {
288
+ const out = [];
289
+ for (const raw of text.split(/\r?\n/)) {
290
+ const s = raw.replace(/(^|\s)#.*$/, '').trim();
291
+ if (!s) continue;
292
+ // Short options may carry their value attached (-ihttps://…, -egit+https://…)
293
+ const opt = s.match(/^(--editable|--index-url|--extra-index-url|--find-links|--requirement|--constraint|-[eifrc])(?:=|\s+)?(.*)$/);
294
+ if (opt) {
295
+ if (/^(-[if]|--index-url|--extra-index-url|--find-links)$/.test(opt[1]) || pypiSource(opt[2]) === 'remote') out.push({ name: '', spec: s, remote: true });
296
+ continue;
297
+ }
298
+ if (s.startsWith('-')) continue;
299
+ out.push(...pep508(s));
300
+ }
301
+ return out;
302
+ }
303
+ const pep508 = (s) => {
304
+ const source = pypiSource(s);
305
+ if (source === 'remote') return [{ name: pypiName(s.replace(REMOTE, '')) ?? '', spec: s, remote: true }];
306
+ if (source === 'local') return [];
307
+ const name = pypiName(s);
308
+ return name ? [{ name, spec: s }] : [];
309
+ };
310
+ // pyproject.toml, by full dotted key (section + key, so [project] optional-dependencies.dev = […], a top-level
311
+ // project.dependencies = […] and a quoted ["project"] header read the same):
312
+ // arrays of requirements: PY_ARRAY; one table entry per package (Poetry dependencies, uv sources): PY_TABLE, also as a
313
+ // sub-table header [tool.poetry.dependencies.x]; a custom index URL ([[tool.uv.index]], [[tool.poetry.source]]): PY_INDEX.
314
+ // Array items are read token by token: a quoted "]" does not end the array, an { include-group = "x" } table is not a
315
+ // name, a # comment ends the line.
316
+ const PY_ARRAY = /^(project\.dependencies|project\.optional-dependencies\.[^.]+|dependency-groups\.[^.]+|tool\.uv\.(dev|constraint|override)-dependencies|build-system\.requires|tool\.pdm\.dev-dependencies\.[^.]+|tool\.hatch\.envs\.[^.]+\.(extra-)?dependencies)$/;
317
+ const PY_TABLE = /^(tool\.poetry(\.group\.[^.]+)?\.(dev-)?dependencies|tool\.uv\.sources)\.([^.]+)$/;
318
+ const PY_INDEX = /^(tool\.uv\.index|tool\.poetry\.source|tool\.pdm\.source)\.url$/;
319
+ // A uv source only redirects a declared package: it adds no name, only a remote spec
320
+ const tableEntry = (full, value) => {
321
+ const t = full.match(PY_TABLE), name = t?.[4].toLowerCase();
322
+ if (!t || name === 'python') return [];
323
+ if (/\b(git|url)\s*=/.test(value)) return [{ name, spec: value, remote: true }];
324
+ return /\bpath\s*=/.test(value) || t[1] === 'tool.uv.sources' ? [] : [{ name, spec: '' }];
325
+ };
326
+ function pyprojectDeps(text) {
327
+ const out = [];
328
+ let section = '', inArray = false, table = null;
329
+ // [tool.poetry.dependencies.x] declares x with its keys on the next lines: decided when the section ends
330
+ const flush = () => { if (table !== null) out.push(...tableEntry(section, table)); table = null; };
331
+ for (const raw of [...text.split(/\r?\n/), '[]']) {
332
+ const line = raw.trim();
333
+ const head = inArray ? null : line.match(/^\[\[?([^\]]*)\]\]?$/);
334
+ if (head) {
335
+ flush();
336
+ section = head[1].replace(/["'\s]/g, '');
337
+ if (PY_TABLE.test(section)) table = '';
338
+ continue;
339
+ }
340
+ let rest = line;
341
+ if (!inArray) {
342
+ const key = line.match(/^((?:"[^"]*"|'[^']*'|[\w.-]+)(?:\s*\.\s*(?:"[^"]*"|'[^']*'|[\w.-]+))*)\s*=\s*(.*)$/);
343
+ if (!key) continue;
344
+ const k = key[1].replace(/["'\s]/g, ''), full = section ? `${section}.${k}` : k, value = key[2];
345
+ if (PY_ARRAY.test(full) && value.startsWith('[')) { inArray = true; rest = value.slice(1); }
346
+ else {
347
+ if (PY_TABLE.test(full)) out.push(...tableEntry(full, value));
348
+ else if (table !== null && /^(git|url|path)$/.test(k)) table += ` ${k} = ${value}`;
349
+ else if (PY_INDEX.test(full)) out.push({ name: '', spec: value, remote: true });
350
+ continue;
351
+ }
352
+ }
353
+ for (const [tok] of rest.matchAll(/"(?:[^"\\]|\\.)*"|'[^']*'|\{[^}]*\}?|\]|#.*$/g)) {
354
+ if (tok === ']') { inArray = false; break; }
355
+ if (tok.startsWith('#')) break;
356
+ if (tok.startsWith('{')) continue;
357
+ out.push(...pep508(tok.slice(1, -1)));
358
+ }
359
+ }
360
+ return out;
361
+ }
362
+ // What the write adds: a registry name not declared before (a version change is not checked), or a remote spec not there
363
+ // before (asked, never checked). Returns [{ eco, name, spec?, unverifiable? }] or null when the result does not parse.
364
+ export function addedDependencies(file, before, after) {
365
+ const rel = file.replace(/\\/g, '/');
366
+ const added = (eco, a, b) => {
367
+ const names = new Set(b.filter((d) => !d.remote).map((d) => d.name)), specs = new Set(b.map((d) => `${d.name}\0${d.spec}`));
368
+ const seen = new Set();
369
+ return a.filter((d) => (d.remote ? !specs.has(`${d.name}\0${d.spec}`) : !names.has(d.name)))
370
+ .filter((d) => { const k = `${d.remote}\0${d.name}\0${d.remote ? d.spec : ''}`; return !seen.has(k) && seen.add(k); })
371
+ .map((d) => (d.remote ? { eco, name: d.name, spec: d.spec, unverifiable: true } : { eco, name: d.name }));
372
+ };
373
+ if (/(^|\/)package\.json$/i.test(rel)) {
374
+ let a, b = {};
375
+ try { a = JSON.parse(after); } catch { return null; }
376
+ try { b = JSON.parse(before || '{}'); } catch { /* a broken file before: every name counts as added */ }
377
+ return added('npm', npmDeps(a), npmDeps(b));
378
+ }
379
+ if (/(^|\/)requirements[^/]*\.txt$|(^|\/)requirements\/[^/]+\.txt$/i.test(rel)) return added('pypi', requirementDeps(after), requirementDeps(before));
380
+ if (/(^|\/)pyproject\.toml$/i.test(rel)) return added('pypi', pyprojectDeps(after), pyprojectDeps(before));
381
+ return [];
382
+ }
383
+
164
384
  function distance(a, b) {
165
385
  const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
166
386
  for (let j = 1; j <= b.length; j++) d[0][j] = j;
@@ -229,16 +449,29 @@ async function gate(g) {
229
449
  // The kit's own package (/start and /adopt run npx -y tillerhand@<version> install): the kit already trusts itself
230
450
  // Both only for the plain spelling: name@1.2 or name@npm:other downloads, it does not run the local bin or the kit's release
231
451
  const packages = findPackages(g.command).filter((p) => !(p.local && p.spec === p.name && bin(p.name)) && !(p.eco === 'npm' && /^tillerhand(@\d+\.\d+\.\d+)?$/.test(p.spec ?? '')));
452
+ for (const f of g.files) {
453
+ if (REGISTRY_CONFIG.test(String(f.path))) decide(g.tool, 'ask', t(g.root, 'guard.package.configFile', { path: f.path }));
454
+ if (!DEP_FILE.test(String(f.path).replace(/\\/g, '/'))) continue;
455
+ const full = path.resolve(g.dir, f.path);
456
+ let disk = ''; try { disk = fs.readFileSync(full, 'utf8'); } catch { /* new file */ }
457
+ const after = boardAfter(disk, f);
458
+ const added = after === null ? null : addedDependencies(f.path, disk, after);
459
+ if (added === null) decide(g.tool, 'ask', t(g.root, 'guard.package.depFileUnclear', { path: f.path }));
460
+ packages.push(...added);
461
+ }
232
462
  if (!packages.length) return;
233
463
  if (packages.length > MAX_PACKAGES) decide(g.tool, 'ask', t(g.root, 'guard.package.tooMany', { count: packages.length, max: MAX_PACKAGES }));
234
- const results = await Promise.all(packages.map(async (p) => ({ ...p, ...(await check(p)) })));
464
+ // A remote source or a registry the gate does not query is asked as is, without a fetch
465
+ const results = await Promise.all(packages.map(async (p) => ({ ...p, ...(p.unverifiable
466
+ ? { status: 'unverifiable', detail: POPULAR[p.eco] ? t(g.root, 'guard.package.notRegistry', { source: p.spec || p.name }) : t(g.root, 'guard.package.otherEco', { eco: p.eco }) }
467
+ : await check(p)) })));
235
468
  const missing = results.filter((s) => s.status === 'missing' && !s.unsure);
236
469
  if (missing.length) {
237
470
  decide(g.tool, 'deny', `Package gate: ${missing.map((s) => `${s.name} (${s.detail})`).join(', ')}. This name may be made up. Find the correct package name in the official docs; tell the user.`);
238
471
  }
239
472
  const suspect = results.filter((s) => s.status !== 'ok');
240
473
  if (suspect.length) {
241
- decide(g.tool, 'ask', t(g.root, 'guard.package.ask', { list: suspect.map((s) => `${s.eco}:${s.name} — ${s.detail}`).join('; ') }));
474
+ decide(g.tool, 'ask', t(g.root, 'guard.package.ask', { list: suspect.map((s) => `${s.eco}:${s.name || s.spec} — ${s.detail}`).join('; ') }));
242
475
  }
243
476
  process.exit(0);
244
477
  }
@@ -97,7 +97,8 @@ export function parseInput(g) {
97
97
  }
98
98
  const t = g.tool_input ?? {};
99
99
  const command = isText(t.command) ? t.command : Array.isArray(t.command) ? t.command.join(' ') : t.command == null ? '' : JSON.stringify(t.command);
100
- if (g.tool_name === 'apply_patch') result.files = patchFiles(command);
100
+ // Codex can pass the patch text in tool_input.input instead of command
101
+ if (g.tool_name === 'apply_patch') result.files = patchFiles(command || (isText(t.input) ? t.input : t.input == null ? '' : JSON.stringify(t.input)));
101
102
  else result.command = command;
102
103
  const file = t.file_path ?? t.notebook_path;
103
104
  if (file) {
@@ -27,19 +27,25 @@ export function globRegex(pattern) {
27
27
  }
28
28
 
29
29
  export function readMap(dir = root) {
30
+ let text;
31
+ try { text = fs.readFileSync(path.join(dir, '.claude', 'structure.json'), 'utf8'); } catch { return null; }
32
+ // A map that exists but does not parse must not switch the guard off: every new file is asked
30
33
  try {
31
- const m = JSON.parse(fs.readFileSync(path.join(dir, '.claude', 'structure.json'), 'utf8'));
34
+ const m = JSON.parse(text);
32
35
  return { allow: (m.allow ?? []).map(globRegex), deny: (m.deny ?? []).map(globRegex), raw: m };
33
36
  } catch {
34
- return null;
37
+ return { broken: true };
35
38
  }
36
39
  }
37
40
 
38
41
  // Returns: null (fine) | { decision: 'deny' | 'ask', reason }
39
42
  export function check(rel, map) {
40
43
  if (!map) return null;
44
+ // The globs are not linear in the path length: a very long path is asked, not matched
45
+ if (rel.length > 1024) return { decision: 'ask', reason: t(root, 'guard.structure.longPath', { length: rel.length }) };
41
46
  rel = rel.replace(/\\/g, '/').replace(/^\.\//, '');
42
47
  if (rel.startsWith('sandbox/')) return null;
48
+ if (map.broken) return { decision: 'ask', reason: t(root, 'guard.structure.brokenMap', { rel }) };
43
49
  const denied = map.deny.find((re) => re.test(rel));
44
50
  if (denied) {
45
51
  return { decision: 'deny', reason: `"${rel}" matches a clutter pattern (backup, old version, copy or temp file). Git history is already the backup; use sandbox/ for experiments.` };
@@ -52,6 +58,46 @@ export function check(rel, map) {
52
58
  };
53
59
  }
54
60
 
61
+ // Shell side, clutter only: a file a command creates that does not exist yet (a redirect target, the destination of
62
+ // cp/mv/copy/ln, the arguments of tee, touch, New-Item, Out-File, Set-Content) is checked against the deny list.
63
+ // Asked, not denied: the shell text is read by heuristics. Off-map files are not asked here (the Write hook does that).
64
+ // No map or a broken map: silent.
65
+ // shortcut: text-based (quoted words, variables, globs and bash -c bodies are skipped; cd followed in order only).
66
+ const CREATORS = /^(cp|copy|mv|move|ln|copy-item|move-item|cpi|mi)$/;
67
+ const MAKERS = /^(tee|touch|new-item|ni|out-file|set-content|add-content)$/;
68
+ const PREFIX = /^(\w+=\S*|sudo|env|time|nohup|command|exec|do|then|else)$/i;
69
+ const HIDDEN = ' <>;&|#=()\n';
70
+ export function clutterIssue(command, dir, root) {
71
+ const map = readMap(root);
72
+ if (!map || map.broken) return null;
73
+ // Text is not a target: a heredoc body goes (its first line, with its redirects, stays); a quoted string becomes one
74
+ // word, its blanks and shell characters hidden in private-use characters (U+E000…) until it is used as a path, so
75
+ // "a > b.bak" is no redirect but "a (1).ts" is still a name; arrows (=>, ->) go; >| >& &> are plain redirects
76
+ const text = command.replace(/(<<-?\s*(['"]?)(\w+)\2[^\n]*)\n[\s\S]*?\n[ \t]*\3[ \t]*(?=\n|$)/g, '$1')
77
+ .replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, (q) => q.slice(1, -1).replace(/[\s<>;&|#=()]/g, (c) => String.fromCharCode(0xe000 + HIDDEN.indexOf(c === '\t' ? ' ' : c))))
78
+ .replace(/[=-]>/g, ' ').replace(/\d*>&\d+/g, ' ').replace(/>\||>&|&>/g, '>');
79
+ let cwd = dir;
80
+ for (const raw of text.split(/&&|\|\||[;|\n&]/)) {
81
+ const part = raw.replace(/(^|\s)#.*$/, '').replace(/^[\s({!]+|[\s)}]+$/g, '');
82
+ const redirects = [...part.matchAll(/\d?>{1,2}\s*([^\s;&|<>]+)/g)].map((m) => m[1]);
83
+ const words = part.replace(/\d?>{1,2}\s*\S+/g, ' ').trim().split(/\s+/);
84
+ while (words.length && PREFIX.test(words[0])) words.shift();
85
+ const name = path.basename(words[0] ?? '').toLowerCase().replace(/\.(exe|cmd)$/, '');
86
+ const dest = words.findIndex((w) => /^-destination$/i.test(w));
87
+ const args = words.slice(1).filter((w) => w && !w.startsWith('-'));
88
+ if (/^(cd|pushd|set-location)$/.test(name)) { cwd = path.resolve(cwd, args[0] ?? '.'); continue; }
89
+ const made = CREATORS.test(name) ? (dest > -1 ? [words[dest + 1] ?? ''] : args.slice(-1)) : MAKERS.test(name) ? args : [];
90
+ for (const w of [...redirects, ...made]) {
91
+ if (!w || /[$`*?{]/.test(w)) continue;
92
+ const x = w.replace(/[-]/g, (c) => HIDDEN[c.charCodeAt(0) - 0xe000]);
93
+ const full = path.resolve(cwd, x), rel = path.relative(root, full);
94
+ if (rel.startsWith('..') || path.isAbsolute(rel) || fs.existsSync(full)) continue;
95
+ if (check(rel, map)?.decision === 'deny') return { decision: 'ask', reason: t(root, 'guard.structure.clutterShell', { rel: rel.replace(/\\/g, '/') }) };
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
55
101
  function hook() {
56
102
  const g = readInput();
57
103
  if (!g) process.exit(0);
@@ -70,6 +116,7 @@ function hook() {
70
116
  function checkAll() {
71
117
  const map = readMap();
72
118
  if (!map) { console.log('No structure map (.claude/structure.json); check skipped.'); return; }
119
+ if (map.broken) { console.error('.claude/structure.json does not parse (it must be JSON); the structure cannot be checked.'); process.exit(1); }
73
120
  const files = execFileSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' }).split('\n').filter(Boolean);
74
121
  const violations = files.map((f) => [f, check(f, map)]).filter(([, s]) => s);
75
122
  if (!violations.length) { console.log(`Structure OK: ${files.length} files.`); return; }
@@ -388,6 +388,12 @@
388
388
  "guard.destructive.pushMain": "Ana dala doğrudan push",
389
389
  "guard.destructive.rewriteHistory": "Git geçmişi yeniden yazılıyor",
390
390
  "guard.destructive.migration": "Migration uygulanıyor (hedef ortamı kontrol et)",
391
+ "guard.destructive.resourceDelete": "Bir küme kaynağı, deploy, alan adı ya da ortam değişkeni siliniyor",
392
+ "guard.destructive.publish": "Bir paket ya da sürüm yayımlanıyor (ya da yayından kaldırılıyor)",
393
+ "guard.destructive.branchDelete": "Ana dal siliniyor",
394
+ "guard.destructive.dockerPrune": "Kullanılmayan bütün Docker imajları siliniyor",
395
+ "guard.destructive.tooLong": "Komut kalkanın denetlemesi için fazla uzun ({length} karakter)",
396
+ "guard.destructive.tooLongWord": "Komut kalkanın denetlemesi için fazla uzun ({length} karakter) ve \"{word}\" içeriyor",
391
397
  "guard.destructive.remoteDb": "Komut localhost dışındaki bir veritabanına bağlanıyor (prod olabilir). Kullanıcı onayı gerekli; AI prod erişim bilgisi kullanmamalı.",
392
398
  "guard.destructive.testShell": "Test dosyası kabuk komutuyla değiştiriliyor, taşınıyor veya siliniyor. Testi geçirmek için testi zayıflatmak yasak (AGENTS.md kural 7); test düzenlemesini dosya aracıyla yap. Kullanıcı onayı gerekli.",
393
399
  "guard.destructive.inlineTest": "Satır içi betik bir test yolunu anıyor; test dosyasına yazıyor olabilir. Test dosyasına yalnız test-writer ajanı yazar (AGENTS.md kural 7). Kullanıcı onayı gerekli.",
@@ -443,10 +449,17 @@
443
449
  "guard.package.timeout": "doğrulanamadı (zaman aşımı)",
444
450
  "guard.package.network": "doğrulanamadı (ağ hatası)",
445
451
  "guard.package.tooMany": "Paket kapısı: tek komutta {count} paket var ({max} sınırının üstünde), tek tek denetlenmiyor. Kullanıcı onayı gerekli.",
452
+ "guard.package.depFileUnclear": "Paket kapısı: {path} içindeki yeni bağımlılıklar okunamadı (dosya ayrıştırılamıyor ya da düzenleme yerleştirilemiyor). Kullanıcı onayı gerekli.",
446
453
  "guard.package.error": "Paket kapısı bu komutu denetleyemedi ({error}). Kullanıcı onayı gerekli.",
447
454
  "guard.package.ask": "Paket kapısı uyarısı: {list}. Kurmadan önce paketin gerçekten istenen paket olduğundan emin ol.",
455
+ "guard.package.notRegistry": "açık kayıt dışından ({source}); kapı bunu denetleyemez",
456
+ "guard.package.otherEco": "kapı {eco} kaydını denetlemez",
457
+ "guard.package.configFile": "Paket kapısı: {path} paketlerin nereden kurulacağını belirliyor (kayıt, dizin ya da kimlik bilgisi). Kullanıcı onayı gerekli.",
448
458
  "guard.structure.offMap": "\"{rel}\" yapı haritasında yok (.claude/structure.json). Doğru yer muhtemelen şunlardan biri: {top}. Geçici veya deneme ise sandbox/ altına yaz. Kalıcı yeni bir yer gerekiyorsa bunu teknik karar olarak sor ve haritayı güncelle (docs/architecture/structure.md).",
449
459
  "guard.structure.prefix": "Yapı: {reason}",
460
+ "guard.structure.longPath": "{length} karakterlik bir yol yapı haritasıyla denetlenmez (1024 üstü). Kullanıcı onayı gerekli.",
461
+ "guard.structure.brokenMap": ".claude/structure.json ayrıştırılamıyor, bu yüzden \"{rel}\" haritayla denetlenemiyor. Haritayı düzelt (JSON) ya da bu dosyayı onayla.",
462
+ "guard.structure.clutterShell": "Komut \"{rel}\" dosyasını oluşturuyor; bu bir dağınıklık desenine uyuyor (yedek, eski sürüm, kopya ya da geçici dosya). Git geçmişi zaten yedek; denemeler için sandbox/ kullan. Kullanıcı onayı gerekli.",
450
463
  "guard.test.skip": "test atlanıyor veya tek teste odaklanıyor (.skip / .only / skipIf / xit / pytest skip)",
451
464
  "guard.test.expectRemoved": "beklenti (expect/assert) siliniyor veya yoruma alınıyor",
452
465
  "guard.test.expectWeak": "beklenti gevşetiliyor (toBeTruthy, toBeDefined, expect.anything, matcher'sız expect, assert True)",
@@ -113,6 +113,11 @@
113
113
  {
114
114
  "type": "command",
115
115
  "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/test-guard.mjs\""
116
+ },
117
+ {
118
+ "type": "command",
119
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/package-gate.mjs\"",
120
+ "timeout": 30
116
121
  }
117
122
  ]
118
123
  },
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  </p>
7
7
 
8
8
  <p align="center">
9
- <a href="kit/CHANGELOG.md"><img alt="Version" src="https://img.shields.io/badge/version-3.49.10-2a78d6?style=flat-square"></a>
9
+ <a href="kit/CHANGELOG.md"><img alt="Version" src="https://img.shields.io/badge/version-3.49.12-2a78d6?style=flat-square"></a>
10
10
  <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-2a78d6?style=flat-square"></a>
11
11
  <img alt="Claude Code" src="https://img.shields.io/badge/Claude%20Code-ready-d97757?style=flat-square">
12
12
  <img alt="Also Codex and Antigravity" src="https://img.shields.io/badge/also-Codex%20%C2%B7%20Antigravity-5b6577?style=flat-square">
@@ -236,8 +236,8 @@ A new file outside the map is asked about, clutter like `*.bak` and `*-old.*` is
236
236
  | Guardrail | What it does |
237
237
  |---|---|
238
238
  | Secret guard | Blocks API keys written into code or docs |
239
- | Destructive-command shield | Blocks `DROP`, unconditional `DELETE`, `db reset`; asks before prod deploys and rollbacks |
240
- | Package gate | Blocks packages that don't exist (on install and on `npx`, `uvx` runs); asks about very new ones or lookalikes of popular ones |
239
+ | Destructive-command shield | Blocks `DROP`, unconditional `DELETE`, `db reset` and cloud data deletion; asks before prod deploys, rollbacks and publishing |
240
+ | Package gate | Blocks packages that don't exist (on install, on `npx`, `uvx` runs and in dependency files); asks about very new ones, lookalikes of popular ones and sources it can't check (git, URLs, other registries) |
241
241
  | Structure guard | Asks about files outside the map, blocks clutter files |
242
242
  | Test separation | Only the `test-writer` agent writes test files; the main session and the coding agent are blocked |
243
243
  | Test guard | Asks before a test is deleted, skipped or weakened |
package/README.tr.md CHANGED
@@ -6,7 +6,7 @@
6
6
  </p>
7
7
 
8
8
  <p align="center">
9
- <a href="kit/CHANGELOG.md"><img alt="Sürüm" src="https://img.shields.io/badge/s%C3%BCr%C3%BCm-3.49.10-2a78d6?style=flat-square"></a>
9
+ <a href="kit/CHANGELOG.md"><img alt="Sürüm" src="https://img.shields.io/badge/s%C3%BCr%C3%BCm-3.49.12-2a78d6?style=flat-square"></a>
10
10
  <a href="LICENSE"><img alt="Lisans: MIT" src="https://img.shields.io/badge/lisans-MIT-2a78d6?style=flat-square"></a>
11
11
  <img alt="Claude Code" src="https://img.shields.io/badge/Claude%20Code-haz%C4%B1r-d97757?style=flat-square">
12
12
  <img alt="Codex ve Antigravity" src="https://img.shields.io/badge/ayr%C4%B1ca-Codex%20%C2%B7%20Antigravity-5b6577?style=flat-square">
@@ -236,8 +236,8 @@ Harita dışı yeni dosya sorulur, `*.bak` ve `*-old.*` gibi dağınıklık dosy
236
236
  | Katman | Ne yapar |
237
237
  |---|---|
238
238
  | Sır engeli | Koda veya dokümana yazılan API anahtarını engeller |
239
- | Yıkıcı komut kalkanı | `DROP`, koşulsuz `DELETE`, `db reset` engellenir; prod deploy ve geri alma sorulur |
240
- | Paket kapısı | Var olmayan paketi engeller (kurulumda ve `npx`, `uvx` ile çalıştırmada); çok yeni veya popüler bir pakete benzeyeni sorar |
239
+ | Yıkıcı komut kalkanı | `DROP`, koşulsuz `DELETE`, `db reset` ve bulut verisini silmek engellenir; prod deploy, geri alma ve yayımlama sorulur |
240
+ | Paket kapısı | Var olmayan paketi engeller (kurulumda, `npx`, `uvx` ile çalıştırmada ve bağımlılık dosyalarında); çok yeni, popüler bir pakete benzeyen ve denetleyemediği kaynakları (git, URL, başka kayıt) sorar |
241
241
  | Yapı koruyucu | Harita dışı dosyayı sorar, dağınıklık dosyalarını engeller |
242
242
  | Test ayrımı | Test dosyasına yalnız `test-writer` ajanı yazar; ana oturum ve kod ajanı denerse engellenir |
243
243
  | Test koruması | Silinen, atlanan veya gevşetilen testi sorar |
package/kit/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
3
3
  The kit version is in `kit/VERSION`. To update a project, run `/kit-update` in it.
4
4
  Items with an "Adapt by hand" note concern files owned by the project; the update does not touch them.
5
5
 
6
+ ## [3.49.12] - 2026-10-30
7
+
8
+ ### Security
9
+ - **The package gate asks about installs it cannot check.** A git source, a URL or a GitHub shorthand (`npm i github:a/b`, `npm i user/repo`, `pip install git+https://…`, `npx github:a/b`) and a different registry or index (`--registry`, `--@scope:registry`, `--userconfig`, `pip install -i`/`--index-url`/`--extra-index-url`/`--find-links`, attached `-ihttps://…` too, an `npm_config_registry=` or `PIP_INDEX_URL=` set anywhere in the command, `npm config set registry` and `pip config set …index-url` in any spelling, `poetry source add`, a shell write to `.npmrc`, `.yarnrc.yml`, `pip.conf`, `uv.toml`, `poetry.toml`, `.pypirc` or `bunfig.toml`) are asked instead of passing unchecked. Writing one of those registry config files with a file tool is asked too. Local paths (`./lib`, `~/x`, `file:`, `-e .`) stay free.
10
+ - `cargo add`/`install`, `go get`/`install`, `gem install`, `bundle add` and `composer require` are asked: the gate does not query those registries. Local installs (`cargo install --path .`, `go install ./cmd/x`) stay free.
11
+ - **Dependency files:** in `package.json` a dependency whose source changes to git, a URL or `user/repo` is asked, and names added under `overrides`, `resolutions` and `pnpm.overrides` are checked. `pyproject.toml` also reads `[project.optional-dependencies]`, `[dependency-groups]`, `[tool.uv] dev-dependencies`, `[build-system] requires`, PDM and Hatch dependency lists, dotted keys and Poetry sub-tables; a quoted `]` no longer ends the list early; a direct reference (`pkg @ git+https://…`), a Poetry `git`/`url` table, a `[tool.uv.sources]` git source and a custom index (`[[tool.uv.index]]`) are asked. In `requirements*.txt` a git or URL line, an index option and a remote `-r` file are asked.
12
+ - **More cloud deletions are blocked:** `aws s3 rm --recursive`, `aws s3 rb --force`, `aws s3api delete-bucket`, `aws rds delete-db-instance`/`delete-db-cluster`, `aws dynamodb delete-table`, `gcloud projects delete`, `az resource delete`, `aws ec2 terminate-instances`, `gcloud sql`/`compute instances delete`, `gsutil rm -r`, `az group delete`, `heroku apps:destroy`, `fly apps`/`volumes destroy`, `supabase projects delete`, `netlify sites:delete` and `gh repo delete`, also with options before or after the service (`aws --profile x s3 --region r …`, `heroku -a x …`).
13
+ - **More deletions and publishes ask you:** `kubectl delete`, `helm uninstall`, `railway down`, `vercel remove`/`env rm`/`domains rm`, deleting the main branch (`git branch -D main`), `npm`/`pnpm`/`yarn publish` and `unpublish`, with workspace flags too (not `--dry-run`), `npm deprecate`/`dist-tag`, `cargo publish`, `twine upload`, `lerna`/`changeset publish`, `gh release create`/`delete`, `docker system prune -a`; options before the subcommand (`helm -n ns uninstall`) and `.exe`/`.cmd` tool names do not hide them.
14
+ - **Clutter files made from the shell are asked:** `cp a.ts a.bak`, `mv a.ts a-old.ts`, `echo x > notes-old.md`, `tee a.bak`, `Copy-Item a.ts a.bak` meet the same clutter list as the file tools. They are asked rather than blocked, since the shell text is read by heuristics; quoted text, heredocs and arrows (`=>`) are not taken for file names.
15
+ - A shell command over 10 000 characters is not matched as a whole (some shield rules are not linear in the length, and a slow hook holds up the session): its short lines are still checked, so a blocked command stays blocked; otherwise it is asked, naming a guarded word it contains.
16
+ - A `.claude/structure.json` that does not parse no longer switches the structure guard off: new files are asked and `--check` fails. A path over 1024 characters is asked instead of matched.
17
+ - **The secret guard sees keys split across edits:** a key that only appears once a MultiEdit's (or an Antigravity chunk edit's) pieces land in the file is blocked. On Antigravity every tool's arguments are scanned, so a write tool the guard does not know is covered; on Codex a patch passed in `input` instead of `command` is read.
18
+
19
+ ## [3.49.11] - 2026-10-30
20
+
21
+ ### Security
22
+ - **The package gate also checks dependency files.** When the AI adds a dependency to `package.json` (any section, an `npm:` alias's target too), `requirements*.txt` or `pyproject.toml` (`[project] dependencies`, Poetry dependency tables) with a file tool, the new names are checked like an install: a missing package is blocked, a new, rarely downloaded or look-alike one is asked. Before, editing `package.json` and then running a bare `npm ci` skipped the gate. Version changes and other fields are not checked. Runs on Claude Code, Codex and Antigravity; a file that does not parse after the write asks.
23
+
6
24
  ## [3.49.10] - 2026-10-30
7
25
 
8
26
  ### Security
package/kit/VERSION CHANGED
@@ -1 +1 @@
1
- 3.49.10
1
+ 3.49.12
package/kit/guide.html CHANGED
@@ -361,7 +361,7 @@ footer .container { display: flex; flex-wrap: wrap; gap: 16px; justify-content:
361
361
  <section class="hero">
362
362
  <div class="container">
363
363
  <div>
364
- <span class="pill"><b>v3.49.10</b> Starter kit for Claude Code · MIT</span>
364
+ <span class="pill"><b>v3.49.12</b> Starter kit for Claude Code · MIT</span>
365
365
  <h1>The AI builds.<br><em>You steer.</em></h1>
366
366
  <p class="sub">TillerHand takes a product from idea to production and maintenance with Claude Code. At every important decision the AI gives you the options, its recommendation and the cost of each option. An expert board reviews the work, and guardrails stop mistakes before they happen.</p>
367
367
  <div class="setup">
@@ -941,9 +941,9 @@ npx tillerhand init
941
941
  <thead><tr><th>Layer</th><th>What it does</th></tr></thead>
942
942
  <tbody>
943
943
  <tr><td>Secret block</td><td>Blocks API keys (Anthropic, OpenAI, Stripe, AWS, GitHub, Supabase…) written to code or docs; also scans before a commit</td></tr>
944
- <tr><td>Destructive command shield</td><td><code>DROP</code>, <code>TRUNCATE</code>, unconditional <code>DELETE</code>, <code>db reset</code> and pulling prod environment variables onto the machine are blocked; prod deploy, rollback, store submission and migrations are asked</td></tr>
945
- <tr><td>Package gate</td><td>A package that is installed, or downloaded and run with <code>npx</code> or <code>uvx</code>, is checked against the registry: if it doesn't exist it is blocked; if it is very new, rarely downloaded or looks like a popular package, it is asked</td></tr>
946
- <tr><td>Structure guard</td><td>A new file outside the map is asked; clutter such as <code>*.bak</code>, <code>*-old.*</code>, copies and <code>temp/</code> is blocked</td></tr>
944
+ <tr><td>Destructive command shield</td><td><code>DROP</code>, <code>TRUNCATE</code>, unconditional <code>DELETE</code>, <code>db reset</code> pulling prod environment variables onto the machine and deleting cloud data (S3 buckets, databases, resource groups, apps, repositories) are blocked; prod deploy, rollback, store submission, migrations, cluster and hosting deletes, package and release publishing and deleting the main branch are asked</td></tr>
945
+ <tr><td>Package gate</td><td>A package that is installed, or downloaded and run with <code>npx</code> or <code>uvx</code>, is checked against the registry: if it doesn't exist it is blocked; if it is very new, rarely downloaded or looks like a popular package, it is asked. A git or URL source, another registry or index, and cargo, go, gem and composer installs are asked, since they cannot be checked. Dependencies the AI adds to <code>package.json</code>, <code>requirements.txt</code> or <code>pyproject.toml</code> go through the same check</td></tr>
946
+ <tr><td>Structure guard</td><td>A new file outside the map is asked; clutter such as <code>*.bak</code>, <code>*-old.*</code>, copies and <code>temp/</code> is blocked; made from the shell (<code>cp a.ts a.bak</code>) it is asked</td></tr>
947
947
  <tr><td>Config approval</td><td><code>CLAUDE.md</code>, <code>AGENTS.md</code>, <code>.claude/</code>, <code>.codex/</code>, <code>.mcp.json</code> and workflow files don't change without your approval</td></tr>
948
948
  <tr><td>Test separation</td><td>The tests are written before the code by a separate agent (<code>test-writer</code>, a different model is recommended), not by whoever writes the code. Nobody but this agent can write to a test file: the main session and the code agent are blocked if they try. A test file changed indirectly by a patch or script also stops at the commit gate. So whoever writes the code can't shape the test to fit their own code</td></tr>
949
949
  <tr><td>Test protection</td><td>Deleting or commenting out an assertion, <code>.skip</code>/<code>.only</code>, a loosened assertion, deleting or moving a test file, and narrowing coverage in the test config are asked; so is writing to a test file through the shell</td></tr>
@@ -1241,7 +1241,7 @@ Next: G-013 password reset · pending decision: email service</pre>
1241
1241
  <footer>
1242
1242
  <div class="container">
1243
1243
  <a class="brand" href="#top" style="font-size:16px"><svg class="mark" style="width:22px;height:22px"><use href="#mark"/></svg>TillerHand</a>
1244
- <span>Version 3.49.10 · <a href="https://github.com/burakdogrul/TillerHand">GitHub</a> · <a href="https://github.com/burakdogrul/TillerHand/blob/main/LICENSE">MIT license</a> · The KVKK section is a checklist, not legal advice.</span>
1244
+ <span>Version 3.49.12 · <a href="https://github.com/burakdogrul/TillerHand">GitHub</a> · <a href="https://github.com/burakdogrul/TillerHand/blob/main/LICENSE">MIT license</a> · The KVKK section is a checklist, not legal advice.</span>
1245
1245
  </div>
1246
1246
  </footer>
1247
1247
 
package/kit/guide.tr.html CHANGED
@@ -361,7 +361,7 @@ footer .container { display: flex; flex-wrap: wrap; gap: 16px; justify-content:
361
361
  <section class="hero">
362
362
  <div class="container">
363
363
  <div>
364
- <span class="pill"><b>v3.49.10</b> Claude Code için başlangıç kiti · MIT</span>
364
+ <span class="pill"><b>v3.49.12</b> Claude Code için başlangıç kiti · MIT</span>
365
365
  <h1>Kodu AI yazar.<br><em>Dümen sende.</em></h1>
366
366
  <p class="sub">TillerHand, Claude Code ile bir ürünü fikirden canlıya ve bakıma kadar götürür. Her önemli kararda AI sana seçenekleri, önerisini ve her seçeneğin bedelini sunar. Bir uzman kurulu işi denetler, korumalar hatayı daha olmadan durdurur.</p>
367
367
  <div class="setup">
@@ -944,9 +944,9 @@ npx tillerhand init
944
944
  <thead><tr><th>Katman</th><th>Ne yapar</th></tr></thead>
945
945
  <tbody>
946
946
  <tr><td>Sır engeli</td><td>Koda veya dokümana yazılan API anahtarını (Anthropic, OpenAI, Stripe, AWS, GitHub, Supabase…) engeller; commit öncesi de tarar</td></tr>
947
- <tr><td>Yıkıcı komut kalkanı</td><td><code>DROP</code>, <code>TRUNCATE</code>, koşulsuz <code>DELETE</code>, <code>db reset</code> ve prod ortam değişkenlerini makineye çekmek engellenir; prod deploy, geri alma, mağazaya gönderim ve migration sorulur</td></tr>
948
- <tr><td>Paket kapısı</td><td>Kurulan ya da <code>npx</code>, <code>uvx</code> ile indirilip çalıştırılan paket kayıt defterinden doğrulanır: yoksa engellenir; çok yeni, az indirilmiş veya popüler bir pakete benziyorsa sorulur</td></tr>
949
- <tr><td>Yapı koruyucu</td><td>Harita dışı yeni dosya sorulur; <code>*.bak</code>, <code>*-old.*</code>, kopya ve <code>temp/</code> gibi dağınıklıklar engellenir</td></tr>
947
+ <tr><td>Yıkıcı komut kalkanı</td><td><code>DROP</code>, <code>TRUNCATE</code>, koşulsuz <code>DELETE</code>, <code>db reset</code> , prod ortam değişkenlerini makineye çekmek ve bulut verisini silmek (S3 kovası, veritabanı, kaynak grubu, uygulama, depo) engellenir; prod deploy, geri alma, mağazaya gönderim, migration, küme ve barındırma silmeleri, paket ve sürüm yayımlama ve ana dalı silmek sorulur</td></tr>
948
+ <tr><td>Paket kapısı</td><td>Kurulan ya da <code>npx</code>, <code>uvx</code> ile indirilip çalıştırılan paket kayıt defterinden doğrulanır: yoksa engellenir; çok yeni, az indirilmiş veya popüler bir pakete benziyorsa sorulur. Git ya da URL kaynağı, başka bir kayıt ya da dizin ve cargo, go, gem, composer kurulumları denetlenemediği için sorulur. AI'nın <code>package.json</code>, <code>requirements.txt</code> ya da <code>pyproject.toml</code>'a eklediği bağımlılıklar da aynı denetimden geçer</td></tr>
949
+ <tr><td>Yapı koruyucu</td><td>Harita dışı yeni dosya sorulur; <code>*.bak</code>, <code>*-old.*</code>, kopya ve <code>temp/</code> gibi dağınıklıklar engellenir; kabukta oluşturulursa (<code>cp a.ts a.bak</code>) sorulur</td></tr>
950
950
  <tr><td>Yapılandırma onayı</td><td><code>CLAUDE.md</code>, <code>AGENTS.md</code>, <code>.claude/</code>, <code>.codex/</code>, <code>.mcp.json</code> ve iş akışı dosyaları onayın olmadan değişmez</td></tr>
951
951
  <tr><td>Test ayrımı</td><td>Testleri kodu yazan değil, ayrı bir ajan (<code>test-writer</code>, farklı model önerilir) koddan önce yazar. Test dosyasına bu ajan dışında kimse yazamaz: ana oturum ve kod ajanı denerse engellenir. Patch veya betikle dolaylı değişen test dosyası da commit kapısında durur. Böylece kodu yazan testi kendi koduna göre şekillendiremez</td></tr>
952
952
  <tr><td>Test koruması</td><td>Beklenti silmek veya yoruma almak, <code>.skip</code>/<code>.only</code>, gevşek beklenti, test dosyasını silmek veya taşımak ve test ayarında kapsamı daraltmak sorulur; kabukla test dosyasına yazmak da</td></tr>
@@ -1245,7 +1245,7 @@ Sıradaki: G-013 şifre sıfırlama · bekleyen karar: e-posta servisi</pre>
1245
1245
  <footer>
1246
1246
  <div class="container">
1247
1247
  <a class="brand" href="#top" style="font-size:16px"><svg class="mark" style="width:22px;height:22px"><use href="#mark"/></svg>TillerHand</a>
1248
- <span>Sürüm 3.49.10 · <a href="https://github.com/burakdogrul/TillerHand">GitHub</a> · <a href="https://github.com/burakdogrul/TillerHand/blob/main/LICENSE">MIT lisansı</a> · KVKK bölümü kontrol listesidir, hukuki görüş değildir.</span>
1248
+ <span>Sürüm 3.49.12 · <a href="https://github.com/burakdogrul/TillerHand">GitHub</a> · <a href="https://github.com/burakdogrul/TillerHand/blob/main/LICENSE">MIT lisansı</a> · KVKK bölümü kontrol listesidir, hukuki görüş değildir.</span>
1249
1249
  </div>
1250
1250
  </footer>
1251
1251
 
@@ -12,7 +12,8 @@
12
12
  { "type": "command", "command": "node .claude/hooks/guard-secrets.mjs" },
13
13
  { "type": "command", "command": "node .claude/hooks/freeze-guard.mjs" },
14
14
  { "type": "command", "command": "node .claude/hooks/structure-guard.mjs" },
15
- { "type": "command", "command": "node .claude/hooks/test-guard.mjs" }
15
+ { "type": "command", "command": "node .claude/hooks/test-guard.mjs" },
16
+ { "type": "command", "command": "node .claude/hooks/package-gate.mjs", "timeout": 30 }
16
17
  ]
17
18
  }
18
19
  ]
@@ -15,7 +15,8 @@
15
15
  { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/guard-secrets.mjs\"", "commandWindows": "node .claude/hooks/guard-secrets.mjs" },
16
16
  { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/freeze-guard.mjs\"", "commandWindows": "node .claude/hooks/freeze-guard.mjs" },
17
17
  { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/structure-guard.mjs\"", "commandWindows": "node .claude/hooks/structure-guard.mjs" },
18
- { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/test-guard.mjs\"", "commandWindows": "node .claude/hooks/test-guard.mjs" }
18
+ { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/test-guard.mjs\"", "commandWindows": "node .claude/hooks/test-guard.mjs" },
19
+ { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/package-gate.mjs\"", "commandWindows": "node .claude/hooks/package-gate.mjs", "timeout": 30 }
19
20
  ]
20
21
  },
21
22
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tillerhand",
3
- "version": "3.49.10",
3
+ "version": "3.49.12",
4
4
  "description": "Vibe coding kit for Claude Code, Codex and Antigravity: rules, commands, guards and project templates.",
5
5
  "license": "MIT",
6
6
  "type": "module",