@starci/hfs 1.0.0 → 2.0.0
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/CHANGELOG.md +57 -0
- package/README.md +116 -12
- package/bin/hfs.mjs +119 -15
- package/emit/compiler.mjs +35 -0
- package/emit/contracts.mjs +97 -0
- package/emit/operations-worker.mjs +24 -0
- package/emit/operations.mjs +126 -0
- package/emit/schema-worker.mjs +117 -0
- package/emit/static-graph.mjs +670 -0
- package/emit/type-schema.mjs +145 -0
- package/package.json +10 -2
- package/report/sonar.mjs +180 -0
- package/runtime/engine/admission.mjs +284 -0
- package/runtime/engine/digest.mjs +10 -0
- package/runtime/engine/ledger-db.mjs +1245 -0
- package/runtime/engine/machine-db.mjs +1484 -0
- package/runtime/engine/migrations/machine/0001-init.sql +887 -0
- package/runtime/engine/migrations/runtime/0001-init.sql +1072 -0
- package/runtime/engine/migrations/runtime/0003-usage-unavailable.sql +13 -0
- package/runtime/engine/migrations/runtime/0004-attempt-why.sql +43 -0
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +16 -58
- package/runtime/knowledge/hfs/slots.yaml +409 -140
- package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
- package/runtime/knowledge/sonar-gate.yaml +85 -0
- package/runtime/modules/kernel/failure-codes.yaml +1480 -16
- package/runtime/scripts/checks/architecture/backend.mjs +350 -0
- package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
- package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
- package/runtime/scripts/checks/architecture/clones.mjs +200 -0
- package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
- package/runtime/scripts/checks/architecture/config.mjs +310 -0
- package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
- package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
- package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
- package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
- package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
- package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
- package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
- package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
- package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
- package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
- package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
- package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
- package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
- package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
- package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
- package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
- package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
- package/runtime/scripts/checks/architecture/index.mjs +316 -0
- package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
- package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
- package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
- package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
- package/runtime/scripts/checks/architecture/owners.mjs +89 -0
- package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
- package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
- package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
- package/runtime/scripts/checks/architecture/registration.mjs +319 -0
- package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
- package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
- package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
- package/runtime/scripts/checks/architecture/size-growth.mjs +73 -0
- package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
- package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
- package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
- package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
- package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
- package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
- package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
- package/runtime/scripts/checks/architecture/typescript.mjs +500 -0
- package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
- package/runtime/scripts/checks/architecture.mjs +41 -0
- package/runtime/scripts/checks/common.mjs +37 -0
- package/runtime/scripts/checks/typescript-programs.mjs +82 -0
- package/runtime/scripts/lib/artifact-hold.mjs +89 -0
- package/runtime/scripts/lib/artifact-store.mjs +103 -0
- package/runtime/scripts/lib/fs-kind.mjs +10 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/hfs-allows.mjs +57 -0
- package/runtime/scripts/lib/hfs-check.mjs +254 -28
- package/runtime/scripts/lib/hfs-rules/contract.mjs +126 -0
- package/runtime/scripts/lib/hfs-rules/deps.mjs +63 -0
- package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
- package/runtime/scripts/lib/hfs-rules/frontend.mjs +124 -0
- package/runtime/scripts/lib/hfs-rules/lint-suppression.mjs +34 -0
- package/runtime/scripts/lib/hfs-rules/pipeline.mjs +51 -0
- package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +64 -0
- package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
- package/runtime/scripts/lib/hfs-rules/repo-local-checks.mjs +32 -0
- package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +31 -0
- package/runtime/scripts/lib/hfs-rules/stacks.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/test-topology.mjs +31 -0
- package/runtime/scripts/lib/hfs-slots.mjs +95 -41
- package/runtime/scripts/lib/hfs-tree.mjs +80 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/json.mjs +22 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/path-key.mjs +2 -0
- package/runtime/scripts/lib/redact.mjs +148 -0
- package/runtime/scripts/lib/repo-identity.mjs +50 -0
- package/runtime/scripts/lib/safe-remove.mjs +179 -0
- package/runtime/scripts/lib/secret-patterns.mjs +44 -0
- package/runtime/scripts/lib/sleep-sync.mjs +17 -0
- package/runtime/scripts/lib/stack-declaration.mjs +52 -0
- package/runtime/scripts/lib/stack-services.mjs +217 -0
- package/runtime/scripts/lib/test-secrets.mjs +120 -0
- package/scaffold/service.mjs +333 -0
- package/sync/format.mjs +46 -0
- package/sync/hygiene.mjs +56 -24
- package/sync/index.mjs +126 -41
- package/sync/managed.mjs +170 -0
- package/sync/skeleton.mjs +32 -10
- package/sync/sonar-key.mjs +13 -0
- package/sync/ts-strict.mjs +48 -0
- package/templates/{common → be/ci-workflows/github/workflows}/ci.yml +13 -13
- package/templates/be/{e2e.yml → ci-workflows/github/workflows/e2e.yml} +1 -0
- package/templates/be/hooks/husky/pre-commit +13 -0
- package/templates/be/hooks/husky/pre-push +7 -0
- package/templates/be/package-scripts/package.json +21 -0
- package/templates/be/{sonar-project.properties → quality-config/sonar-project.properties} +2 -3
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +14 -4
- package/templates/be/skeleton/apps/__app__/src/main.ts +9 -6
- package/templates/be/skeleton/scripts/.gitkeep +0 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.contracts.ts +2 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.handler.ts +22 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.query.ts +11 -0
- package/templates/be/skeleton/src/features/system-health/index.ts +1 -1
- package/templates/be/skeleton/src/features/system-health/system-health.module.ts +3 -3
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +8 -9
- package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +3 -6
- package/templates/be/skeleton/src/modules/domain/liveness/index.ts +3 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.contracts.ts +9 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.module.ts +14 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.options.ts +2 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.spec.ts +40 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.ts +26 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.module.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.port.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/clock/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.spec.ts +20 -0
- package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -0
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/config/index.ts +2 -3
- package/templates/be/skeleton/src/modules/platform/config/server.config.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/config/server.options.ts +0 -3
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.handler.ts +27 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.log-events.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module.ts +18 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +8 -12
- package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/errors/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/logging/index.ts +4 -4
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +99 -0
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +38 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +23 -10
- package/templates/be/skeleton/src/modules/platform/logging/logging.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +15 -0
- package/templates/be/tool-config/eslint.config.mjs +3 -0
- package/templates/be/tool-config/jest.config.js +1 -0
- package/templates/be/tool-config/prettierignore +8 -0
- package/templates/be/tool-config/prettierrc +1 -0
- package/templates/be/tool-config/src/tests/tsconfig.json +5 -0
- package/templates/be/tool-config/tsconfig.build.json +5 -0
- package/templates/be/tool-config/tsconfig.json +11 -0
- package/templates/common/gitignore.base +1 -1
- package/templates/fe/ci-workflows/github/workflows/ci.yml +54 -0
- package/templates/fe/hooks/husky/pre-commit +16 -0
- package/templates/fe/hooks/husky/pre-push +6 -0
- package/templates/fe/package-scripts/package.json +17 -0
- package/templates/fe/parts/api-client.ts +44 -0
- package/templates/fe/parts/api-outcome.ts +7 -0
- package/templates/fe/quality-config/sonar-project.properties +8 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +1 -1
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +1 -1
- package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +1 -1
- package/templates/fe/skeleton/scripts/.gitkeep +0 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +1 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +3 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +1 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +4 -0
- package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +12 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +2 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +9 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +5 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +5 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/package.json +12 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +1 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +3 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +1 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +5 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +18 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +19 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +2 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +12 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +15 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +5 -0
- package/templates/fe/tool-config/eslint.config.mjs +3 -0
- package/templates/fe/tool-config/prettierignore +10 -0
- package/templates/fe/tool-config/prettierrc +1 -0
- package/templates/fe/tool-config/stylelint.config.mjs +3 -0
- package/templates/fe/tool-config/tsconfig.json +4 -0
- package/templates/be/pre-commit +0 -8
- package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +0 -31
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +0 -13
- package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +0 -36
- package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +0 -19
- package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +0 -15
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +0 -38
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +0 -33
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +0 -35
- package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +0 -9
- package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +0 -19
- package/templates/common/codecov.yml +0 -13
- package/templates/common/pre-push +0 -5
- package/templates/fe/e2e.yml +0 -22
- package/templates/fe/pre-commit +0 -7
- package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +0 -10
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +0 -27
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +0 -10
- package/templates/fe/sonar-project.properties +0 -11
- /package/templates/be/skeleton/src/modules/platform/config/{env-source.ts → env-source.config.ts} +0 -0
- /package/templates/be/skeleton/src/modules/platform/errors/{domain-error.ts → domain.error.ts} +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/next.config.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/config.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/navigation.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/request.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/routing.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/proxy.ts +0 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// redact.mjs — the ONE redaction module (alpha.3, ARCHITECTURE-DB §4.2). Every blob put of text bytes
|
|
2
|
+
// (report attachments, check stdout/stderr/output, terminal transcripts, CLI sessions) and every log write
|
|
3
|
+
// passes through here first; the harness UI server reuses it. A blob whose bytes went through redactText is
|
|
4
|
+
// stamped blobs.redaction = REDACTION_VERSION; bytes that cannot be filtered (images, video, archives) are
|
|
5
|
+
// stamped 'binary'.
|
|
6
|
+
//
|
|
7
|
+
// It blanks: provider tokens and JWTs (secret-patterns.mjs, the push scan's list), auth headers, URL query
|
|
8
|
+
// secrets, URL credentials (scheme://user:pass@host), PEM private-key blocks (the whole block), KEY=value /
|
|
9
|
+
// KEY: value pairs whose key names a secret (FOO_API_KEY=..., password: ...), and the declared secrets of a
|
|
10
|
+
// product repo's .starcistacks (their env-style names as keys, and their values wherever they appear).
|
|
11
|
+
// Keys and surrounding words stay, so a redacted transcript still reads.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { FORBIDDEN_FILES, SECRET_PATTERNS } from './secret-patterns.mjs';
|
|
15
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
16
|
+
|
|
17
|
+
export const REDACTION_VERSION = 'v1';
|
|
18
|
+
export const MARK = '[redacted]';
|
|
19
|
+
|
|
20
|
+
const withGlobal = (re) => new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
|
|
21
|
+
const PATTERNS = SECRET_PATTERNS.filter((rule) => rule.name !== 'private-key-block').map((rule) => ({ ...rule, g: withGlobal(rule.re) }));
|
|
22
|
+
// Words that make an env-style key a secret, matched per `_`-separated segment of the name (FOO_API_KEY,
|
|
23
|
+
// KEYCLOAK_ADMIN_PASSWORD, GH_TOKEN, MINIO_SECRET_KEY, DATABASE_URL is not; TESTS_PASSED is not).
|
|
24
|
+
const SECRET_SEGMENTS = new Set(['PASSWORD', 'PASSWD', 'PWD', 'PASS', 'SECRET', 'SECRETS', 'TOKEN', 'APIKEY', 'KEY', 'CREDENTIAL', 'CREDENTIALS', 'COOKIE', 'OTP', 'DSN', 'PAT']);
|
|
25
|
+
export const isSecretEnvName = (name) => String(name).replace(/^export\s+/, '').replace(/_FILE$/, '').split('_').some((seg) => SECRET_SEGMENTS.has(seg));
|
|
26
|
+
const PEM_BLOCK = /-----BEGIN ((?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?)-----[\s\S]*?(?:-----END \1-----|$)/g;
|
|
27
|
+
const RULES = [
|
|
28
|
+
{ name: 'url-credentials', re: /\b([a-z][a-z0-9+.-]*:\/\/)([^\s:@/"'<>]+):([^\s@/"'<>]+)@/gi, to: (m, scheme, user) => `${scheme}${user}:${MARK}@` },
|
|
29
|
+
{ name: 'auth-header', re: /\b(Bearer|Basic|Token)(\s+)[A-Za-z0-9._~+/=-]{8,}/gi, to: (m, a, b) => `${a}${b}${MARK}` },
|
|
30
|
+
{ name: 'url-secret', re: /([?&#](?:access_token|refresh_token|id_token|token|key|api_key|apikey|secret|code|password|otp|sig|signature)=)([^&#\s"']+)/gi, to: (m, a) => `${a}${MARK}` },
|
|
31
|
+
{ name: 'otp', re: /\b(otp|one[-_ ]?time[-_ ]?(?:code|password|pin)|verification[-_ ]?code|2fa[-_ ]?code|mã[ _-]?(?:otp|xác[ _-]?(?:thực|minh)))(\s*[:=]?\s*["']?)(\d{4,8})\b/giu, to: (m, a, b) => `${a}${b}${MARK}` },
|
|
32
|
+
// ENV_STYLE_KEY=value / ENV_STYLE_KEY: value (shell exports, dotenv, compose, CLI echo).
|
|
33
|
+
{ name: 'env-secret', re: /\b((?:export\s+)?[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*)(\s*[:=]\s*["']?)((?!\[redacted)[^\s"'`]{3,})/g,
|
|
34
|
+
to: (m, a, b, c) => (isSecretEnvName(a) && !c.startsWith('/run/secrets/') && !c.startsWith('$') ? `${a}${b}${MARK}` : m) },
|
|
35
|
+
{ name: 'keyed-secret', re: /\b(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|auth[_-]?token|session[_-]?token|private[_-]?key|otp|pin[_-]?code|cookie|set-cookie)(["']?\s*[:=]\s*["']?)((?!\[redacted)[^\s"',;&}]{3,})/gi, to: (m, a, b) => `${a}${b}${MARK}` },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** Keys whose value is a secret whatever it looks like; the key stays, the value goes. */
|
|
39
|
+
export const SECRET_KEY = /^(?:password|passwd|pwd|pass|secret|otp|pin|pincode|pin_code|credential|credentials|authorization|cookie|cookies|set-cookie|private[_-]?key|client[_-]?secret|api[_-]?key|apikey|[a-z_-]*token|[a-z_-]*secret)$/i;
|
|
40
|
+
|
|
41
|
+
// ------------------------------------------------------------------------------ declared stack secrets
|
|
42
|
+
// A product repo's .starcistacks/<stack>/stack.yaml `secrets:` block names each secret and the runtime file
|
|
43
|
+
// that holds its value. Both are learned once per repo root per process; values never leave this module.
|
|
44
|
+
const STACK_DIRS = ['.starcistacks'];
|
|
45
|
+
const learnedRoots = new Set();
|
|
46
|
+
const secretValues = new Set();
|
|
47
|
+
const secretNames = new Set();
|
|
48
|
+
const envName = (name) => String(name).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toUpperCase();
|
|
49
|
+
const readSafe = (file) => { try { return fs.readFileSync(file, 'utf8'); } catch { return null; } };
|
|
50
|
+
const listSafe = (dir) => { try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; } };
|
|
51
|
+
|
|
52
|
+
/** A value this process must never write out (a secret it resolved itself). Values under 6 chars are ignored. */
|
|
53
|
+
export function addSecretValue(value) {
|
|
54
|
+
const v = typeof value === 'string' ? value.trim() : '';
|
|
55
|
+
if (v.length >= 6 && !/^\[redacted/.test(v)) secretValues.add(v);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Learn the declared secrets of every .starcistacks stack under `repoRoot` (idempotent). */
|
|
59
|
+
export function learnStackSecrets(repoRoot) {
|
|
60
|
+
if (typeof repoRoot !== 'string' || !repoRoot) return;
|
|
61
|
+
const root = path.resolve(repoRoot);
|
|
62
|
+
if (learnedRoots.has(root)) return;
|
|
63
|
+
learnedRoots.add(root);
|
|
64
|
+
for (const stackDir of STACK_DIRS) {
|
|
65
|
+
for (const entry of listSafe(path.join(root, stackDir))) {
|
|
66
|
+
if (!entry.isDirectory()) continue;
|
|
67
|
+
const dir = path.join(root, stackDir, entry.name);
|
|
68
|
+
const text = readSafe(path.join(dir, 'stack.yaml'));
|
|
69
|
+
let doc = null;
|
|
70
|
+
try { doc = text ? parseYaml(text) : null; } catch { doc = null; }
|
|
71
|
+
const secrets = doc && typeof doc.secrets === 'object' && doc.secrets ? doc.secrets : {};
|
|
72
|
+
for (const [name, spec] of Object.entries(secrets)) {
|
|
73
|
+
secretNames.add(envName(name));
|
|
74
|
+
if (spec && typeof spec.file === 'string') addSecretValue(readSafe(path.resolve(dir, spec.file)));
|
|
75
|
+
}
|
|
76
|
+
// Plaintext runtime copies of the secrets (git-ignored): their values are secrets too.
|
|
77
|
+
for (const file of listSafe(path.join(dir, 'runtime', 'files'))) if (file.isFile()) addSecretValue(readSafe(path.join(dir, 'runtime', 'files', file.name)));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
83
|
+
let namesRe = null, namesSize = -1;
|
|
84
|
+
const declaredNameRule = () => {
|
|
85
|
+
if (namesSize !== secretNames.size) {
|
|
86
|
+
namesSize = secretNames.size;
|
|
87
|
+
namesRe = secretNames.size ? new RegExp(`\\b((?:${[...secretNames].map(escapeRe).join('|')})(?:_FILE)?)(\\s*[:=]\\s*["']?)((?!\\[redacted)[^\\s"'\`]{3,})`, 'g') : null;
|
|
88
|
+
}
|
|
89
|
+
return namesRe;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// --------------------------------------------------------------------------------------------- text
|
|
93
|
+
/**
|
|
94
|
+
* `text` with every secret value blanked. `repoRoots` (optional) are product repos whose .starcistacks
|
|
95
|
+
* declarations are learned first.
|
|
96
|
+
*/
|
|
97
|
+
export function redactText(text, { repoRoots = [] } = {}) {
|
|
98
|
+
if (typeof text !== 'string' || !text) return text;
|
|
99
|
+
for (const root of repoRoots) learnStackSecrets(root);
|
|
100
|
+
let out = text.replace(PEM_BLOCK, (m, kind) => `[redacted:pem ${kind}]`);
|
|
101
|
+
for (const value of secretValues) if (out.includes(value)) out = out.split(value).join('[redacted:stack-secret]');
|
|
102
|
+
for (const rule of PATTERNS) {
|
|
103
|
+
rule.g.lastIndex = 0;
|
|
104
|
+
out = out.replace(rule.g, (match, value) => {
|
|
105
|
+
if (rule.name === 'assigned-secret') return rule.placeholder?.test(value ?? '') ? match : match.replace(value, MARK);
|
|
106
|
+
return `[redacted:${rule.name}]`;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const declared = declaredNameRule();
|
|
110
|
+
if (declared) { declared.lastIndex = 0; out = out.replace(declared, (m, a, b) => `${a}${b}${MARK}`); }
|
|
111
|
+
for (const rule of RULES) { rule.re.lastIndex = 0; out = out.replace(rule.re, rule.to); }
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A path that is a secret by being one (an env file, a key file, .secrets/): blanked, its rule named. */
|
|
116
|
+
export function redactPath(p) {
|
|
117
|
+
if (typeof p !== 'string') return p;
|
|
118
|
+
const slashed = p.replace(/\\/g, '/');
|
|
119
|
+
const rule = FORBIDDEN_FILES.find((r) => r.test(slashed));
|
|
120
|
+
return rule ? `[redacted:${rule.name}]` : redactText(p);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A deep copy of `value` with secret-named keys blanked and every string redacted. */
|
|
124
|
+
export function redactData(value, key = null, depth = 0) {
|
|
125
|
+
if (depth > 8) return '[depth]';
|
|
126
|
+
if (key && SECRET_KEY.test(key) && value != null && typeof value !== 'object' && typeof value !== 'boolean') return MARK;
|
|
127
|
+
if (typeof value === 'string') return /(?:path|ref|file)$/i.test(key ?? '') ? redactPath(value) : redactText(value);
|
|
128
|
+
if (Array.isArray(value)) return value.map((v) => redactData(v, null, depth + 1));
|
|
129
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactData(v, k, depth + 1)]));
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// -------------------------------------------------------------------------------------------- bytes
|
|
134
|
+
/** True for media types whose bytes are text the filter can read. */
|
|
135
|
+
export const isTextMedia = (mediaType) => /^text\/|[/+](?:json|xml|yaml|x-yaml|javascript|x-ndjson|x-diff|x-patch|csv|sql|x-sh)\b|^application\/(?:json|xml|yaml|javascript|x-ndjson|x-diff|x-patch|sql)$/i
|
|
136
|
+
.test(String(mediaType ?? ''));
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Bytes ready for a blob put: text bytes redacted ({redaction:'v1'}), anything else untouched
|
|
140
|
+
* ({redaction:'binary'}). The blob sha is then taken over the returned bytes.
|
|
141
|
+
*/
|
|
142
|
+
export function redactBytes(bytes, mediaType, { repoRoots = [] } = {}) {
|
|
143
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
144
|
+
if (!isTextMedia(mediaType)) return { bytes: buf, redaction: 'binary' };
|
|
145
|
+
const text = buf.toString('utf8');
|
|
146
|
+
const clean = redactText(text, { repoRoots });
|
|
147
|
+
return { bytes: clean === text ? buf : Buffer.from(clean, 'utf8'), redaction: REDACTION_VERSION };
|
|
148
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// repo-identity.mjs - who a repository is, independent of the folder it is checked out in.
|
|
2
|
+
// A git worktree (D:/starci-lanes/<lane>/<name>) and the main checkout of the same repository are ONE repository:
|
|
3
|
+
// they carry the same README title, the same stack-declaration project name and the same sibling repositories.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { gitOutput } from './git.mjs';
|
|
7
|
+
|
|
8
|
+
const git = (root, args) => gitOutput(args, { cwd: root }).trim();
|
|
9
|
+
const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
10
|
+
|
|
11
|
+
/** { repositoryRoot, inWorkTree, home } for `root`: home is the main checkout folder when root is a repository top level. */
|
|
12
|
+
function identityOf(root) {
|
|
13
|
+
let inWorkTree = false, repositoryRoot = false, home = null;
|
|
14
|
+
try {
|
|
15
|
+
// Identity comes from Git only when root IS a repository (or worktree) top level, never a folder inside one.
|
|
16
|
+
repositoryRoot = real(git(root, ['rev-parse', '--show-toplevel'])) === real(root);
|
|
17
|
+
inWorkTree = true;
|
|
18
|
+
const common = path.resolve(root, git(root, ['rev-parse', '--git-common-dir']));
|
|
19
|
+
if (repositoryRoot && path.basename(common) === '.git') home = path.dirname(common);
|
|
20
|
+
} catch { /* Not a Git work tree; the caller falls back to the folder and package name. */ }
|
|
21
|
+
return { inWorkTree, repositoryRoot, home };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The product repository name: the main checkout's folder name (so a worktree and its main checkout agree), else the
|
|
26
|
+
* origin remote's name, else package.json name outside Git, else the folder name. A folder inside a larger repository
|
|
27
|
+
* (a nested product, an example) is named by its own folder.
|
|
28
|
+
*/
|
|
29
|
+
export function repositoryName(root) {
|
|
30
|
+
const { inWorkTree, repositoryRoot, home } = identityOf(root);
|
|
31
|
+
if (home) return path.basename(home);
|
|
32
|
+
if (repositoryRoot) {
|
|
33
|
+
try {
|
|
34
|
+
const remote = git(root, ['remote', 'get-url', 'origin']).replace(/[\/]+$/u, '').replace(/\.git$/iu, '');
|
|
35
|
+
const name = remote.split(/[\/:]/u).pop();
|
|
36
|
+
if (name) return name;
|
|
37
|
+
} catch { /* No origin remote. */ }
|
|
38
|
+
}
|
|
39
|
+
if (inWorkTree && !repositoryRoot) return path.basename(root);
|
|
40
|
+
try {
|
|
41
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
42
|
+
if (typeof pkg.name === 'string' && pkg.name) return pkg.name.replace(/^@[^/]+\//u, '');
|
|
43
|
+
} catch { /* No readable package.json. */ }
|
|
44
|
+
return path.basename(root);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The folder whose siblings are this repository's sibling repositories: the main checkout for a worktree, else root. */
|
|
48
|
+
export function repositoryHome(root) {
|
|
49
|
+
return identityOf(root).home ?? path.resolve(root);
|
|
50
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// safe-remove.mjs — the ONE way the runtime deletes a directory tree (nivo-fe inc-c8fbf76aa499).
|
|
2
|
+
//
|
|
3
|
+
// At 05:47 on 2026-09-25 D:/Repositories/nivo-fe lost 674 tracked working-tree files and its node_modules: a
|
|
4
|
+
// recursive delete of a scratch tree followed directory links (Windows junctions) into the live repository.
|
|
5
|
+
// Git for Windows' `git worktree remove --force` follows a junction inside the worktree and empties its target
|
|
6
|
+
// (proven on this host, git 2.52), and a PowerShell/cmd/third-party recursive delete may do the same. A tree
|
|
7
|
+
// that holds links to live checkouts is exactly what the runtime makes (node_modules junctions in land and
|
|
8
|
+
// worker staging checkouts, push-mains scratch worktrees, dependency mirrors).
|
|
9
|
+
//
|
|
10
|
+
// safeRemoveTree walks the tree itself with lstat and NEVER descends into a link: every symlink, junction or
|
|
11
|
+
// other reparse point is unlinked as a link (the link only, never what it points at) and verified gone. A
|
|
12
|
+
// directory counts as a link when lstat says so, when readlink resolves it, or when its real path is not
|
|
13
|
+
// its own path under its parent's real path (any name-surrogate reparse point). A link that cannot be
|
|
14
|
+
// unlinked stops the removal of everything above it; nothing is ever deleted through it.
|
|
15
|
+
//
|
|
16
|
+
// safeRemoveWorktree removes a git worktree's directory with safeRemoveTree and then prunes the registration,
|
|
17
|
+
// so `git worktree remove --force` never walks the tree.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { sleepSync } from './sleep-sync.mjs';
|
|
23
|
+
import { samePath } from './path-key.mjs';
|
|
24
|
+
import { gitSpawn } from './git.mjs';
|
|
25
|
+
import { artifactHoldReason } from './artifact-hold.mjs';
|
|
26
|
+
import { realpathOr } from './fs-kind.mjs';
|
|
27
|
+
|
|
28
|
+
const WIN = process.platform === 'win32';
|
|
29
|
+
const same = samePath;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* True when `p` is a link of any kind: a symlink, a junction, or another reparse point that redirects it.
|
|
33
|
+
* `parentReal` (the real path of p's parent, when the caller walked to it through real directories) lets the
|
|
34
|
+
* real-path test catch a reparse point lstat and readlink do not report. A missing path is not a link.
|
|
35
|
+
*/
|
|
36
|
+
export function isLinkLike(p, { parentReal = null, stat = null } = {}) {
|
|
37
|
+
let st = stat;
|
|
38
|
+
if (!st) { try { st = fs.lstatSync(p); } catch { return false; } }
|
|
39
|
+
if (st.isSymbolicLink()) return true;
|
|
40
|
+
if (!st.isDirectory()) return false;
|
|
41
|
+
try { fs.readlinkSync(p); return true; } catch { /* not a link readlink can read */ }
|
|
42
|
+
const parent = parentReal ?? realpathOr(path.dirname(p));
|
|
43
|
+
const real = realpathOr(p);
|
|
44
|
+
if (!parent || !real) return true; // cannot prove it is a plain directory: treat it as a link
|
|
45
|
+
return !same(real, path.join(parent, path.basename(p)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Remove a link itself, never its target. True when nothing is left at `p`. */
|
|
49
|
+
export function unlinkOnly(p) {
|
|
50
|
+
try { fs.lstatSync(p); } catch (error) { return error?.code === 'ENOENT'; }
|
|
51
|
+
try { fs.unlinkSync(p); } catch { try { fs.rmdirSync(p); } catch { /* verified below */ } }
|
|
52
|
+
try { fs.lstatSync(p); return false; } catch (error) { return error?.code === 'ENOENT'; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const retrying = (fn, retries) => {
|
|
56
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
57
|
+
try { fn(); return null; } catch (error) {
|
|
58
|
+
if (error?.code === 'ENOENT') return null;
|
|
59
|
+
if (attempt >= retries || !['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY'].includes(error?.code)) return error;
|
|
60
|
+
sleepSync(25 * (attempt + 1));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const removeFile = (p, retries) => retrying(() => {
|
|
65
|
+
try { fs.unlinkSync(p); } catch (error) {
|
|
66
|
+
if (WIN && (error?.code === 'EPERM' || error?.code === 'EACCES')) { fs.chmodSync(p, 0o666); fs.unlinkSync(p); } else throw error;
|
|
67
|
+
}
|
|
68
|
+
}, retries);
|
|
69
|
+
|
|
70
|
+
const SKILL_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
71
|
+
/**
|
|
72
|
+
* True when `p` sits strictly below `root` by real path: no link on the way, not `root` itself. The one place a
|
|
73
|
+
* primary checkout may be removed is a disposable fixture a caller names by its temp root (hk-tmp).
|
|
74
|
+
*/
|
|
75
|
+
export function strictlyInsideReal(p, root) {
|
|
76
|
+
if (!root) return false;
|
|
77
|
+
const rel = path.relative(path.resolve(root), path.resolve(p));
|
|
78
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
|
79
|
+
const real = realpathOr(p);
|
|
80
|
+
const rootReal = realpathOr(root);
|
|
81
|
+
// Its real path must be the same relative place under the root's real path: no link between them.
|
|
82
|
+
return Boolean(real && rootReal) && same(real, path.join(rootReal, rel));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A path the runtime must never remove whole: a filesystem root, the home or temp directory, the runtime, the
|
|
87
|
+
* repository that hosts it, the repositories root, or a primary git checkout (its .git is a directory; the
|
|
88
|
+
* scratch trees the runtime removes are temp directories and linked worktrees, whose .git is a file).
|
|
89
|
+
* `checkoutsUnder` names a disposable root (hk-tmp's temp root): a checkout strictly inside it by real path
|
|
90
|
+
* is a spec fixture, not a live repository, and is not refused for its .git. Every other refusal stands.
|
|
91
|
+
* A tree holding an indexed job artifact, or inside an evidence directory holding one, is refused whatever the
|
|
92
|
+
* workflow's phase (artifact-hold.mjs).
|
|
93
|
+
*/
|
|
94
|
+
export function forbiddenRoot(p, { checkoutsUnder = null } = {}) {
|
|
95
|
+
const resolved = path.resolve(p);
|
|
96
|
+
if (path.parse(resolved).root === resolved || same(path.dirname(resolved), resolved)) return 'a filesystem root';
|
|
97
|
+
for (const [name, dir] of [['the home directory', os.homedir()], ['the temp directory', os.tmpdir()], ['the runtime', SKILL_ROOT],
|
|
98
|
+
['the repository hosting the runtime', path.dirname(SKILL_ROOT)], ['the repositories root', path.dirname(path.dirname(SKILL_ROOT))]]) {
|
|
99
|
+
if (dir && same(path.resolve(dir), resolved)) return name;
|
|
100
|
+
}
|
|
101
|
+
const held = artifactHoldReason(resolved);
|
|
102
|
+
if (held) return held;
|
|
103
|
+
let checkout = false;
|
|
104
|
+
try { checkout = fs.lstatSync(path.join(resolved, '.git')).isDirectory(); } catch { /* no .git directory */ }
|
|
105
|
+
if (checkout && !strictlyInsideReal(resolved, checkoutsUnder)) return 'a git checkout';
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Remove `root` and everything under it without ever following a link. Links are unlinked (the link only);
|
|
111
|
+
* plain files and directories are deleted bottom-up. `checkoutsUnder` is forbiddenRoot's disposable root. Returns {ok, root, removed: {files, dirs, links},
|
|
112
|
+
* errors: [{path, code, message}]}; ok is true only when `root` is gone. A missing root is ok.
|
|
113
|
+
*/
|
|
114
|
+
export function safeRemoveTree(root, { retries = 5, checkoutsUnder = null } = {}) {
|
|
115
|
+
const target = path.resolve(String(root ?? ''));
|
|
116
|
+
const out = { ok: false, root: target, removed: { files: 0, dirs: 0, links: 0 }, errors: [] };
|
|
117
|
+
const fail = (p, error) => { out.errors.push({ path: p, code: error?.code ?? 'ERROR', message: String(error?.message ?? error) }); };
|
|
118
|
+
const refused = root ? forbiddenRoot(target, { checkoutsUnder }) : 'no path';
|
|
119
|
+
if (refused) { fail(target, { code: 'REFUSED', message: `refusing to remove ${refused}` }); return out; }
|
|
120
|
+
let st;
|
|
121
|
+
try { st = fs.lstatSync(target); } catch (error) {
|
|
122
|
+
if (error?.code === 'ENOENT') { out.ok = true; return out; }
|
|
123
|
+
fail(target, error); return out;
|
|
124
|
+
}
|
|
125
|
+
// CONTAINMENT (the .claude/node_modules wipe, 2026-09-28): nothing is ever deleted whose real path is not
|
|
126
|
+
// the root itself or strictly under the root's real path. A link is unlinked above (the link only); any other
|
|
127
|
+
// entry that resolves outside the tree is refused, whatever made it look like a plain entry.
|
|
128
|
+
const rootReal = realpathOr(target);
|
|
129
|
+
const insideRoot = (p) => { const real = realpathOr(p); if (!real || !rootReal) return false; if (same(real, rootReal)) return true;
|
|
130
|
+
const rel = path.relative(rootReal, real); return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel); };
|
|
131
|
+
const walk = (dir, st, parentReal) => {
|
|
132
|
+
if (isLinkLike(dir, { parentReal, stat: st })) {
|
|
133
|
+
if (unlinkOnly(dir)) { out.removed.links += 1; return true; }
|
|
134
|
+
fail(dir, { code: 'LINK_STUCK', message: 'a link could not be unlinked; nothing above it is removed' });
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
if (!insideRoot(dir)) {
|
|
138
|
+
fail(dir, { code: 'OUTSIDE_ROOT', message: `refusing to delete ${dir}: its real path ${realpathOr(dir) ?? '?'} is outside ${rootReal ?? target}` });
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
if (!st.isDirectory()) {
|
|
142
|
+
const error = removeFile(dir, retries);
|
|
143
|
+
if (error) { fail(dir, error); return false; }
|
|
144
|
+
out.removed.files += 1; return true;
|
|
145
|
+
}
|
|
146
|
+
const real = realpathOr(dir);
|
|
147
|
+
if (!real) { fail(dir, { code: 'REALPATH', message: 'cannot resolve the directory' }); return false; }
|
|
148
|
+
let entries;
|
|
149
|
+
try { entries = fs.readdirSync(dir); } catch (error) { fail(dir, error); return false; }
|
|
150
|
+
let clear = true;
|
|
151
|
+
for (const name of entries) {
|
|
152
|
+
const child = path.join(dir, name);
|
|
153
|
+
let childStat;
|
|
154
|
+
try { childStat = fs.lstatSync(child); } catch (error) { if (error?.code !== 'ENOENT') { fail(child, error); clear = false; } continue; }
|
|
155
|
+
if (!walk(child, childStat, real)) clear = false;
|
|
156
|
+
}
|
|
157
|
+
if (!clear) return false;
|
|
158
|
+
const error = retrying(() => fs.rmdirSync(dir), retries);
|
|
159
|
+
if (error) { fail(dir, error); return false; }
|
|
160
|
+
out.removed.dirs += 1; return true;
|
|
161
|
+
};
|
|
162
|
+
walk(target, st, null);
|
|
163
|
+
out.ok = !fs.existsSync(target) && (() => { try { fs.lstatSync(target); return false; } catch { return true; } })();
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Remove a git worktree without letting git walk it: its directory goes through safeRemoveTree, then
|
|
169
|
+
* `git worktree prune` (run in `repo`) drops the registration. `git(args, {cwd})` is the caller's git runner;
|
|
170
|
+
* omitted, git is spawned directly.
|
|
171
|
+
*/
|
|
172
|
+
export function safeRemoveWorktree(worktree, { repo, git = null, retries = 5 } = {}) {
|
|
173
|
+
const removed = safeRemoveTree(worktree, { retries });
|
|
174
|
+
if (repo) {
|
|
175
|
+
const run = git ?? ((args, opts) => gitSpawn('git', args, { cwd: opts.cwd }));
|
|
176
|
+
try { run(['worktree', 'prune'], { cwd: repo }); } catch { /* the registration is pruned on the next prune */ }
|
|
177
|
+
}
|
|
178
|
+
return removed;
|
|
179
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// secret-patterns.mjs — the one list of secret shapes the runtime refuses to carry: the push secret scan
|
|
2
|
+
// (scripts/supervisor/push-mains.mjs scanDiff) refuses an outgoing range that adds one, and the typed-log
|
|
3
|
+
// redaction (scripts/kernel/typed-logs.mjs redactText) blanks the value before a log row is stored.
|
|
4
|
+
// FORBIDDEN_FILES name paths that are secrets by being (an env file, a key file); SECRET_PATTERNS match values.
|
|
5
|
+
|
|
6
|
+
export const FORBIDDEN_FILES = [
|
|
7
|
+
{ name: 'env-file', test: (f) => /(^|\/)\.env(\.[^/]*)?$/i.test(f) && !/\.env\.(example|sample|template)$/i.test(f) },
|
|
8
|
+
{ name: 'secrets-dir', test: (f) => /(^|\/)\.secrets\//i.test(f) },
|
|
9
|
+
{ name: 'private-key-file', test: (f) => /\.(pem|key|p12|pfx)$/i.test(f) || /(^|\/)id_(rsa|ed25519|ecdsa)$/i.test(f) },
|
|
10
|
+
// .starcistacks/<stack>/secrets/ commits sops twins only (owner ruling push-scan-test-secrets-encrypted): a plaintext
|
|
11
|
+
// credential there - a test password included - is git-ignored by the product repo and never pushed.
|
|
12
|
+
{ name: 'starcistacks-secret-plaintext', test: (f) => /(^|\/)\.starcistacks\/[^/]+\/secrets\//i.test(f) && !/(\.enc|\/\.gitkeep|\/KEYS\.md)$/i.test(f) },
|
|
13
|
+
{ name: 'credentials-json', test: (f) => /(^|\/)(credentials|service-account|client_secret)[^/]*\.json$/i.test(f) },
|
|
14
|
+
];
|
|
15
|
+
export const SECRET_PATTERNS = [
|
|
16
|
+
{ name: 'private-key-block', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ },
|
|
17
|
+
{ name: 'aws-access-key', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
18
|
+
{ name: 'github-token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{50,}\b/ },
|
|
19
|
+
{ name: 'anthropic-key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
|
|
20
|
+
{ name: 'openai-key', re: /\bsk-(?:proj-)?[A-Za-z0-9]{32,}\b/ },
|
|
21
|
+
{ name: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/ },
|
|
22
|
+
{ name: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
23
|
+
{ name: 'telegram-bot-token', re: /\b\d{8,10}:AA[0-9A-Za-z_-]{33}\b/ },
|
|
24
|
+
{ name: 'stripe-secret', re: /\b(?:sk|rk)_live_[0-9A-Za-z]{20,}\b/ },
|
|
25
|
+
{ name: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
|
|
26
|
+
// A keyword-assigned value that names itself a stand-in (a test stub's `accessToken: "fixture-..."`) is no
|
|
27
|
+
// candidate; only this heuristic takes the exemption, never a provider-shaped token above.
|
|
28
|
+
// A spec file's keyword-assigned values are test inputs (nivo-fe AuthenticationPage/index.spec.tsx: the sign-in
|
|
29
|
+
// form's password and a mocked accessToken refused the push, 2026-09-24): the heuristic skips spec files too.
|
|
30
|
+
{ name: 'assigned-secret', re: /\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|client[_-]?secret)\b\s*[:=]\s*['"]([^'"\s$<{]{12,})['"]/i, placeholder: /fixture|stub|fake|dummy|placeholder|example|sample|changeme|redacted|mock/i,
|
|
31
|
+
skipFile: /\.(?:spec|test|e2e-spec)\.[cm]?[jt]sx?$/ },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
/** The names of the patterns one line of `file` matches (never the value). A stand-in value and a spec file's keyword assignment are no hit. */
|
|
36
|
+
export function secretHits(file, text) {
|
|
37
|
+
const names = [];
|
|
38
|
+
for (const rule of SECRET_PATTERNS) {
|
|
39
|
+
if (rule.skipFile?.test(file ?? '')) continue;
|
|
40
|
+
const hit = rule.re.exec(text);
|
|
41
|
+
if (hit && !rule.placeholder?.test(hit[1] ?? '')) names.push(rule.name);
|
|
42
|
+
}
|
|
43
|
+
return names;
|
|
44
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// sleep-sync.mjs — the blocking wait synchronous code (lock polls, retry backoff) uses.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test seam: STARCI_SLEEP_SCALE (a number >= 0, default 1) multiplies every wait. A spec that drives a real
|
|
5
|
+
* dispatch/boot against a fake host sets it small so the card's settle/attestation windows (seconds of pure
|
|
6
|
+
* waiting, counted logically by their callers, not by the wall clock) cost milliseconds and the run no longer
|
|
7
|
+
* depends on machine load. Unset in production.
|
|
8
|
+
*/
|
|
9
|
+
const scaleOf = () => {
|
|
10
|
+
const raw = process.env.STARCI_SLEEP_SCALE;
|
|
11
|
+
if (raw === undefined || raw === '') return 1;
|
|
12
|
+
const value = Number(raw);
|
|
13
|
+
return Number.isFinite(value) && value >= 0 ? value : 1;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Block the thread for `ms` milliseconds (scaled by STARCI_SLEEP_SCALE). */
|
|
17
|
+
export const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.round(ms * scaleOf()));
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// stack-declaration.mjs - the one reader of a repository's stack declaration (.starcistacks/application-stacks.yaml): where it
|
|
2
|
+
// lives, its bounded read and parse, and the stack block of one service as declared. scripts/checks/check-starcistacks.mjs (the
|
|
3
|
+
// services contract) and scripts/lib/hfs-rules/stacks.mjs (the .starcistacks shape, R10) both read it here; this file imports
|
|
4
|
+
// nothing of the kernel, so the published @starci/hfs bundle carries it without the ledger.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { isPlainObject as plain } from '../../engine/plain-object.mjs';
|
|
8
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
9
|
+
import { isDir, isFile } from './fs-kind.mjs';
|
|
10
|
+
import { posixPath } from './path-key.mjs';
|
|
11
|
+
|
|
12
|
+
export const DECLARATION = 'application-stacks.yaml';
|
|
13
|
+
/** The one stack root a repository owns. */
|
|
14
|
+
export const STACK_ROOT = '.starcistacks';
|
|
15
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
/** A non-blank string, trimmed; anything else is null. */
|
|
18
|
+
export const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
|
|
19
|
+
|
|
20
|
+
/** The text of `file`, or null when it is absent or larger than 2 MiB. */
|
|
21
|
+
export const readText = (file) => { try { return fs.statSync(file).size > MAX_BYTES ? null : fs.readFileSync(file, 'utf8'); } catch { return null; } };
|
|
22
|
+
|
|
23
|
+
/** The parsed declaration file: {doc} or {error}. */
|
|
24
|
+
export function readDeclaration(file) {
|
|
25
|
+
const raw = readText(file);
|
|
26
|
+
if (raw === null) return { error: 'unreadable or larger than 2 MiB' };
|
|
27
|
+
try { const doc = parseYaml(raw); return plain(doc) ? { doc } : { error: 'not a YAML mapping' }; }
|
|
28
|
+
catch (error) { return { error: `YAML does not parse: ${String(error?.message ?? error).split('\n')[0]}` }; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The stack declaration a repository owns: {root, file, rooted, doc|error} - or {missing:true, rooted}
|
|
33
|
+
* when .starcistacks/application-stacks.yaml does not exist. `rooted` says the .starcistacks tree exists.
|
|
34
|
+
*/
|
|
35
|
+
export function findStackDeclaration(repoRoot) {
|
|
36
|
+
const repo = path.resolve(String(repoRoot ?? ''));
|
|
37
|
+
const rooted = isDir(path.join(repo, STACK_ROOT));
|
|
38
|
+
const file = path.join(repo, STACK_ROOT, DECLARATION);
|
|
39
|
+
if (isFile(file)) return { repo, root: STACK_ROOT, file, rooted, ...readDeclaration(file) };
|
|
40
|
+
return { repo, missing: true, rooted };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The stack block of one declared service, or null when it has none: {owner, root, repository, hostOwned}. A stack is
|
|
45
|
+
* host-owned when it says `owner: host` (it lives in the installed runtime tree, `.claude/ext/<service>`); `root` is posix.
|
|
46
|
+
*/
|
|
47
|
+
export function declaredStack(entry) {
|
|
48
|
+
const stack = plain(entry) && plain(entry.stack) ? entry.stack : null;
|
|
49
|
+
if (!stack) return null;
|
|
50
|
+
const root = text(stack.root);
|
|
51
|
+
return { owner: text(stack.owner), root: root === null ? null : posixPath(root), repository: text(stack.repository), hostOwned: text(stack.owner) === 'host' };
|
|
52
|
+
}
|