tillerhand 3.49.11 → 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.
- package/.claude/hooks/destructive-guard.mjs +29 -1
- package/.claude/hooks/guard-secrets.mjs +24 -3
- package/.claude/hooks/messages.mjs +12 -0
- package/.claude/hooks/package-gate.mjs +212 -31
- package/.claude/hooks/shared.mjs +2 -1
- package/.claude/hooks/structure-guard.mjs +49 -2
- package/.claude/i18n/tr.json +12 -0
- package/README.md +3 -3
- package/README.tr.md +3 -3
- package/kit/CHANGELOG.md +13 -0
- package/kit/VERSION +1 -1
- package/kit/guide.html +5 -5
- package/kit/guide.tr.html +5 -5
- package/package.json +1 -1
|
@@ -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 ? [
|
|
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
|
-
|
|
42
|
-
|
|
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 ${
|
|
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.' },
|
|
@@ -449,8 +455,14 @@ export const MESSAGES = {
|
|
|
449
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.' },
|
|
450
456
|
'guard.package.error': { en: 'The package gate could not check this command ({error}). User approval required.' },
|
|
451
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.' },
|
|
452
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).' },
|
|
453
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.' },
|
|
454
466
|
'guard.test.skip': { en: 'a test is skipped or focused alone (.skip / .only / skipIf / xit / pytest skip)' },
|
|
455
467
|
'guard.test.expectRemoved': { en: 'an expectation (expect/assert) is deleted or commented out' },
|
|
456
468
|
'guard.test.expectWeak': { en: 'an expectation is loosened (toBeTruthy, toBeDefined, expect.anything, expect without a matcher, assert True)' },
|
|
@@ -30,9 +30,57 @@ const COMMANDS = [
|
|
|
30
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'],
|
|
31
31
|
// create/init <name> downloads and runs create-<name>; deno takes npm:<name> specifiers
|
|
32
32
|
[/^(npm|pnpm|yarn|bun)\s+(create|init)\b/i, 'npm', 'create'], [/^deno\s+(add|install|run|x)\b/i, 'npm', 'deno'],
|
|
33
|
-
|
|
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'],
|
|
34
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'],
|
|
35
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 });
|
|
36
84
|
// create-vite for vite, @scope/create for @scope, @scope/create-x for @scope/x (npm's own mapping)
|
|
37
85
|
function createName(arg) {
|
|
38
86
|
const n = npmName(arg);
|
|
@@ -103,7 +151,7 @@ export function normalize(part) {
|
|
|
103
151
|
return s;
|
|
104
152
|
}
|
|
105
153
|
|
|
106
|
-
const NOT_REGISTRY = /^(
|
|
154
|
+
const NOT_REGISTRY = /^(\.|\/|~[\\/]|file:|link:|git\+|git:|https?:|github:|workspace:)/;
|
|
107
155
|
function npmName(arg) {
|
|
108
156
|
// name@npm:other installs "other": the alias target is checked (a loop: a deep a@npm:a@npm:… chain must not overflow
|
|
109
157
|
// the stack). name@github:x/y, @https:… are not from the registry (like a bare URL, not checked here).
|
|
@@ -123,18 +171,37 @@ function pypiName(arg) {
|
|
|
123
171
|
|
|
124
172
|
export function findPackages(command) {
|
|
125
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
|
+
}
|
|
126
178
|
// Every place a new command can start: chains, a newline, a background &, $( ), backticks, ( ) and { } groups.
|
|
127
|
-
// A redirect's & (2>&1, &>) is not a separator.
|
|
128
|
-
for (const
|
|
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);
|
|
129
184
|
const s = normalize(part);
|
|
130
185
|
const match = COMMANDS.find(([re]) => re.test(s));
|
|
131
186
|
const exec = match ? null : EXEC.find((e) => e.re.test(s));
|
|
132
187
|
if (!match && !exec) continue;
|
|
133
188
|
const re = match?.[0] ?? exec.re, eco = match?.[1] ?? exec.eco;
|
|
134
|
-
// Redirects (2>&1, > log, 2>/dev/null, &> x) are not arguments; a quoted "pkg>=1" has no space before ">" and stays
|
|
135
|
-
|
|
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;
|
|
136
202
|
if (exec) {
|
|
137
203
|
for (const { arg, fromFlag, unsure } of execArgs(args, eco, exec.positional)) {
|
|
204
|
+
if (source(arg) === 'remote') { result.push(remote(eco, arg)); continue; }
|
|
138
205
|
const name = eco === 'npm' ? npmName(arg) : pypiName(arg);
|
|
139
206
|
if (name) result.push({ eco, name, exec: true, spec: arg, ...(exec.local && !fromFlag && { local: true }), ...(unsure && { unsure: true }) });
|
|
140
207
|
}
|
|
@@ -152,9 +219,23 @@ export function findPackages(command) {
|
|
|
152
219
|
for (let i = 0; i < args.length; i++) {
|
|
153
220
|
const a = args[i];
|
|
154
221
|
if (a.startsWith('-')) {
|
|
155
|
-
if (
|
|
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
|
+
}
|
|
156
236
|
continue;
|
|
157
237
|
}
|
|
238
|
+
if (source(a) === 'remote') { result.push(remote(eco, a)); continue; }
|
|
158
239
|
const name = eco === 'npm' ? npmName(a) : pypiName(a);
|
|
159
240
|
if (name) result.push({ eco, name });
|
|
160
241
|
}
|
|
@@ -166,41 +247,137 @@ export function findPackages(command) {
|
|
|
166
247
|
// 2026-10-30), so "edit package.json, then a bare npm ci" cannot skip the gate. null: the result does not parse (asked).
|
|
167
248
|
const DEP_FILE = /(^|\/)(package\.json|pyproject\.toml|requirements[^/]*\.txt|requirements\/[^/]+\.txt)$/i;
|
|
168
249
|
const NPM_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
function
|
|
175
|
-
const
|
|
176
|
-
|
|
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 = [];
|
|
177
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/), '[]']) {
|
|
178
332
|
const line = raw.trim();
|
|
179
|
-
const head = line.match(/^\[([^\]
|
|
180
|
-
if (head) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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)));
|
|
189
358
|
}
|
|
190
359
|
}
|
|
191
|
-
return
|
|
360
|
+
return out;
|
|
192
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.
|
|
193
364
|
export function addedDependencies(file, before, after) {
|
|
194
365
|
const rel = file.replace(/\\/g, '/');
|
|
195
|
-
const added = (eco, a, b) =>
|
|
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
|
+
};
|
|
196
373
|
if (/(^|\/)package\.json$/i.test(rel)) {
|
|
197
374
|
let a, b = {};
|
|
198
375
|
try { a = JSON.parse(after); } catch { return null; }
|
|
199
376
|
try { b = JSON.parse(before || '{}'); } catch { /* a broken file before: every name counts as added */ }
|
|
200
377
|
return added('npm', npmDeps(a), npmDeps(b));
|
|
201
378
|
}
|
|
202
|
-
if (/(^|\/)requirements[^/]*\.txt$|(^|\/)requirements\/[^/]+\.txt$/i.test(rel)) return added('pypi',
|
|
203
|
-
if (/(^|\/)pyproject\.toml$/i.test(rel)) return added('pypi',
|
|
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));
|
|
204
381
|
return [];
|
|
205
382
|
}
|
|
206
383
|
|
|
@@ -273,6 +450,7 @@ async function gate(g) {
|
|
|
273
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
|
|
274
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 ?? '')));
|
|
275
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 }));
|
|
276
454
|
if (!DEP_FILE.test(String(f.path).replace(/\\/g, '/'))) continue;
|
|
277
455
|
const full = path.resolve(g.dir, f.path);
|
|
278
456
|
let disk = ''; try { disk = fs.readFileSync(full, 'utf8'); } catch { /* new file */ }
|
|
@@ -283,14 +461,17 @@ async function gate(g) {
|
|
|
283
461
|
}
|
|
284
462
|
if (!packages.length) return;
|
|
285
463
|
if (packages.length > MAX_PACKAGES) decide(g.tool, 'ask', t(g.root, 'guard.package.tooMany', { count: packages.length, max: MAX_PACKAGES }));
|
|
286
|
-
|
|
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)) })));
|
|
287
468
|
const missing = results.filter((s) => s.status === 'missing' && !s.unsure);
|
|
288
469
|
if (missing.length) {
|
|
289
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.`);
|
|
290
471
|
}
|
|
291
472
|
const suspect = results.filter((s) => s.status !== 'ok');
|
|
292
473
|
if (suspect.length) {
|
|
293
|
-
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('; ') }));
|
|
294
475
|
}
|
|
295
476
|
process.exit(0);
|
|
296
477
|
}
|
package/.claude/hooks/shared.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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; }
|
package/.claude/i18n/tr.json
CHANGED
|
@@ -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.",
|
|
@@ -446,8 +452,14 @@
|
|
|
446
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.",
|
|
447
453
|
"guard.package.error": "Paket kapısı bu komutu denetleyemedi ({error}). Kullanıcı onayı gerekli.",
|
|
448
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.",
|
|
449
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).",
|
|
450
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.",
|
|
451
463
|
"guard.test.skip": "test atlanıyor veya tek teste odaklanıyor (.skip / .only / skipIf / xit / pytest skip)",
|
|
452
464
|
"guard.test.expectRemoved": "beklenti (expect/assert) siliniyor veya yoruma alınıyor",
|
|
453
465
|
"guard.test.expectWeak": "beklenti gevşetiliyor (toBeTruthy, toBeDefined, expect.anything, matcher'sız expect, assert True)",
|
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.
|
|
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
|
|
240
|
-
| Package gate | Blocks packages that don't exist (on install
|
|
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.
|
|
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
|
|
240
|
-
| Paket kapısı | Var olmayan paketi engeller (kurulumda
|
|
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,19 @@
|
|
|
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
|
+
|
|
6
19
|
## [3.49.11] - 2026-10-30
|
|
7
20
|
|
|
8
21
|
### Security
|
package/kit/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.49.
|
|
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.
|
|
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>
|
|
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.
|
|
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.
|
|
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>
|
|
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.
|
|
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
|
|