diff --git a/CONTRACT.md b/CONTRACT.md index 7f9b144..665b2d5 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -240,3 +240,50 @@ Checks: The kit is dev-dependency friendly: `@hasna/contracts` can be a `devDependency` and the checks run under `bun test` with no runtime footprint in the app. + +--- + +## 11. Secure local-store lifecycle + +The shared secure local-store policy is `hasna.secure_local_store_policy.v1` +(`SecureLocalStorePolicySchema`) and the helper module is +`@hasna/contracts/secure-local-store`. + +This policy applies to local operator state under `.hasna` and `.codewith`. +The default inventory is explicit by package: Codewith, Todos, Conversations, +Mementos, Knowledge, Projects, Browser, Terminal, Logs, and Loops. + +Local stores **MUST** use owner-only defaults: + +- Store directories: `0700`. +- Store files: `0600`. +- SQLite main DB files, WAL sidecars, and SHM sidecars: `0600`. +- Backup, export, report, session, snapshot, tmp, and log artifacts: `0600` + unless a package records a narrower non-secret exception. + +Lifecycle cleanup **MUST** default to dry-run. Destructive retention requires: + +1. Explicit apply intent. +2. A package-owned retention adapter. +3. Artifact allowlist matches; no broad delete outside the allowlist. +4. Active-record exclusion proof for current tasks, sessions, messages, runs, + workspace rows, attachments, evidence, or other package-owned references. +5. Redaction-before-persistence and redacted evidence from the owning package. + +SQLite maintenance **MUST NOT** run against active stores. WAL checkpoint, +incremental vacuum, optimize, or vacuum operations are allowed only when the +caller explicitly proves exclusive/offline access for that package store. The +contracts helper plans maintenance and can apply it only when the caller opts in +to both apply mode and SQLite maintenance. + +The CLI surface: + +```bash +contracts secure-local-store --json +contracts secure-local-store "$HOME" --json --plan --store todos +contracts secure-local-store "$HOME" --json --apply --store todos +contracts secure-local-store "$HOME" --json --plan --retention --store todos --retention-proof todos-exports-backups +``` + +The CLI and helper report paths, modes, counts, statuses, store ids, and adapter +ids only. They do not read or print file contents. diff --git a/README.md b/README.md index 0051949..aa924f0 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,24 @@ contracts no-cloud-scan --manifest app-cloud.manifest.json . contracts no-cloud-scan --json hasna-todos-0.11.62.tgz ``` +Print the shared secure local-store policy for `.hasna` and `.codewith`, or +produce a dry-run lifecycle plan for owner-only permissions and package-owned +retention adapters: + +```bash +contracts secure-local-store --json +contracts secure-local-store --json --store todos +contracts secure-local-store "$HOME" --json --plan --store todos +contracts secure-local-store "$HOME" --json --apply --store todos +contracts secure-local-store "$HOME" --json --plan --retention --store todos --retention-proof todos-exports-backups +``` + +`secure-local-store` never reads file contents. The contract covers `0700` +directories, `0600` files, SQLite DB/WAL/SHM sidecars, backups, exports, active +record exclusions, artifact allowlists, and SQLite maintenance gates. Retention +and SQLite maintenance are dry-run by default and require explicit package +adapter proof before deletion or compaction. + ## Storage Kit (vendored codegen) `vendor-kit` stamps a canonical, self-contained Postgres storage kit into a @@ -131,7 +149,7 @@ import { expressApiKey, ApiKeyStore } from "@hasna/contracts/auth"; import { createCloudPoolFromEnv } from "./generated/storage-kit"; // vendored kit const APP = "todos"; -const signingSecret = process.env.HASNA_TODOS_API_SIGNING_KEY!; // shared: HASNA_API_SIGNING_KEY +const apiSigningMaterial = process.env.HASNA_TODOS_API_SIGNING_KEY!; // shared: HASNA_API_SIGNING_KEY const { client } = createCloudPoolFromEnv(APP); // RDS pool (Amendment A1) const keys = new ApiKeyStore(client); await keys.ensureSchema(); // idempotent: api_keys table @@ -139,7 +157,7 @@ await keys.ensureSchema(); // idempotent: a app.use( expressApiKey({ app: APP, - signingSecret, + signingSecret: apiSigningMaterial, isRevoked: keys.isRevoked, // per-request revocation check against RDS requiredScopes: ["todos:read"], // optional per-mount scope gate audit: (e) => log.info("api_auth", e), // per-request AUDIT hook (allow + deny) @@ -278,6 +296,10 @@ defaults are applied; output aliases such as `EvidenceRef` describe parsed data. - `hasna.app_cloud_manifest.v1`: app-owned cloud boundary declaration for a package that uses its own cloud resources, local cache, and conflict policy without depending on shared `@hasna/cloud` or `open-cloud` runtimes. +- `hasna.secure_local_store_policy.v1`: shared `.hasna`/`.codewith` secure + local-store lifecycle policy with owner-only modes, package inventory, + retention adapters, active-record exclusions, artifact allowlists, and SQLite + maintenance safety gates. - `hasna.no_cloud_evidence_pack.v1`: prepublish/CI evidence pack for package manifest, lockfile, source/runtime config, packed artifact, published metadata, and app-cloud-manifest scans. diff --git a/examples/secure-local-store-policy.valid.json b/examples/secure-local-store-policy.valid.json new file mode 100644 index 0000000..7f0c122 --- /dev/null +++ b/examples/secure-local-store-policy.valid.json @@ -0,0 +1,91 @@ +{ + "schema": "hasna.secure_local_store_policy.v1", + "id": "secure_local_store_policy_example", + "createdAt": "2026-07-06T00:00:00.000Z", + "version": "2026-07-06", + "scope": [".hasna", ".codewith"], + "defaults": { + "directoryMode": "0700", + "fileMode": "0600", + "dryRunDefault": true, + "requireExplicitApply": true, + "includeSqliteSidecars": true, + "redactedEvidenceOnly": true + }, + "stores": [ + { + "storeId": "todos", + "packageName": "@hasna/todos", + "displayName": "Todos", + "root": ".hasna", + "relativePath": "todos", + "sqliteDatabaseGlobs": ["todos.db"], + "sensitiveFileGlobs": ["todos.db", "todos.db-wal", "todos.db-shm", "exports/**/*", "backups/**/*"], + "backupGlobs": ["backups/**/*"], + "exportGlobs": ["exports/**/*"], + "retentionAdapters": [ + { + "id": "todos-exports-backups", + "description": "Redacted backup and export retention for Todos artifacts.", + "ttlDays": 14, + "artifactClasses": ["backup", "export"], + "allowlistGlobs": ["backups/**/*", "exports/**/*"], + "activeRecordExclusions": [ + { + "id": "todos-active-evidence", + "source": "sqlite", + "table": "task_files", + "column": "path", + "description": "Exclude files still referenced by active tasks or verification evidence." + } + ], + "sqliteMaintenance": { + "safeWhen": "exclusive_access", + "operations": ["wal_checkpoint_truncate", "optimize"] + } + } + ] + }, + { + "storeId": "codewith", + "packageName": "codewith", + "displayName": "Codewith native state", + "root": ".codewith", + "relativePath": ".", + "sqliteDatabaseGlobs": ["state_*.sqlite", "logs_*.sqlite", "goals_*.sqlite"], + "sensitiveFileGlobs": ["sessions/**/*.jsonl", "shell_snapshots/**/*", "*.sqlite"], + "backupGlobs": ["backups/**/*"], + "exportGlobs": ["exports/**/*"], + "retentionAdapters": [ + { + "id": "codewith-session-snapshots", + "description": "Session and shell snapshot retention after package redaction.", + "ttlDays": 30, + "artifactClasses": ["session", "snapshot", "log"], + "allowlistGlobs": ["sessions/**/*.jsonl", "shell_snapshots/**/*", "logs/**/*"], + "activeRecordExclusions": [ + { + "id": "codewith-active-session", + "source": "package_adapter", + "description": "Exclude active sessions, leased schedules, active monitors, and active goal rows." + } + ], + "sqliteMaintenance": { + "safeWhen": "exclusive_access", + "operations": ["wal_checkpoint_truncate", "optimize"] + } + } + ] + } + ], + "lifecycle": { + "retentionDryRunDefault": true, + "requireActiveRecordExclusionProof": true, + "requireArtifactAllowlist": true, + "sqliteMaintenanceRequiresExclusiveAccess": true + }, + "warnings": [ + "Retention defaults to dry-run and requires package-owned active-record checks.", + "SQLite maintenance requires explicit exclusive access." + ] +} diff --git a/package.json b/package.json index 975883c..d5a584c 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,10 @@ "types": "./dist/service-contract.d.ts", "import": "./dist/service-contract.js" }, + "./secure-local-store": { + "types": "./dist/secure-local-store.d.ts", + "import": "./dist/secure-local-store.js" + }, "./conformance": { "types": "./dist/conformance.d.ts", "import": "./dist/conformance.js" @@ -74,7 +78,7 @@ "LICENSE" ], "scripts": { - "build": "rm -rf dist && bun build src/index.ts src/schemas.ts src/validators.ts src/no-cloud.ts src/mode.ts src/service-contract.ts src/conformance.ts src/kit/generate.ts src/auth/index.ts src/sdk/generate.ts --root src --outdir dist --target bun && bun build src/cli/index.ts --outdir dist/cli --target bun && cp src/hasna.contract.schema.json dist/hasna.contract.schema.json && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist", + "build": "rm -rf dist && bun build src/index.ts src/schemas.ts src/validators.ts src/no-cloud.ts src/mode.ts src/service-contract.ts src/secure-local-store.ts src/conformance.ts src/kit/generate.ts src/auth/index.ts src/sdk/generate.ts --root src --outdir dist --target bun && bun build src/cli/index.ts --outdir dist/cli --target bun && cp src/hasna.contract.schema.json dist/hasna.contract.schema.json && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist", "typecheck": "tsc --noEmit", "test": "bun test", "lint": "tsc --noEmit", diff --git a/scripts/smoke-dist.ts b/scripts/smoke-dist.ts index cdaacdf..5affe38 100644 --- a/scripts/smoke-dist.ts +++ b/scripts/smoke-dist.ts @@ -6,10 +6,14 @@ const root = join(import.meta.dir, ".."); const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { version: string }; const { CONTRACTS_PACKAGE_VERSION } = await import("../dist/schemas.js"); const { scanNoCloudTarget } = await import("../dist/no-cloud.js"); +const { secureLocalStorePolicy } = await import("../dist/secure-local-store.js"); if (typeof scanNoCloudTarget !== "function") { throw new Error("dist/no-cloud.js did not export scanNoCloudTarget"); } +if (typeof secureLocalStorePolicy !== "function") { + throw new Error("dist/secure-local-store.js did not export secureLocalStorePolicy"); +} type CommandResult = ReturnType; @@ -76,6 +80,13 @@ if (conformancePayload.ok !== true || conformancePayload.failed !== 0 || Number( throw new Error("conformance did not report a non-empty passing fixture set"); } +const secureStore = run(["secure-local-store", "--json", "--store", "todos"]); +requireExit(secureStore, 0, "secure-local-store"); +const secureStorePayload = parseJson(secureStore, "secure-local-store"); +if (secureStorePayload.schema !== "hasna.secure_local_store_policy.v1") { + throw new Error("secure-local-store did not emit the secure local-store policy schema"); +} + const noCloudDir = mkdtempSync(join(tmpdir(), "contracts-no-cloud-dist-")); try { writeFileSync(join(noCloudDir, "package.json"), JSON.stringify({ name: "@hasna/dist-smoke", version: packageJson.version })); diff --git a/src/cli/index.ts b/src/cli/index.ts index a66a51c..5209b54 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -11,6 +11,12 @@ import { import { scanNoCloudTarget } from "../no-cloud"; import { runRepoConformance } from "../conformance"; import { getEmbeddedSchemaId, validateContract } from "../validators"; +import { + applySecureLocalStorePlan, + planSecureLocalStoreLifecycle, + secureLocalStorePolicy, + type PlanSecureLocalStoreOptions +} from "../secure-local-store"; import { runVendorKit } from "./kit-runner"; import { runIssueKey } from "./issue-key"; @@ -65,13 +71,13 @@ function preflightJsonUsageErrors(argv: string[]) { return false; } - if (!["schemas", "validate", "conformance", "no-cloud-scan", "repo-conformance", "vendor-kit", "issue-key"].includes(command)) { + if (!["schemas", "validate", "conformance", "no-cloud-scan", "repo-conformance", "vendor-kit", "issue-key", "secure-local-store"].includes(command)) { return reportParserJsonError("commander.unknownCommand", `unknown command '${command}'`); } // issue-key has rich value-taking options; let commander parse it (its // CommanderError is already rendered as JSON by main()). - if (command === "issue-key") { + if (command === "issue-key" || command === "secure-local-store") { return false; } @@ -135,6 +141,33 @@ function preflightJsonUsageErrors(argv: string[]) { return false; } +function collectOption(value: string, previous: string[] = []): string[] { + return [...previous, value]; +} + +function parsePositiveInteger(value: string, fallback: number): number { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function printSecureLocalStoreText(payload: ReturnType | ReturnType) { + if ("actions" in payload) { + console.log(`${payload.ok ? "ok" : "blocked"} hasna.secure_local_store_policy.v1 ${payload.mode}`); + console.log( + `stores=${payload.summary.stores} scanned=${payload.summary.scannedEntries} planned=${payload.summary.planned} blocked=${payload.summary.blocked} skipped=${payload.summary.skipped} applied=${payload.summary.applied} failed=${payload.summary.failed}` + ); + for (const action of payload.actions) { + console.log(` ${action.status} ${action.kind} ${action.storeId} ${action.path} ${action.reason}`); + } + return; + } + + console.log(`ok ${payload.schema} ${payload.id}`); + for (const store of payload.stores) { + console.log(` ${store.storeId} ${store.packageName} ${store.root}/${store.relativePath}`); + } +} + export function createContractsProgram() { const program = new Command(); @@ -307,6 +340,95 @@ export function createContractsProgram() { } }); + program + .command("secure-local-store") + .description("Print or plan the shared .hasna/.codewith secure local-store lifecycle contract") + .argument("[home]", "Home directory to inspect when --plan or --apply is set. Omitted: print the default policy.") + .option("--plan", "Dry-run filesystem lifecycle plan for the selected stores") + .option("--apply", "Apply owner-only chmod repairs from the plan") + .option("--retention", "Include retention candidates in the dry-run plan") + .option("--apply-retention", "Apply allowlisted retention deletes; requires --apply") + .option("--sqlite-maintenance", "Include SQLite maintenance actions") + .option("--apply-sqlite-maintenance", "Run planned SQLite maintenance; requires --apply and --assume-exclusive-sqlite") + .option("--assume-exclusive-sqlite", "Assert no app process is using selected SQLite stores") + .option("--store ", "Limit to a store id; repeat for multiple stores", collectOption, []) + .option("--active-path ", "Path to exclude from retention as active; repeat for multiple paths", collectOption, []) + .option("--retention-proof ", "Adapter id with package-owned active-record proof; repeat for multiple adapters", collectOption, []) + .option("--max-entries ", "Maximum filesystem entries to scan per store", "25000") + .option("-j, --json", "Output JSON") + .action(async ( + home: string | undefined, + options: { + plan?: boolean; + apply?: boolean; + retention?: boolean; + applyRetention?: boolean; + sqliteMaintenance?: boolean; + applySqliteMaintenance?: boolean; + assumeExclusiveSqlite?: boolean; + store?: string[]; + activePath?: string[]; + retentionProof?: string[]; + maxEntries?: string; + json?: boolean; + } + ) => { + const stores = options.store && options.store.length > 0 ? options.store : undefined; + const shouldPlan = Boolean(options.plan || options.apply || options.retention || options.sqliteMaintenance); + if (!shouldPlan) { + let policy; + try { + policy = secureLocalStorePolicy(stores); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + reportCliError(options, `secure-local-store failed: ${message}`, { code: "secure_local_store_error" }); + return; + } + if (options.json) { + console.log(JSON.stringify(policy, null, 2)); + } else { + printSecureLocalStoreText(policy); + } + return; + } + + const planOptions: PlanSecureLocalStoreOptions = { + apply: Boolean(options.apply), + includeRetention: Boolean(options.retention || options.applyRetention), + includeSqliteMaintenance: Boolean(options.sqliteMaintenance || options.applySqliteMaintenance), + assumeExclusiveSqlite: Boolean(options.assumeExclusiveSqlite), + maxEntries: parsePositiveInteger(options.maxEntries ?? "25000", 25_000) + }; + if (home) planOptions.home = home; + if (stores) planOptions.stores = stores; + if (options.activePath && options.activePath.length > 0) planOptions.activePaths = options.activePath; + if (options.retentionProof && options.retentionProof.length > 0) planOptions.activeRecordProofs = options.retentionProof; + + let plan; + try { + plan = planSecureLocalStoreLifecycle(planOptions); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + reportCliError(options, `secure-local-store failed: ${message}`, { code: "secure_local_store_error" }); + return; + } + const result = options.apply + ? await applySecureLocalStorePlan(plan, { + applyRetention: Boolean(options.applyRetention), + applySqliteMaintenance: Boolean(options.applySqliteMaintenance) + }) + : plan; + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + printSecureLocalStoreText(result); + } + if (!result.ok) { + process.exitCode = 1; + } + }); + program .command("repo-conformance") .description("Check a repo against the Hasna Service Contract v1 using its hasna.contract.json") @@ -373,7 +495,7 @@ export function createContractsProgram() { return program; } -export function main(argv = process.argv) { +export async function main(argv = process.argv) { if (preflightJsonUsageErrors(argv)) { return; } @@ -386,7 +508,7 @@ export function main(argv = process.argv) { } program.exitOverride(); try { - program.parse(argv); + await program.parseAsync(argv); } catch (error) { const commanderError = error as Partial & { message?: string }; if (error instanceof CommanderError || typeof commanderError.code === "string" || typeof commanderError.exitCode === "number") { @@ -410,5 +532,5 @@ export function main(argv = process.argv) { } if (import.meta.main) { - main(); + await main(); } diff --git a/src/index.ts b/src/index.ts index 02fdc17..b9f7d05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export * from "./validators"; export * from "./no-cloud"; export * from "./mode"; export * from "./service-contract"; +export * from "./secure-local-store"; export * from "./conformance"; export * from "./kit/generate"; export * from "./auth/index"; diff --git a/src/schemas.ts b/src/schemas.ts index 11a21ac..5700793 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -24,6 +24,7 @@ export const SCHEMA_IDS = { scaffoldInstallRecord: "hasna.scaffold_install_record.v1", appCloudManifest: "hasna.app_cloud_manifest.v1", noCloudEvidencePack: "hasna.no_cloud_evidence_pack.v1", + secureLocalStorePolicy: "hasna.secure_local_store_policy.v1", serviceContract: "hasna.service_contract.v1" } as const; @@ -1573,6 +1574,167 @@ export const StorageContractSchema = z .strict(); export type StorageContract = z.infer; +export const OwnerOnlyFileModeSchema = z.enum(["0600"]); +export type OwnerOnlyFileMode = z.infer; + +export const OwnerOnlyDirectoryModeSchema = z.enum(["0700"]); +export type OwnerOnlyDirectoryMode = z.infer; + +export const LocalStoreRootSchema = z.enum([".hasna", ".codewith"]); +export type LocalStoreRoot = z.infer; + +export const SecureLocalStoreArtifactClassSchema = z.enum([ + "directory", + "file", + "sqlite_db", + "sqlite_wal", + "sqlite_shm", + "backup", + "export", + "report", + "tmp", + "log", + "session", + "snapshot" +]); +export type SecureLocalStoreArtifactClass = z.infer; + +export const SecureLocalStorePathPatternSchema = RelativeProjectPathSchema.refine( + (value) => !value.startsWith("~"), + "Local store path patterns must be relative to their declared root" +); + +export const SecureLocalStoreActiveRecordExclusionSchema = z + .object({ + id: z.string().min(1), + source: z.enum(["sqlite", "manifest", "index", "runtime", "package_adapter"]), + table: z.string().min(1).optional(), + column: z.string().min(1).optional(), + description: z.string().min(1), + required: z.boolean().default(true) + }) + .strict(); +export type SecureLocalStoreActiveRecordExclusion = z.infer; + +export const SecureLocalStoreSqliteMaintenanceSchema = z + .object({ + safeWhen: z.enum(["exclusive_access", "offline_only", "never"]), + operations: z + .array(z.enum(["wal_checkpoint_truncate", "incremental_vacuum", "optimize", "vacuum"])) + .default([]) + }) + .strict() + .superRefine((value, ctx) => { + if (value.safeWhen === "never" && value.operations.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "sqliteMaintenance.safeWhen=never cannot declare operations", + path: ["operations"] + }); + } + }); +export type SecureLocalStoreSqliteMaintenance = z.infer; + +export const SecureLocalStoreRetentionAdapterSchema = z + .object({ + id: z.string().min(1), + description: z.string().min(1), + ttlDays: z.number().int().nonnegative().optional(), + artifactClasses: z.array(SecureLocalStoreArtifactClassSchema).min(1), + allowlistGlobs: z.array(SecureLocalStorePathPatternSchema).min(1), + activeRecordExclusions: z.array(SecureLocalStoreActiveRecordExclusionSchema).default([]), + sqliteMaintenance: SecureLocalStoreSqliteMaintenanceSchema.optional() + }) + .strict(); +export type SecureLocalStoreRetentionAdapter = z.infer; + +export const SecureLocalStoreDefinitionSchema = z + .object({ + storeId: z.string().regex(/^[a-z][a-z0-9-]*$/), + packageName: z.string().min(1), + displayName: z.string().min(1), + root: LocalStoreRootSchema, + relativePath: SecureLocalStorePathPatternSchema, + directoryMode: OwnerOnlyDirectoryModeSchema.default("0700"), + fileMode: OwnerOnlyFileModeSchema.default("0600"), + sqliteDatabaseGlobs: z.array(SecureLocalStorePathPatternSchema).default([]), + sensitiveFileGlobs: z.array(SecureLocalStorePathPatternSchema).default([]), + backupGlobs: z.array(SecureLocalStorePathPatternSchema).default([]), + exportGlobs: z.array(SecureLocalStorePathPatternSchema).default([]), + retentionAdapters: z.array(SecureLocalStoreRetentionAdapterSchema).default([]), + notes: z.array(z.string().min(1)).default([]) + }) + .strict() + .superRefine((value, ctx) => { + if (value.relativePath.includes("*")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "store relativePath must be a concrete directory; use glob fields for files", + path: ["relativePath"] + }); + } + const adapterIds = new Set(); + for (const [index, adapter] of value.retentionAdapters.entries()) { + if (adapterIds.has(adapter.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "retention adapter ids must be unique within a store", + path: ["retentionAdapters", index, "id"] + }); + } + adapterIds.add(adapter.id); + } + }); +export type SecureLocalStoreDefinition = z.infer; + +export const SecureLocalStorePolicySchema = contractBaseSchema(SCHEMA_IDS.secureLocalStorePolicy) + .extend({ + version: z.string().min(1), + scope: z.array(LocalStoreRootSchema).min(1), + defaults: z + .object({ + directoryMode: OwnerOnlyDirectoryModeSchema.default("0700"), + fileMode: OwnerOnlyFileModeSchema.default("0600"), + dryRunDefault: z.literal(true), + requireExplicitApply: z.literal(true), + includeSqliteSidecars: z.literal(true), + redactedEvidenceOnly: z.literal(true) + }) + .strict(), + stores: z.array(SecureLocalStoreDefinitionSchema).min(1), + lifecycle: z + .object({ + retentionDryRunDefault: z.literal(true), + requireActiveRecordExclusionProof: z.literal(true), + requireArtifactAllowlist: z.literal(true), + sqliteMaintenanceRequiresExclusiveAccess: z.literal(true) + }) + .strict(), + warnings: z.array(z.string().min(1)).default([]) + }) + .strict() + .superRefine((value, ctx) => { + const stores = new Set(); + for (const [index, store] of value.stores.entries()) { + if (stores.has(store.storeId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "store ids must be unique", + path: ["stores", index, "storeId"] + }); + } + stores.add(store.storeId); + if (!value.scope.includes(store.root)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "store root must be listed in policy scope", + path: ["stores", index, "root"] + }); + } + } + }); +export type SecureLocalStorePolicy = z.infer; + export const ServiceContractManifestSchema = z .object({ /** Optional editor hint pointing at the JSON Schema; ignored at runtime. */ @@ -1731,6 +1893,7 @@ export const ContractSchemaRegistry = { [SCHEMA_IDS.scaffoldInstallRecord]: ScaffoldInstallRecordSchema, [SCHEMA_IDS.appCloudManifest]: AppCloudManifestSchema, [SCHEMA_IDS.noCloudEvidencePack]: NoCloudEvidencePackSchema, + [SCHEMA_IDS.secureLocalStorePolicy]: SecureLocalStorePolicySchema, [SCHEMA_IDS.serviceContract]: ServiceContractManifestSchema } as const; @@ -1757,6 +1920,7 @@ export type ContractBySchemaId = { [SCHEMA_IDS.scaffoldInstallRecord]: ScaffoldInstallRecord; [SCHEMA_IDS.appCloudManifest]: AppCloudManifest; [SCHEMA_IDS.noCloudEvidencePack]: NoCloudEvidencePack; + [SCHEMA_IDS.secureLocalStorePolicy]: SecureLocalStorePolicy; [SCHEMA_IDS.serviceContract]: ServiceContractManifest; }; @@ -1780,6 +1944,7 @@ export type ScaffoldManifestInput = z.input; export type ScaffoldInstallRecordInput = z.input; export type AppCloudManifestInput = z.input; export type NoCloudEvidencePackInput = z.input; +export type SecureLocalStorePolicyInput = z.input; export type ServiceContractManifestInput = z.input; export type ActorPointerInput = z.input; export type ResourcePointerInput = z.input; @@ -1806,5 +1971,6 @@ export type ContractInputBySchemaId = { [SCHEMA_IDS.scaffoldInstallRecord]: ScaffoldInstallRecordInput; [SCHEMA_IDS.appCloudManifest]: AppCloudManifestInput; [SCHEMA_IDS.noCloudEvidencePack]: NoCloudEvidencePackInput; + [SCHEMA_IDS.secureLocalStorePolicy]: SecureLocalStorePolicyInput; [SCHEMA_IDS.serviceContract]: ServiceContractManifestInput; }; diff --git a/src/secure-local-store.ts b/src/secure-local-store.ts new file mode 100644 index 0000000..41f20c4 --- /dev/null +++ b/src/secure-local-store.ts @@ -0,0 +1,843 @@ +import { + chmodSync, + existsSync, + lstatSync, + readdirSync, + rmSync, + type Stats +} from "node:fs"; +import { homedir } from "node:os"; +import { basename, join, relative, resolve, sep } from "node:path"; +import { + SCHEMA_IDS, + SecureLocalStorePolicySchema, + type SecureLocalStoreArtifactClass, + type SecureLocalStoreDefinition, + type SecureLocalStorePolicy, + type SecureLocalStoreRetentionAdapter, + type SecureLocalStoreSqliteMaintenance +} from "./schemas"; + +export const SECURE_LOCAL_STORE_POLICY_VERSION = "2026-07-06"; +export const OWNER_ONLY_DIR_MODE = 0o700; +export const OWNER_ONLY_FILE_MODE = 0o600; + +export type SecureLocalStorePlanMode = "dry-run" | "apply"; +export type SecureLocalStoreActionKind = + | "chmod_dir" + | "chmod_file" + | "retention_delete" + | "sqlite_maintenance" + | "blocked"; +export type SecureLocalStoreActionStatus = "planned" | "applied" | "skipped" | "blocked" | "failed"; + +export interface SecureLocalStoreAction { + kind: SecureLocalStoreActionKind; + status: SecureLocalStoreActionStatus; + storeId: string; + packageName: string; + path: string; + artifactClass: SecureLocalStoreArtifactClass | "sqlite_maintenance"; + reason: string; + expectedMode?: string; + currentMode?: string; + adapterId?: string; + ageDays?: number; + operations?: string[]; + error?: string; +} + +export interface SecureLocalStorePlan { + ok: boolean; + mode: SecureLocalStorePlanMode; + home: string; + policy: SecureLocalStorePolicy; + actions: SecureLocalStoreAction[]; + summary: { + stores: number; + scannedEntries: number; + planned: number; + blocked: number; + skipped: number; + applied: number; + failed: number; + }; + warnings: string[]; +} + +export interface PlanSecureLocalStoreOptions { + home?: string; + policy?: SecureLocalStorePolicy; + stores?: string[]; + apply?: boolean; + includeRetention?: boolean; + includeSqliteMaintenance?: boolean; + activePaths?: string[]; + activeRecordProofs?: string[]; + assumeExclusiveSqlite?: boolean; + now?: Date; + maxEntries?: number; +} + +export interface ApplySecureLocalStoreOptions { + applyRetention?: boolean; + applySqliteMaintenance?: boolean; +} + +interface ScannedEntry { + path: string; + relFromStore: string; + stats: Stats; + artifactClass: SecureLocalStoreArtifactClass; +} + +type ActiveRecordExclusionInput = Omit & { + required?: boolean; +}; + +function retentionAdapter( + id: string, + description: string, + ttlDays: number, + artifactClasses: SecureLocalStoreArtifactClass[], + allowlistGlobs: string[], + activeRecordExclusions: ActiveRecordExclusionInput[] = [], + sqliteMaintenance?: SecureLocalStoreSqliteMaintenance +): SecureLocalStoreRetentionAdapter { + return { + id, + description, + ttlDays, + artifactClasses, + allowlistGlobs, + activeRecordExclusions: activeRecordExclusions.map((exclusion) => ({ ...exclusion, required: exclusion.required ?? true })), + sqliteMaintenance + }; +} + +export const DEFAULT_SECURE_LOCAL_STORE_POLICY: SecureLocalStorePolicy = SecureLocalStorePolicySchema.parse({ + schema: SCHEMA_IDS.secureLocalStorePolicy, + id: "hasna-secure-local-store-defaults", + createdAt: "2026-07-06T00:00:00.000Z", + version: SECURE_LOCAL_STORE_POLICY_VERSION, + scope: [".hasna", ".codewith"], + defaults: { + directoryMode: "0700", + fileMode: "0600", + dryRunDefault: true, + requireExplicitApply: true, + includeSqliteSidecars: true, + redactedEvidenceOnly: true + }, + lifecycle: { + retentionDryRunDefault: true, + requireActiveRecordExclusionProof: true, + requireArtifactAllowlist: true, + sqliteMaintenanceRequiresExclusiveAccess: true + }, + stores: [ + { + storeId: "codewith", + packageName: "codewith", + displayName: "Codewith native state", + root: ".codewith", + relativePath: ".", + sqliteDatabaseGlobs: ["logs_*.sqlite", "state_*.sqlite", "goals_*.sqlite"], + sensitiveFileGlobs: ["sessions/**/*.jsonl", "shell_snapshots/**/*", "logs*.sqlite", "state*.sqlite", "goals*.sqlite"], + backupGlobs: ["backups/**/*"], + exportGlobs: ["exports/**/*"], + retentionAdapters: [ + retentionAdapter( + "codewith-session-snapshots", + "Codewith sessions, shell snapshots, logs, monitor output, mailbox payloads, and scheduler state need package-owned redaction before retention applies.", + 30, + ["session", "snapshot", "log"], + ["sessions/**/*.jsonl", "shell_snapshots/**/*", "logs/**/*"], + [ + { + id: "codewith-active-session", + source: "package_adapter", + description: "Exclude currently active sessions, leased schedules, monitors, pending interactions, and active goal rows." + } + ], + { safeWhen: "exclusive_access", operations: ["wal_checkpoint_truncate", "optimize"] } + ) + ], + notes: ["Includes native .codewith DBs and transcript-like artifacts; redaction-before-persistence remains package-owned."] + }, + { + storeId: "todos", + packageName: "@hasna/todos", + displayName: "Todos", + root: ".hasna", + relativePath: "todos", + sqliteDatabaseGlobs: ["todos.db"], + sensitiveFileGlobs: ["todos.db", "todos.db-wal", "todos.db-shm", "exports/**/*", "backups/**/*"], + backupGlobs: ["backups/**/*", "*.bak", "*.backup"], + exportGlobs: ["exports/**/*", "*.jsonl", "*.csv"], + retentionAdapters: [ + retentionAdapter( + "todos-exports-backups", + "Todos backups and exports are deleted only after package redaction and active task/evidence references are excluded.", + 14, + ["backup", "export"], + ["backups/**/*", "exports/**/*"], + [ + { + id: "todos-active-evidence", + source: "sqlite", + table: "task_files", + column: "path", + description: "Exclude files still referenced by active tasks, verification evidence, task comments, or handoff records." + } + ], + { safeWhen: "exclusive_access", operations: ["wal_checkpoint_truncate", "optimize"] } + ) + ] + }, + { + storeId: "conversations", + packageName: "@hasna/conversations", + displayName: "Conversations", + root: ".hasna", + relativePath: "conversations", + sqliteDatabaseGlobs: ["messages.db"], + sensitiveFileGlobs: ["messages.db", "messages.db-wal", "messages.db-shm", "exports/**/*", "attachments/**/*"], + backupGlobs: ["backups/**/*", "*.bak"], + exportGlobs: ["exports/**/*", "*.json", "*.csv"], + retentionAdapters: [ + retentionAdapter( + "conversations-exports-attachments", + "Conversation exports and attachments require message-id redaction and active attachment reference checks before deletion.", + 14, + ["export", "backup"], + ["exports/**/*", "backups/**/*", "attachments/**/*"], + [ + { + id: "conversations-active-attachments", + source: "sqlite", + table: "messages", + column: "attachments", + description: "Exclude attachments still referenced by retained messages or audited redaction records." + } + ], + { safeWhen: "exclusive_access", operations: ["wal_checkpoint_truncate", "optimize"] } + ) + ] + }, + { + storeId: "mementos", + packageName: "@hasna/mementos", + displayName: "Mementos", + root: ".hasna", + relativePath: "mementos", + sqliteDatabaseGlobs: ["mementos.db"], + sensitiveFileGlobs: ["mementos.db", "mementos.db-wal", "mementos.db-shm", "exports/**/*", "backups/**/*"], + backupGlobs: ["backups/**/*", "*.bak"], + exportGlobs: ["exports/**/*"], + retentionAdapters: [ + retentionAdapter( + "mementos-audit-search-history", + "Mementos retention must preserve active memory versions while compacting audit/search surfaces through package-owned adapters.", + 30, + ["backup", "export", "log"], + ["backups/**/*", "exports/**/*", "audit/**/*"], + [ + { + id: "mementos-active-memory-versions", + source: "sqlite", + table: "memory_versions", + column: "memory_id", + description: "Exclude current memory versions and audit entries required for provenance." + } + ], + { safeWhen: "exclusive_access", operations: ["wal_checkpoint_truncate", "optimize"] } + ) + ] + }, + { + storeId: "knowledge", + packageName: "@hasna/knowledge", + displayName: "Knowledge", + root: ".hasna", + relativePath: "knowledge", + sqliteDatabaseGlobs: ["knowledge.db"], + sensitiveFileGlobs: ["knowledge.db", "knowledge.db-wal", "knowledge.db-shm", "db.json", "migration-exports/**/*", "*.bak"], + backupGlobs: ["*.bak", "backups/**/*", "*.pre-cloud-*"], + exportGlobs: ["migration-exports/**/*", "exports/**/*", "*.jsonl"], + retentionAdapters: [ + retentionAdapter( + "knowledge-migrations-exports", + "Knowledge migration exports and pre-cloud backups require replacement, encryption, or redaction before retention deletion.", + 14, + ["backup", "export"], + ["migration-exports/**/*", "exports/**/*", "*.bak", "*.pre-cloud-*"], + [ + { + id: "knowledge-current-catalog", + source: "manifest", + description: "Exclude files referenced by the active catalog or migration ledger." + } + ], + { safeWhen: "exclusive_access", operations: ["wal_checkpoint_truncate", "optimize"] } + ) + ] + }, + { + storeId: "projects", + packageName: "@hasna/projects", + displayName: "Projects", + root: ".hasna", + relativePath: "projects", + sqliteDatabaseGlobs: ["projects.db", "data/*/project.db"], + sensitiveFileGlobs: ["projects.db", "projects.db-wal", "projects.db-shm", "data/*/project.db", "data/*/project.db-wal", "data/*/project.db-shm", "reports/**/*"], + backupGlobs: ["backups/**/*", "data/*/backups/**/*"], + exportGlobs: ["reports/**/*", "exports/**/*"], + retentionAdapters: [ + retentionAdapter( + "projects-reports-workspaces", + "Project reports, dashboards, workspaces, and per-project DBs need active workspace/location references before cleanup.", + 30, + ["backup", "export", "report", "tmp"], + ["backups/**/*", "reports/**/*", "workspaces/**/*", "data/*/backups/**/*"], + [ + { + id: "projects-active-workspaces", + source: "sqlite", + table: "workspaces", + column: "primary_path", + description: "Exclude active workspace paths, locations, linked reports, and project store artifacts." + } + ], + { safeWhen: "exclusive_access", operations: ["wal_checkpoint_truncate", "optimize"] } + ) + ] + }, + { + storeId: "browser", + packageName: "@hasna/browser", + displayName: "Browser", + root: ".hasna", + relativePath: "browser", + sqliteDatabaseGlobs: ["browser.db"], + sensitiveFileGlobs: ["browser.db", "browser.db-wal", "browser.db-shm", "profiles/**/cookies.json", "states/**/*.json", "auth/**/*"], + backupGlobs: ["backups/**/*"], + exportGlobs: ["exports/**/*", "traces/**/*", "har/**/*"], + retentionAdapters: [ + retentionAdapter( + "browser-auth-traces", + "Browser state, trace, HAR, and auth artifacts require session invalidation or redaction before deletion.", + 7, + ["backup", "export", "session", "snapshot"], + ["profiles/**/*", "states/**/*", "traces/**/*", "har/**/*", "exports/**/*"], + [ + { + id: "browser-active-profiles", + source: "sqlite", + table: "sessions", + column: "profile_path", + description: "Exclude profiles, cookies, and storage state used by active browser sessions." + } + ], + { safeWhen: "exclusive_access", operations: ["wal_checkpoint_truncate", "optimize"] } + ) + ] + }, + { + storeId: "terminal", + packageName: "@hasna/terminal", + displayName: "Terminal", + root: ".hasna", + relativePath: "terminal", + sqliteDatabaseGlobs: ["sessions.db"], + sensitiveFileGlobs: ["sessions.db", "sessions.db-wal", "sessions.db-shm", "exports/**/*"], + backupGlobs: ["backups/**/*"], + exportGlobs: ["exports/**/*"], + retentionAdapters: [ + retentionAdapter( + "terminal-sessions", + "Terminal sessions and interactions need active session exclusion plus command-output redaction before retention.", + 30, + ["backup", "export", "session", "log"], + ["backups/**/*", "exports/**/*", "sessions/**/*"], + [ + { + id: "terminal-active-sessions", + source: "sqlite", + table: "sessions", + column: "id", + description: "Exclude active terminal session records and any linked interaction artifacts." + } + ], + { safeWhen: "exclusive_access", operations: ["wal_checkpoint_truncate", "optimize"] } + ) + ] + }, + { + storeId: "logs", + packageName: "@hasna/logs", + displayName: "Logs", + root: ".hasna", + relativePath: "logs", + sqliteDatabaseGlobs: ["logs.db"], + sensitiveFileGlobs: ["logs.db", "logs.db-wal", "logs.db-shm", "exports/**/*"], + backupGlobs: ["backups/**/*"], + exportGlobs: ["exports/**/*"], + retentionAdapters: [ + retentionAdapter( + "logs-retention", + "Logs require redaction before compaction and must preserve active incident/evidence references.", + 14, + ["backup", "export", "log"], + ["backups/**/*", "exports/**/*", "*.log", "logs/**/*"], + [ + { + id: "logs-active-evidence", + source: "sqlite", + table: "logs", + column: "id", + description: "Exclude log rows or files linked to active incidents, tasks, or proof bundles." + } + ], + { safeWhen: "exclusive_access", operations: ["wal_checkpoint_truncate", "optimize"] } + ) + ] + }, + { + storeId: "loops", + packageName: "@hasna/loops", + displayName: "OpenLoops", + root: ".hasna", + relativePath: "loops", + sqliteDatabaseGlobs: ["loops.db", "state.db", "*.sqlite"], + sensitiveFileGlobs: ["*.db", "*.sqlite", "*.db-wal", "*.db-shm", "reports/**/*", "tmp/**/*", "runs/**/*"], + backupGlobs: ["backups/**/*", "tmp/**/*"], + exportGlobs: ["reports/**/*", "runs/**/*", "exports/**/*"], + retentionAdapters: [ + retentionAdapter( + "loops-reports-tmp", + "Loop reports, tmp files, workflow artifacts, and command output need run-state checks and redaction before retention deletion.", + 14, + ["backup", "export", "report", "tmp", "log"], + ["reports/**/*", "tmp/**/*", "runs/**/*", "exports/**/*"], + [ + { + id: "loops-active-runs", + source: "sqlite", + table: "loop_runs", + column: "id", + description: "Exclude active, leased, recently failed, or evidence-linked loop and workflow run artifacts." + } + ], + { safeWhen: "exclusive_access", operations: ["wal_checkpoint_truncate", "optimize"] } + ) + ] + } + ], + warnings: [ + "This contract only handles owner-only storage and lifecycle planning; redaction-before-persistence remains package-owned.", + "Retention deletion defaults to dry-run and must use package adapters to prove active-record exclusions.", + "SQLite maintenance requires explicit exclusive access and must not run against active stores." + ] +}); + +export function secureLocalStorePolicy(stores?: string[]): SecureLocalStorePolicy { + if (!stores || stores.length === 0) { + return DEFAULT_SECURE_LOCAL_STORE_POLICY; + } + const selected = new Set(stores); + const known = new Set(DEFAULT_SECURE_LOCAL_STORE_POLICY.stores.map((store) => store.storeId)); + const unknown = stores.filter((store) => !known.has(store)); + if (unknown.length > 0) { + throw new Error(`Unknown secure local store id: ${unknown.join(", ")}`); + } + return SecureLocalStorePolicySchema.parse({ + ...DEFAULT_SECURE_LOCAL_STORE_POLICY, + stores: DEFAULT_SECURE_LOCAL_STORE_POLICY.stores.filter((store) => selected.has(store.storeId)) + }); +} + +export function sqliteSidecarPaths(databasePath: string): string[] { + return [`${databasePath}-wal`, `${databasePath}-shm`]; +} + +export function modeToString(mode: number): string { + return `0${(mode & 0o777).toString(8)}`; +} + +function expectedModeForClass(artifactClass: SecureLocalStoreArtifactClass): number { + return artifactClass === "directory" ? OWNER_ONLY_DIR_MODE : OWNER_ONLY_FILE_MODE; +} + +function normalizeRelativePath(value: string): string { + return value.split(sep).join("/"); +} + +function globToRegExp(glob: string): RegExp { + const normalized = normalizeRelativePath(glob); + let pattern = ""; + for (let index = 0; index < normalized.length; index += 1) { + const char = normalized[index]; + const next = normalized[index + 1]; + if (char === "*" && next === "*") { + pattern += ".*"; + index += 1; + } else if (char === "*") { + pattern += "[^/]*"; + } else if ("\\^$+?.()|{}[]".includes(char ?? "")) { + pattern += `\\${char}`; + } else { + pattern += char; + } + } + return new RegExp(`^${pattern}$`); +} + +function matchesGlob(relPath: string, glob: string): boolean { + const normalized = normalizeRelativePath(relPath); + const normalizedGlob = normalizeRelativePath(glob); + if (normalizedGlob.endsWith("/**/*")) { + const prefix = normalizedGlob.slice(0, -"/**/*".length); + return normalized.startsWith(`${prefix}/`) && normalized.length > prefix.length + 1; + } + if (normalizedGlob.endsWith("/**")) { + const prefix = normalizedGlob.slice(0, -"/**".length); + return normalized === prefix || normalized.startsWith(`${prefix}/`); + } + return globToRegExp(normalizedGlob).test(normalized); +} + +function matchesAnyGlob(relPath: string, globs: readonly string[]): boolean { + return globs.some((glob) => matchesGlob(relPath, glob)); +} + +function storeBase(home: string, store: SecureLocalStoreDefinition): string { + return resolve(join(home, store.root, store.relativePath === "." ? "" : store.relativePath)); +} + +function classifyEntry(store: SecureLocalStoreDefinition, relPath: string, stats: Stats): SecureLocalStoreArtifactClass { + const normalized = normalizeRelativePath(relPath); + const name = basename(normalized); + if (stats.isDirectory()) return "directory"; + if (matchesAnyGlob(normalized, store.backupGlobs)) return "backup"; + if (matchesAnyGlob(normalized, store.exportGlobs)) return "export"; + if (normalized.includes("/reports/") || normalized.startsWith("reports/")) return "report"; + if (normalized.includes("/tmp/") || normalized.startsWith("tmp/")) return "tmp"; + if (name.endsWith("-wal")) return "sqlite_wal"; + if (name.endsWith("-shm")) return "sqlite_shm"; + if (matchesAnyGlob(normalized, store.sqliteDatabaseGlobs) || name.endsWith(".db") || name.endsWith(".sqlite")) return "sqlite_db"; + if (normalized.includes("session") || normalized.includes("sessions/")) return "session"; + if (normalized.includes("snapshot") || normalized.includes("shell_snapshots/")) return "snapshot"; + if (name.endsWith(".log") || normalized.includes("/logs/") || normalized.startsWith("logs/")) return "log"; + return "file"; +} + +function isSensitiveStoreFile(store: SecureLocalStoreDefinition, entry: ScannedEntry): boolean { + if (entry.stats.isDirectory()) return true; + if (["sqlite_db", "sqlite_wal", "sqlite_shm", "backup", "export", "report", "tmp", "log", "session", "snapshot"].includes(entry.artifactClass)) return true; + return matchesAnyGlob(entry.relFromStore, store.sensitiveFileGlobs); +} + +function hasActiveRecordProof(storeId: string, adapterId: string, proofs: Set): boolean { + return proofs.has("all") || proofs.has(adapterId) || proofs.has(`${storeId}:${adapterId}`); +} + +function scanStore(base: string, store: SecureLocalStoreDefinition, maxEntries: number): { entries: ScannedEntry[]; warnings: string[] } { + const entries: ScannedEntry[] = []; + const warnings: string[] = []; + if (!existsSync(base)) { + return { entries, warnings: [`store_missing:${store.storeId}`] }; + } + + const visit = (path: string) => { + if (entries.length >= maxEntries) { + warnings.push(`max_entries_reached:${store.storeId}:${maxEntries}`); + return; + } + let stats: Stats; + try { + stats = lstatSync(path); + } catch (error) { + warnings.push(`stat_failed:${store.storeId}:${error instanceof Error ? error.message : String(error)}`); + return; + } + const relFromStore = normalizeRelativePath(relative(base, path)) || "."; + entries.push({ path, relFromStore, stats, artifactClass: classifyEntry(store, relFromStore, stats) }); + if (stats.isSymbolicLink()) return; + if (!stats.isDirectory()) return; + let children: string[]; + try { + children = readdirSync(path).sort(); + } catch (error) { + warnings.push(`read_dir_failed:${store.storeId}:${error instanceof Error ? error.message : String(error)}`); + return; + } + for (const child of children) { + visit(join(path, child)); + if (entries.length >= maxEntries) return; + } + }; + + visit(base); + return { entries, warnings }; +} + +function activePathMatches(path: string, activePaths: Set): boolean { + const resolved = resolve(path); + return activePaths.has(resolved); +} + +function isRetentionCandidate(entry: ScannedEntry, adapter: SecureLocalStoreRetentionAdapter, now: Date): boolean { + if (entry.stats.isDirectory()) return false; + if (!adapter.artifactClasses.includes(entry.artifactClass)) return false; + if (!matchesAnyGlob(entry.relFromStore, adapter.allowlistGlobs)) return false; + if (adapter.ttlDays === undefined) return true; + const ageMs = now.getTime() - entry.stats.mtimeMs; + return ageMs >= adapter.ttlDays * 24 * 60 * 60 * 1000; +} + +function summarize(plan: Omit, scannedEntries: number): SecureLocalStorePlan["summary"] { + return { + stores: plan.policy.stores.length, + scannedEntries, + planned: plan.actions.filter((action) => action.status === "planned").length, + blocked: plan.actions.filter((action) => action.status === "blocked").length, + skipped: plan.actions.filter((action) => action.status === "skipped").length, + applied: plan.actions.filter((action) => action.status === "applied").length, + failed: plan.actions.filter((action) => action.status === "failed").length + }; +} + +export function planSecureLocalStoreLifecycle(options: PlanSecureLocalStoreOptions = {}): SecureLocalStorePlan { + const home = resolve(options.home ?? homedir()); + const sourcePolicy = options.policy ?? DEFAULT_SECURE_LOCAL_STORE_POLICY; + const policy = options.stores && options.stores.length > 0 + ? SecureLocalStorePolicySchema.parse({ + ...sourcePolicy, + stores: sourcePolicy.stores.filter((store) => options.stores?.includes(store.storeId)) + }) + : sourcePolicy; + const now = options.now ?? new Date(); + const mode: SecureLocalStorePlanMode = options.apply ? "apply" : "dry-run"; + const activePaths = new Set((options.activePaths ?? []).map((path) => resolve(path))); + const activeRecordProofs = new Set(options.activeRecordProofs ?? []); + const actions: SecureLocalStoreAction[] = []; + const warnings = [...policy.warnings]; + let scannedEntries = 0; + const maxEntries = options.maxEntries ?? 25_000; + + for (const root of policy.scope) { + const rootPath = join(home, root); + if (!existsSync(rootPath)) continue; + try { + const stats = lstatSync(rootPath); + if (stats.isSymbolicLink()) { + actions.push({ + kind: "blocked", + status: "blocked", + storeId: `${root.slice(1)}-root`, + packageName: "@hasna/contracts", + path: rootPath, + artifactClass: "directory", + reason: "local store root is a symlink; refusing to follow or chmod target" + }); + continue; + } + if (stats.isDirectory() && (stats.mode & 0o777) !== OWNER_ONLY_DIR_MODE) { + actions.push({ + kind: "chmod_dir", + status: "planned", + storeId: `${root.slice(1)}-root`, + packageName: "@hasna/contracts", + path: rootPath, + artifactClass: "directory", + reason: "owner-only local store root mode", + expectedMode: "0700", + currentMode: modeToString(stats.mode) + }); + } + } catch (error) { + warnings.push(`stat_failed:${root}:${error instanceof Error ? error.message : String(error)}`); + } + } + + for (const store of policy.stores) { + const base = storeBase(home, store); + const scan = scanStore(base, store, maxEntries); + warnings.push(...scan.warnings); + scannedEntries += scan.entries.length; + + for (const entry of scan.entries) { + if (entry.stats.isSymbolicLink()) { + actions.push({ + kind: "blocked", + status: "blocked", + storeId: store.storeId, + packageName: store.packageName, + path: entry.path, + artifactClass: entry.artifactClass, + reason: "local store symlink requires package-owned migration; refusing to follow or chmod target" + }); + continue; + } + if (!isSensitiveStoreFile(store, entry)) continue; + const expectedMode = expectedModeForClass(entry.artifactClass); + const currentMode = entry.stats.mode & 0o777; + if (currentMode !== expectedMode) { + actions.push({ + kind: entry.stats.isDirectory() ? "chmod_dir" : "chmod_file", + status: "planned", + storeId: store.storeId, + packageName: store.packageName, + path: entry.path, + artifactClass: entry.artifactClass, + reason: "owner-only local store mode", + expectedMode: modeToString(expectedMode), + currentMode: modeToString(currentMode) + }); + } + } + + if (options.includeRetention) { + for (const adapter of store.retentionAdapters) { + for (const entry of scan.entries) { + if (!isRetentionCandidate(entry, adapter, now)) continue; + const ageDays = Math.max(0, Math.floor((now.getTime() - entry.stats.mtimeMs) / (24 * 60 * 60 * 1000))); + if (activePathMatches(entry.path, activePaths)) { + actions.push({ + kind: "retention_delete", + status: "skipped", + storeId: store.storeId, + packageName: store.packageName, + path: entry.path, + artifactClass: entry.artifactClass, + reason: "active-record exclusion matched active path", + adapterId: adapter.id, + ageDays + }); + continue; + } + if (adapter.activeRecordExclusions.some((exclusion) => exclusion.required) && !hasActiveRecordProof(store.storeId, adapter.id, activeRecordProofs)) { + actions.push({ + kind: "blocked", + status: "blocked", + storeId: store.storeId, + packageName: store.packageName, + path: entry.path, + artifactClass: entry.artifactClass, + reason: "retention requires package-owned active-record exclusion proof", + adapterId: adapter.id, + ageDays + }); + continue; + } + actions.push({ + kind: "retention_delete", + status: "planned", + storeId: store.storeId, + packageName: store.packageName, + path: entry.path, + artifactClass: entry.artifactClass, + reason: "allowlisted artifact exceeded retention TTL", + adapterId: adapter.id, + ageDays + }); + } + } + } + + if (options.includeSqliteMaintenance) { + for (const entry of scan.entries.filter((candidate) => candidate.artifactClass === "sqlite_db")) { + for (const adapter of store.retentionAdapters) { + const maintenance = adapter.sqliteMaintenance; + if (!maintenance || maintenance.safeWhen === "never" || maintenance.operations.length === 0) continue; + const safe = maintenance.safeWhen === "exclusive_access" && options.assumeExclusiveSqlite; + actions.push({ + kind: safe ? "sqlite_maintenance" : "blocked", + status: safe ? "planned" : "blocked", + storeId: store.storeId, + packageName: store.packageName, + path: entry.path, + artifactClass: "sqlite_maintenance", + reason: safe ? "SQLite maintenance allowed by explicit exclusive-access assertion" : "SQLite maintenance requires exclusive access", + adapterId: adapter.id, + operations: maintenance.operations + }); + } + } + } + } + + const partial = { mode, home, policy, actions, warnings }; + const summary = summarize(partial, scannedEntries); + return { ...partial, ok: summary.blocked === 0 && summary.failed === 0, summary }; +} + +async function runSqliteMaintenance(path: string, operations: readonly string[]): Promise { + const { Database } = await import("bun:sqlite"); + const db = new Database(path); + try { + for (const operation of operations) { + if (operation === "wal_checkpoint_truncate") db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + if (operation === "incremental_vacuum") db.exec("PRAGMA incremental_vacuum"); + if (operation === "optimize") db.exec("PRAGMA optimize"); + if (operation === "vacuum") db.exec("VACUUM"); + } + } finally { + db.close(); + } +} + +export async function applySecureLocalStorePlan( + plan: SecureLocalStorePlan, + options: ApplySecureLocalStoreOptions = {} +): Promise { + if (plan.mode !== "apply") { + return plan; + } + + const actions: SecureLocalStoreAction[] = []; + for (const action of plan.actions) { + if (action.status !== "planned") { + actions.push(action); + continue; + } + try { + if (action.kind === "chmod_dir" || action.kind === "chmod_file") { + const expected = action.expectedMode === "0700" ? OWNER_ONLY_DIR_MODE : OWNER_ONLY_FILE_MODE; + chmodSync(action.path, expected); + actions.push({ ...action, status: "applied" }); + continue; + } + if (action.kind === "retention_delete") { + if (!options.applyRetention) { + actions.push({ ...action, status: "skipped", reason: "retention deletion needs applyRetention=true" }); + continue; + } + rmSync(action.path, { force: true }); + actions.push({ ...action, status: "applied" }); + continue; + } + if (action.kind === "sqlite_maintenance") { + if (!options.applySqliteMaintenance) { + actions.push({ ...action, status: "skipped", reason: "SQLite maintenance needs applySqliteMaintenance=true" }); + continue; + } + await runSqliteMaintenance(action.path, action.operations ?? []); + actions.push({ ...action, status: "applied" }); + continue; + } + actions.push(action); + } catch (error) { + actions.push({ + ...action, + status: "failed", + error: error instanceof Error ? error.message : String(error) + }); + } + } + + const next = { ...plan, actions }; + const summary = summarize(next, plan.summary.scannedEntries); + return { ...next, ok: summary.blocked === 0 && summary.failed === 0, summary }; +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index f3168c4..3654d59 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -25,6 +25,78 @@ describe("contracts CLI", () => { const result = runContracts(["schemas"]); expect(result.exitCode).toBe(0); expect(result.stdout.toString()).toContain("hasna.proof_bundle.v1"); + expect(result.stdout.toString()).toContain("hasna.secure_local_store_policy.v1"); + }); + + test("prints secure local-store policy JSON", () => { + const result = runContracts(["secure-local-store", "--json", "--store", "todos"]); + expect(result.exitCode).toBe(0); + const payload = parseStdoutJson(result); + expect(payload.schema).toBe("hasna.secure_local_store_policy.v1"); + expect(payload.stores.map((store: { storeId: string }) => store.storeId)).toEqual(["todos"]); + expect(payload.defaults.dryRunDefault).toBe(true); + }); + + test("reports unknown secure local-store ids without a stack trace", () => { + const result = runContracts(["secure-local-store", "--json", "--store", "missing-store"]); + expect(result.exitCode).toBe(2); + const payload = parseStdoutJson(result); + expect(payload.ok).toBe(false); + expect(payload.code).toBe("secure_local_store_error"); + expect(payload.error).toContain("Unknown secure local store id"); + expect(result.stderr.toString()).toBe(""); + }); + + test("plans secure local-store chmod repairs without reading file contents", () => { + const dir = mkdtempSync(join(tmpdir(), "contracts-secure-cli-")); + try { + const todosDir = join(dir, ".hasna", "todos"); + mkdirSync(todosDir, { recursive: true }); + const db = join(todosDir, "todos.db"); + writeFileSync(db, "redacted fixture\n"); + chmodSync(todosDir, 0o755); + chmodSync(db, 0o644); + const result = runContracts(["secure-local-store", dir, "--json", "--plan", "--store", "todos"]); + expect(result.exitCode).toBe(0); + const payload = parseStdoutJson(result); + expect(payload.mode).toBe("dry-run"); + expect(payload.actions.some((action: { kind: string; path: string }) => action.kind === "chmod_file" && action.path === db)).toBe(true); + expect(JSON.stringify(payload)).not.toContain("redacted fixture"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("requires retention proof before planning retention deletes", () => { + const dir = mkdtempSync(join(tmpdir(), "contracts-secure-cli-")); + try { + const backupDir = join(dir, ".hasna", "todos", "backups"); + mkdirSync(backupDir, { recursive: true }); + const backup = join(backupDir, "old.jsonl"); + writeFileSync(backup, "redacted backup fixture\n"); + const old = new Date(Date.now() - 45 * 24 * 60 * 60 * 1000); + utimesSync(backup, old, old); + + const blocked = runContracts(["secure-local-store", dir, "--json", "--plan", "--retention", "--store", "todos"]); + expect(blocked.exitCode).toBe(1); + expect(parseStdoutJson(blocked).actions.some((action: { kind: string }) => action.kind === "blocked")).toBe(true); + + const planned = runContracts([ + "secure-local-store", + dir, + "--json", + "--plan", + "--retention", + "--store", + "todos", + "--retention-proof", + "todos-exports-backups" + ]); + expect(planned.exitCode).toBe(0); + expect(parseStdoutJson(planned).actions.some((action: { kind: string; status: string; path: string }) => action.kind === "retention_delete" && action.status === "planned" && action.path === backup)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); test("validates with embedded schema", () => { diff --git a/tests/secure-local-store.test.ts b/tests/secure-local-store.test.ts new file mode 100644 index 0000000..ca2b827 --- /dev/null +++ b/tests/secure-local-store.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applySecureLocalStorePlan, + DEFAULT_SECURE_LOCAL_STORE_POLICY, + modeToString, + planSecureLocalStoreLifecycle, + secureLocalStorePolicy, + sqliteSidecarPaths, + validateContract, + SCHEMA_IDS +} from "../src"; + +function tempHome(): string { + return mkdtempSync(join(tmpdir(), "contracts-secure-store-")); +} + +function makeTodosStore(home: string) { + const todosDir = join(home, ".hasna", "todos"); + const backupsDir = join(todosDir, "backups"); + mkdirSync(backupsDir, { recursive: true }); + const db = join(todosDir, "todos.db"); + const wal = `${db}-wal`; + const shm = `${db}-shm`; + const backup = join(backupsDir, "old.jsonl"); + writeFileSync(db, "not a real database fixture\n"); + writeFileSync(wal, "wal sidecar fixture\n"); + writeFileSync(shm, "shm sidecar fixture\n"); + writeFileSync(backup, "redacted backup fixture\n"); + chmodSync(join(home, ".hasna"), 0o755); + chmodSync(todosDir, 0o755); + chmodSync(backupsDir, 0o755); + chmodSync(db, 0o644); + chmodSync(wal, 0o644); + chmodSync(shm, 0o644); + chmodSync(backup, 0o644); + const old = new Date(Date.now() - 45 * 24 * 60 * 60 * 1000); + utimesSync(backup, old, old); + return { todosDir, backupsDir, db, wal, shm, backup }; +} + +describe("secure local store contract", () => { + test("default policy validates and inventories required stores", () => { + const result = validateContract(SCHEMA_IDS.secureLocalStorePolicy, DEFAULT_SECURE_LOCAL_STORE_POLICY); + expect(result.success).toBe(true); + const storeIds = new Set(DEFAULT_SECURE_LOCAL_STORE_POLICY.stores.map((store) => store.storeId)); + for (const id of ["codewith", "todos", "conversations", "mementos", "knowledge", "projects", "browser", "terminal", "logs", "loops"]) { + expect(storeIds.has(id)).toBe(true); + } + expect(DEFAULT_SECURE_LOCAL_STORE_POLICY.defaults.dryRunDefault).toBe(true); + expect(DEFAULT_SECURE_LOCAL_STORE_POLICY.lifecycle.requireArtifactAllowlist).toBe(true); + }); + + test("filters policy by store id and derives sqlite sidecars", () => { + expect(secureLocalStorePolicy(["todos"]).stores.map((store) => store.storeId)).toEqual(["todos"]); + expect(sqliteSidecarPaths("/tmp/example.db")).toEqual(["/tmp/example.db-wal", "/tmp/example.db-shm"]); + }); + + test("dry-run plans owner-only mode repairs without mutating files", () => { + const home = tempHome(); + try { + const store = makeTodosStore(home); + const plan = planSecureLocalStoreLifecycle({ home, stores: ["todos"] }); + expect(plan.mode).toBe("dry-run"); + expect(plan.summary.planned).toBeGreaterThanOrEqual(4); + expect(plan.actions.some((action) => action.kind === "chmod_dir" && action.path === store.todosDir)).toBe(true); + expect(plan.actions.some((action) => action.kind === "chmod_file" && action.path === store.db && action.expectedMode === "0600")).toBe(true); + expect(modeToString(statSync(store.db).mode)).toBe("0644"); + expect(modeToString(statSync(store.todosDir).mode)).toBe("0755"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("apply mode repairs permissions but does not delete retention artifacts unless explicitly allowed", async () => { + const home = tempHome(); + try { + const store = makeTodosStore(home); + const plan = planSecureLocalStoreLifecycle({ + home, + stores: ["todos"], + apply: true, + includeRetention: true, + activeRecordProofs: ["todos-exports-backups"] + }); + const applied = await applySecureLocalStorePlan(plan); + expect(applied.summary.applied).toBeGreaterThanOrEqual(4); + expect(applied.actions.some((action) => action.kind === "retention_delete" && action.status === "skipped")).toBe(true); + expect(existsSync(store.backup)).toBe(true); + expect(modeToString(statSync(store.db).mode)).toBe("0600"); + expect(modeToString(statSync(store.todosDir).mode)).toBe("0700"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("retention candidates require adapter proof and skip active paths", () => { + const home = tempHome(); + try { + const store = makeTodosStore(home); + const blocked = planSecureLocalStoreLifecycle({ home, stores: ["todos"], includeRetention: true }); + expect(blocked.actions.some((action) => action.kind === "blocked" && action.adapterId === "todos-exports-backups")).toBe(true); + + const active = planSecureLocalStoreLifecycle({ + home, + stores: ["todos"], + includeRetention: true, + activePaths: [store.backup] + }); + expect(active.actions.some((action) => action.kind === "retention_delete" && action.status === "skipped" && action.path === store.backup)).toBe(true); + + const planned = planSecureLocalStoreLifecycle({ + home, + stores: ["todos"], + includeRetention: true, + activeRecordProofs: ["todos-exports-backups"] + }); + expect(planned.actions.some((action) => action.kind === "retention_delete" && action.status === "planned" && action.path === store.backup)).toBe(true); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("lifecycle artifact classes are owner-only even without explicit sensitive globs", () => { + const home = tempHome(); + try { + const logsDir = join(home, ".hasna", "logs"); + mkdirSync(logsDir, { recursive: true }); + const logFile = join(logsDir, "foo.log"); + writeFileSync(logFile, "log fixture\n"); + chmodSync(logFile, 0o644); + const plan = planSecureLocalStoreLifecycle({ home, stores: ["logs"] }); + expect(plan.actions.some((action) => action.kind === "chmod_file" && action.path === logFile && action.expectedMode === "0600")).toBe(true); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("sqlite maintenance requires explicit exclusive access", () => { + const home = tempHome(); + try { + const store = makeTodosStore(home); + const blocked = planSecureLocalStoreLifecycle({ home, stores: ["todos"], includeSqliteMaintenance: true }); + expect(blocked.actions.some((action) => action.kind === "blocked" && action.path === store.db && action.reason.includes("exclusive"))).toBe(true); + + const planned = planSecureLocalStoreLifecycle({ + home, + stores: ["todos"], + includeSqliteMaintenance: true, + assumeExclusiveSqlite: true + }); + expect(planned.actions.some((action) => action.kind === "sqlite_maintenance" && action.path === store.db)).toBe(true); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("symlinks inside stores are blocked and never chmod their targets", async () => { + const home = tempHome(); + try { + const todosDir = join(home, ".hasna", "todos"); + const outsideDir = join(home, "outside"); + mkdirSync(todosDir, { recursive: true }); + mkdirSync(outsideDir, { recursive: true }); + const target = join(outsideDir, "target.db"); + const link = join(todosDir, "todos.db"); + writeFileSync(target, "outside target fixture\n"); + chmodSync(target, 0o644); + symlinkSync(target, link); + + const plan = planSecureLocalStoreLifecycle({ home, stores: ["todos"], apply: true }); + expect(plan.actions.some((action) => action.kind === "blocked" && action.path === link && action.reason.includes("symlink"))).toBe(true); + const applied = await applySecureLocalStorePlan(plan); + expect(applied.actions.some((action) => action.kind === "blocked" && action.path === link)).toBe(true); + expect(modeToString(statSync(target).mode)).toBe("0644"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +});